FPLAN-0410 repo cleanup + integration test bug fixes
VERA cleanup (FPLAN-0410): - Remove all /home/aipass hardcoded paths (portability) - Remove internal Telegram bot code from public repo (6k+ lines) - Remove runtime logs, .claude/settings.local.json, stale files - Clean READMEs across all branches - Restructure spawn handlers into proper 3-layer architecture - Add __init__.py files for proper package imports - Update seedgo standards for public repo context - Add log_structure standard to seedgo Integration test bug fixes: - Fix double @@ display in drone introspection output - Fix spawn registry resolving to wrong file (walk up from package root) - Fix email sender identity always showing as @ai_mail (pass AIPASS_CALLER_CWD) - Fix branch_detection.py relative path resolution against CWD instead of registry dir - Add .trinity/passport.json detection for spawned agents - Archive duplicate json_ops.py, update imports to canonical json_handler - Update .gitignore for runtime logs and .claude/ dirs Tested end-to-end in Docker container (aipass-test:latest): - Spawn → register → send email → receive → reply all verified working Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a59f9c006b
commit
3b45824dc6
-55
@@ -1,55 +0,0 @@
|
||||
# Docker
|
||||
|
||||
|
||||
Docker stores images at /var/lib/docker/ (managed by the Docker daemon). You don't interact with it directly — Docker
|
||||
handles the layers.
|
||||
|
||||
Your cheat sheet:
|
||||
|
||||
┌────────────────────────────┬───────────────────────┐
|
||||
│ Command │ What it does │
|
||||
├────────────────────────────┼───────────────────────┤
|
||||
│ docker start aipass-vscode │ Resume container │
|
||||
├────────────────────────────┼───────────────────────┤
|
||||
│ docker stop aipass-vscode │ Pause container │
|
||||
├────────────────────────────┼───────────────────────┤
|
||||
│ docker images │ List all images │
|
||||
├────────────────────────────┼───────────────────────┤
|
||||
│ docker ps -a │ List all containers │
|
||||
├────────────────────────────┼───────────────────────┤
|
||||
│ docker system df │ Disk usage │
|
||||
├────────────────────────────┼───────────────────────┤
|
||||
│ docker system prune │ Clean up unused stuff │
|
||||
└────────────────────────────┴───────────────────────┘
|
||||
|
||||
The aipass-test image (~260MB) stays until you docker rmi aipass-test. The container stays until you docker rm it.
|
||||
Both survive reboots.
|
||||
|
||||
|
||||
## Git Workflow (inside the container)
|
||||
|
||||
The container is a separate user. It clones from your fork, not the main repo.
|
||||
|
||||
┌─────────────────────────────────┬──────────────────────────────────────────┐
|
||||
│ Command │ What it does │
|
||||
├─────────────────────────────────┼──────────────────────────────────────────┤
|
||||
│ git pull upstream main │ Pull latest from AIOSAI/AIPass (source) │
|
||||
├─────────────────────────────────┼──────────────────────────────────────────┤
|
||||
│ git push origin main │ Push to your fork (Input-X/AIPass) │
|
||||
├─────────────────────────────────┼──────────────────────────────────────────┤
|
||||
│ git remote -v │ Check which remotes are set │
|
||||
├─────────────────────────────────┼──────────────────────────────────────────┤
|
||||
│ git log --oneline -5 │ See recent commits │
|
||||
└─────────────────────────────────┴──────────────────────────────────────────┘
|
||||
|
||||
Remotes:
|
||||
origin = Input-X/AIPass (your fork — push here)
|
||||
upstream = AIOSAI/AIPass (main repo — pull from here)
|
||||
|
||||
Typical flow:
|
||||
1. git pull upstream main # Get latest changes
|
||||
2. Work on your code
|
||||
3. git add . && git commit -m "what you did"
|
||||
4. git push origin main # Push to your fork
|
||||
|
||||
Nothing you do in the container touches AIOSAI/AIPass directly.
|
||||
+5
-3
@@ -60,8 +60,10 @@ aipass_json/
|
||||
.standards/
|
||||
# AIPASS_REGISTRY.json — NOT ignored. Ships with core modules pre-registered.
|
||||
|
||||
# Runtime log files (created by prax logging)
|
||||
**/logs
|
||||
|
||||
# Claude local settings (per-branch, not source code)
|
||||
.claude/
|
||||
|
||||
setup-workspace.sh
|
||||
|
||||
# Local cheat sheets (not source code)
|
||||
# Docker.md
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
# DPLAN-047: Path.home() Purge — AIPass Portability Fix
|
||||
|
||||
**Status:** READY
|
||||
**Created:** 2026-03-06
|
||||
**Priority:** CRITICAL — blocks all Docker/container testing
|
||||
**Parent:** MPLAN-001 (AIPass Public Repo Build)
|
||||
**Triggered by:** Docker container testing revealed `Permission denied: '/home/aipass'` on `drone @seedgo audit aipass`
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Every module in AIPass uses `Path.home()` or hardcoded `/home/aipass` strings to resolve paths at module level (import time). This means:
|
||||
|
||||
- **0 of 10 modules** are portable
|
||||
- **107 module-level bindings** crash on any machine that isn't ours
|
||||
- A pip user running `from aipass.prax import logger` gets `Permission denied` immediately
|
||||
- Docker container (the acceptance test) fails on basic commands
|
||||
|
||||
### Root Cause
|
||||
|
||||
Code was transferred from Dev-Pass (where `/home/aipass` is the fixed root) to AIPass (pip package, root is wherever the user cloned it). The import rewire (FPLAN-0409) fixed `from` statements but didn't touch path resolution.
|
||||
|
||||
### The Fix Pattern
|
||||
|
||||
```python
|
||||
# WRONG — Dev-Pass pattern (breaks everywhere else)
|
||||
SYSTEM_LOGS_DIR = Path.home() / "system_logs"
|
||||
BRANCH_REGISTRY = Path.home() / "BRANCH_REGISTRY.json"
|
||||
API_ROOT = Path.home() / "aipass_core" / "api"
|
||||
|
||||
# RIGHT — Package-relative (works everywhere)
|
||||
# Option A: Walk up from __file__ to find repo root
|
||||
REPO_ROOT = Path(__file__).resolve().parents[N] # N = depth to repo root
|
||||
|
||||
# Option B: Walk-up finder (already used by drone/seedgo)
|
||||
def _find_repo_root() -> Path:
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in [current] + list(current.parents):
|
||||
if (parent / "AIPASS_REGISTRY.json").exists():
|
||||
return parent
|
||||
return Path.cwd()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope — Module Debt Count
|
||||
|
||||
| Module | Path.home() | Hardcoded /home/aipass | Module-Level | Severity |
|
||||
|--------|-------------|----------------------|--------------|----------|
|
||||
| api | 40 | 20 | 33 | CRITICAL |
|
||||
| ai_mail | 35 | 20 | 22 | CRITICAL |
|
||||
| prax | 16 | 23 | 9 | CRITICAL |
|
||||
| flow | 15 | 26 | 6 | HIGH |
|
||||
| trigger | 8 | 14 | 8 | MEDIUM |
|
||||
| seedgo | 10 | 66* | 0 | DONE (seed purged) |
|
||||
| drone | 2 | 0 | 1 | LOW (fallback only) |
|
||||
| cli | 2 | 11 | 2 | LOW |
|
||||
| spawn | 2 | 0 | 2 | LOW |
|
||||
| devpulse | 1 | 0 | 1 | LOW |
|
||||
|
||||
*seedgo 66 count is 59 documentation strings + 7 code. Seed already fixed all runtime breakers.
|
||||
|
||||
---
|
||||
|
||||
## Phase Plan
|
||||
|
||||
### Phase 0: Immediate Unblock (DEV_CENTRAL — done)
|
||||
- [x] AIPASS_REGISTRY.json DEVPULSE absolute path fixed
|
||||
- [x] README.md fake import paths fixed
|
||||
- [x] pyproject.toml missing watchdog dependency added
|
||||
- [x] Seedgo Path.home() purge dispatched to @seed (12 files, AST-verified clean)
|
||||
- [x] Prax setup.py:125 hardcoded `Path("/home/aipass")` fixed
|
||||
- [ ] Commit + push all pending changes
|
||||
|
||||
### Phase 1: Prax Logger Unblock (dispatch to @prax)
|
||||
**Goal:** `from aipass.prax import logger` works in container
|
||||
**Files:** 8 module-level bindings in prax
|
||||
**Key targets:**
|
||||
- config/load.py:51 — SYSTEM_LOGS_DIR = Path.home()
|
||||
- config/load.py:55 — mkdir at import time
|
||||
- setup.py:125 — branch_logs_dir (FIXED)
|
||||
- introspection.py:103 — parts.index('aipass') username assumption
|
||||
- registry/reader.py:34 — BRANCH_REGISTRY_PATH
|
||||
- dashboard/agent_status_writer.py:45 — BRANCH_REGISTRY
|
||||
- monitoring/telegram_command_bot.py:68 — AIPASS_HOME cascade
|
||||
- monitoring/telegram_relay.py:60 — PRAX_MONITOR_CONFIG
|
||||
- log_watchdog.py:44 — SYSTEM_LOGS_DIR duplicate
|
||||
|
||||
**Fix pattern:** Lazy initialization. Don't mkdir at import time. Use repo-relative paths.
|
||||
|
||||
### Phase 2: Core Module Purge (parallel dispatch)
|
||||
**Goal:** drone, cli, spawn, devpulse portable
|
||||
**These are small** — 1-2 fixes each. Can dispatch in parallel.
|
||||
- drone config.py:41 — fallback Path.home() (already has walk-up, just remove fallback)
|
||||
- cli json_handler.py:27 — CLI_ROOT
|
||||
- spawn verify_branch.py — AIPASS_ROOT
|
||||
- devpulse verify_branch.py — AIPASS_ROOT
|
||||
|
||||
### Phase 3: Heavy Modules (parallel dispatch, larger scope)
|
||||
**Goal:** flow, trigger, ai_mail, api portable
|
||||
**These are big** — 6-33 module-level bindings each.
|
||||
|
||||
Strategy: Many of these paths reference Dev-Pass infrastructure that doesn't exist in AIPass (MEMORY_BANK, AI_CENTRAL, BRANCH_REGISTRY.json, telegram bots, daemon). Two options per reference:
|
||||
1. **Rewire to repo-relative** — if the feature exists in AIPass
|
||||
2. **Guard with try/except or conditional** — if it's a Dev-Pass-only feature
|
||||
3. **Remove entirely** — if the code shouldn't be in the public repo at all
|
||||
|
||||
**Decision needed from Patrick:** Which modules should ship functional vs stub in v1.0?
|
||||
- api telegram handlers — probably Dev-Pass only (not for pip users)
|
||||
- ai_mail daemon/dispatch — probably Dev-Pass only
|
||||
- flow AI_CENTRAL aggregation — probably Dev-Pass only
|
||||
- trigger event handlers — probably Dev-Pass only
|
||||
|
||||
### Phase 4: Docker Verification
|
||||
**Goal:** Full test suite passes in container
|
||||
- `pip install -e .` clean
|
||||
- `drone --help` works
|
||||
- `drone systems` works
|
||||
- `drone @seedgo audit aipass` works
|
||||
- `drone @seedgo verify` works
|
||||
- `drone @seedgo list` works
|
||||
- Python imports work: `from aipass.drone import ...`, `from aipass.prax import logger`
|
||||
|
||||
---
|
||||
|
||||
## Key Decision: What Ships in v1.0?
|
||||
|
||||
The massive path debt in api (40), ai_mail (35), flow (15) is because these modules contain Dev-Pass infrastructure code (telegram bots, daemon dispatch, AI_CENTRAL aggregation) that pip users will never use.
|
||||
|
||||
**Option A: Fix everything** — rewire all 131 Path.home() calls. Massive effort, most code is dead weight for pip users.
|
||||
|
||||
**Option B: Strip Dev-Pass code from public modules** — ship only the pip-relevant parts. Faster, cleaner, but requires deciding what's public vs private per module.
|
||||
|
||||
**Option C: Guard at module boundary** — keep all code but wrap Dev-Pass features in `try/import` guards so they don't crash when infrastructure is missing. Middle ground.
|
||||
|
||||
**Recommendation:** Option B for v1.0. Ship lean. The telegram bridge, daemon dispatch, and AI_CENTRAL aggregation are Dev-Pass features. Public users need: drone routing, seedgo auditing, prax logging (basic), flow planning (basic), cli formatting.
|
||||
|
||||
---
|
||||
|
||||
## Container Test Checklist (Acceptance Criteria)
|
||||
|
||||
```bash
|
||||
# In Docker container (clean clone, pip install -e .)
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
|
||||
# Core commands
|
||||
drone --help # Must work
|
||||
drone systems # Must show 10 branches
|
||||
drone @seedgo verify # Must pass 5/5
|
||||
drone @seedgo list # Must show packs
|
||||
drone @seedgo audit aipass # Must run without Permission denied
|
||||
|
||||
# Python imports
|
||||
python3 -c "from aipass.drone.apps.modules.registry import load_registry; print(load_registry())"
|
||||
python3 -c "from aipass.prax import logger; logger.info('test')"
|
||||
python3 -c "from aipass.cli import console, header; header('test')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Shebangs (`#!/home/aipass/.venv/bin/python3`) are cosmetic — don't affect pip imports
|
||||
- Display strings in seedgo standards checkers are documentation, not runtime
|
||||
- The walk-up `_find_registry()` pattern is proven — drone and seedgo already use it
|
||||
- Dev-Pass and AIPass can coexist on same machine (different venvs, different registries)
|
||||
+1
-1
@@ -34,4 +34,4 @@ ENV PATH="/opt/venv/bin:$PATH"
|
||||
EXPOSE 8080
|
||||
# Use the official entrypoint (already in the base image at /usr/bin/entrypoint.sh)
|
||||
# It runs fixuid -> entrypoint.d scripts -> dumb-init code-server
|
||||
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "none", "/home/coder/workspace"]
|
||||
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "none", "/home/coder/workspace/AIPass"]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
2026-03-06 19:26:01 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
|
||||
2026-03-06 19:26:01 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
|
||||
2026-03-06 19:26:01 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
|
||||
2026-03-06 19:26:01 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
|
||||
2026-03-06 19:26:01 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
|
||||
2026-03-06 19:26:27 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
|
||||
2026-03-06 19:26:27 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
|
||||
2026-03-06 19:26:27 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
|
||||
2026-03-06 19:26:27 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
|
||||
2026-03-06 19:26:27 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
|
||||
2026-03-06 20:32:14 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
|
||||
2026-03-06 20:32:14 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
|
||||
2026-03-06 20:32:14 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
|
||||
2026-03-06 20:32:14 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
|
||||
2026-03-06 20:32:14 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
|
||||
2026-03-06 20:32:37 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
|
||||
2026-03-06 20:32:37 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
|
||||
2026-03-06 20:32:37 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
|
||||
2026-03-06 20:32:37 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
|
||||
2026-03-06 20:32:37 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
|
||||
2026-03-06 20:32:42 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
|
||||
2026-03-06 20:32:42 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
|
||||
2026-03-06 20:32:42 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
|
||||
2026-03-06 20:32:42 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
|
||||
2026-03-06 20:32:42 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
|
||||
2026-03-06 20:33:19 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
|
||||
2026-03-06 20:33:19 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
|
||||
2026-03-06 20:33:19 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
|
||||
2026-03-06 20:33:19 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
|
||||
2026-03-06 20:33:19 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
|
||||
2026-03-06 20:33:19 - captured_ai_mail - INFO - [ai_mail] Discovering modules...
|
||||
2026-03-06 20:33:19 - captured_ai_mail - ERROR - [-] aipass.ai_mail.apps.modules.email - import error: No module named 'aipass.dev_central'
|
||||
2026-03-06 20:33:19 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.dispatch
|
||||
2026-03-06 20:33:19 - captured_ai_mail - INFO - [+] aipass.ai_mail.apps.modules.branch_ping
|
||||
2026-03-06 20:33:19 - captured_ai_mail - INFO - [ai_mail] Discovered 2 modules
|
||||
@@ -1 +0,0 @@
|
||||
2026-03-06 20:33:19 - captured_dispatch - INFO - [dispatch] Showing dispatch status
|
||||
@@ -1,29 +0,0 @@
|
||||
2026-03-06 19:25:57 - captured_api - INFO - [api] Discovering modules...
|
||||
2026-03-06 19:25:57 - captured_api - INFO - [+] telegram_service
|
||||
2026-03-06 19:25:57 - captured_api - INFO - [+] telegram_bot
|
||||
2026-03-06 19:25:57 - captured_api - INFO - [+] api_key
|
||||
2026-03-06 19:25:57 - captured_api - ERROR - [-] openrouter_client - import error: name 'OpenAI' is not defined
|
||||
2026-03-06 19:25:57 - captured_api - INFO - [+] usage_tracker
|
||||
2026-03-06 19:25:57 - captured_api - INFO - [api] Discovered 4 modules
|
||||
2026-03-06 19:25:57 - captured_api - WARNING - Unknown command: test
|
||||
2026-03-06 19:26:31 - captured_api - INFO - [api] Discovering modules...
|
||||
2026-03-06 19:26:31 - captured_api - INFO - [+] telegram_service
|
||||
2026-03-06 19:26:31 - captured_api - INFO - [+] telegram_bot
|
||||
2026-03-06 19:26:31 - captured_api - INFO - [+] api_key
|
||||
2026-03-06 19:26:31 - captured_api - ERROR - [-] openrouter_client - import error: name 'OpenAI' is not defined
|
||||
2026-03-06 19:26:31 - captured_api - INFO - [+] usage_tracker
|
||||
2026-03-06 19:26:31 - captured_api - INFO - [api] Discovered 4 modules
|
||||
2026-03-06 19:26:38 - captured_api - INFO - [api] Discovering modules...
|
||||
2026-03-06 19:26:38 - captured_api - INFO - [+] telegram_service
|
||||
2026-03-06 19:26:38 - captured_api - INFO - [+] telegram_bot
|
||||
2026-03-06 19:26:38 - captured_api - INFO - [+] api_key
|
||||
2026-03-06 19:26:38 - captured_api - ERROR - [-] openrouter_client - import error: name 'OpenAI' is not defined
|
||||
2026-03-06 19:26:38 - captured_api - INFO - [+] usage_tracker
|
||||
2026-03-06 19:26:38 - captured_api - INFO - [api] Discovered 4 modules
|
||||
2026-03-06 19:26:41 - captured_api - INFO - [api] Discovering modules...
|
||||
2026-03-06 19:26:41 - captured_api - INFO - [+] telegram_service
|
||||
2026-03-06 19:26:41 - captured_api - INFO - [+] telegram_bot
|
||||
2026-03-06 19:26:41 - captured_api - INFO - [+] api_key
|
||||
2026-03-06 19:26:41 - captured_api - ERROR - [-] openrouter_client - import error: name 'OpenAI' is not defined
|
||||
2026-03-06 19:26:41 - captured_api - INFO - [+] usage_tracker
|
||||
2026-03-06 19:26:41 - captured_api - INFO - [api] Discovered 4 modules
|
||||
@@ -1,6 +0,0 @@
|
||||
2026-03-06 19:26:45 - captured_aggregate_central - INFO - [aggregate_central] Starting central aggregation
|
||||
2026-03-06 19:26:45 - captured_aggregate_central - INFO - [aggregate_central] Processing branch: flow
|
||||
2026-03-06 19:26:45 - captured_aggregate_central - INFO - [aggregate_central] SUCCESS: Aggregation complete: 0 active, 0 recently closed
|
||||
2026-03-06 20:20:39 - captured_aggregate_central - INFO - [aggregate_central] Starting central aggregation
|
||||
2026-03-06 20:20:39 - captured_aggregate_central - INFO - [aggregate_central] Processing branch: flow
|
||||
2026-03-06 20:20:39 - captured_aggregate_central - INFO - [aggregate_central] SUCCESS: Aggregation complete: 1 active, 0 recently closed
|
||||
@@ -1,4 +0,0 @@
|
||||
2026-03-06 19:26:52 - captured_close_plan - INFO - [close_plan] Deleted empty template file: /tmp/FPLAN-0001_test_plan_from_devpulse_2026-03-06.md
|
||||
2026-03-06 19:26:52 - captured_close_plan - INFO - [close_plan] Removed FPLAN-0001 from registry
|
||||
2026-03-06 20:21:02 - captured_close_plan - INFO - [close_plan] Deleted empty template file: /home/coder/workspace/src/aipass/flow/FPLAN-0002_2026-03-06.md
|
||||
2026-03-06 20:21:02 - captured_close_plan - INFO - [close_plan] Removed FPLAN-0002 from registry
|
||||
@@ -1,2 +0,0 @@
|
||||
2026-03-06 19:26:45 - captured_create_plan - INFO - [create_plan] Created FPLAN-0001 in /tmp
|
||||
2026-03-06 20:20:39 - captured_create_plan - INFO - [create_plan] Created FPLAN-0002 in flow
|
||||
@@ -1,77 +0,0 @@
|
||||
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 19:25:13 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 19:25:53 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 19:26:45 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 19:26:49 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 19:26:52 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 20:20:13 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 20:20:22 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 20:20:39 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 20:21:02 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 20:37:06 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Skipped post_close_runner - no handle_command()
|
||||
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: aggregate_central
|
||||
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: registry_monitor
|
||||
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: restore_plan
|
||||
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: list_plans
|
||||
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: close_plan
|
||||
2026-03-06 20:38:04 - captured_flow - INFO - [FLOW] Loaded module: create_plan
|
||||
@@ -1,2 +0,0 @@
|
||||
2026-03-06 19:25:53 - captured_list_plans - INFO - [list_plans] No plans in registry
|
||||
2026-03-06 19:26:49 - captured_list_plans - INFO - [list_plans] Listed plans (filter: open)
|
||||
@@ -1,2 +0,0 @@
|
||||
2026-03-06 20:19:23 - captured_monitor_module - INFO - Starting unified monitoring (args: [])
|
||||
2026-03-06 20:19:23 - captured_monitor_module - INFO - All monitoring threads started
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "1.0.0"
|
||||
version = "2.0.0"
|
||||
description = "Orchestration framework for autonomous AI agent ecosystems"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
2026-03-06 19:24:36 - captured_seedgo_verify - INFO - Seedgo verify: 5/5 (100%)
|
||||
@@ -1,78 +0,0 @@
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Starting standards compliance check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - ERROR - [standards_checklist] Error reading branch registry: name 'BRANCH_REGISTRY_PATH' is not defined
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running IMPORTS standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] IMPORTS check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running ARCHITECTURE standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] ARCHITECTURE check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running NAMING standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] NAMING check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running CLI standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] CLI check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running HANDLERS standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] HANDLERS check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running MODULES standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] MODULES check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running DOCUMENTATION standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] DOCUMENTATION check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running JSON_STRUCTURE standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] JSON_STRUCTURE check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running TESTING standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] TESTING check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running ERROR_HANDLING standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] ERROR_HANDLING check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running ENCAPSULATION standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] ENCAPSULATION check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running TRIGGER standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] TRIGGER check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_LEVEL standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] LOG_LEVEL check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running CLI_FLAGS standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] CLI_FLAGS check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_HANDLER standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] LOG_HANDLER check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_VISIBILITY standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] LOG_VISIBILITY check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running PERMISSION_FLAGS standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] PERMISSION_FLAGS check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Running SHEBANG standard check on src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] SHEBANG check complete: 0/100
|
||||
2026-03-06 19:25:42 - captured_standards_checklist - INFO - [standards_checklist] Standards check complete: 0% average compliance
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Starting standards compliance check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - ERROR - [standards_checklist] Error reading branch registry: name 'BRANCH_REGISTRY_PATH' is not defined
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running IMPORTS standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] IMPORTS check complete: 80/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running ARCHITECTURE standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] ARCHITECTURE check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running NAMING standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] NAMING check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running CLI standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] CLI check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running HANDLERS standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] HANDLERS check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running MODULES standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] MODULES check complete: 60/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running DOCUMENTATION standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] DOCUMENTATION check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running JSON_STRUCTURE standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] JSON_STRUCTURE check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running TESTING standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] TESTING check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running ERROR_HANDLING standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] ERROR_HANDLING check complete: 66/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running ENCAPSULATION standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] ENCAPSULATION check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running TRIGGER standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] TRIGGER check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_LEVEL standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] LOG_LEVEL check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running CLI_FLAGS standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] CLI_FLAGS check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_HANDLER standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] LOG_HANDLER check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running LOG_VISIBILITY standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] LOG_VISIBILITY check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running PERMISSION_FLAGS standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] PERMISSION_FLAGS check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Running SHEBANG standard check on /home/coder/workspace/src/aipass/drone/apps/modules/config.py
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] SHEBANG check complete: 100/100
|
||||
2026-03-06 19:25:46 - captured_standards_checklist - INFO - [standards_checklist] Standards check complete: 94% average compliance
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enabledMcpjsonServers": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-03-06T03:16:10.842297",
|
||||
"created": "2026-03-06T14:27:27.887566",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [],
|
||||
|
||||
+53
-276
@@ -1,308 +1,85 @@
|
||||
# AI_MAIL - Branch Documentation
|
||||
# ai_mail
|
||||
|
||||
## Overview
|
||||
Inter-agent messaging for AIPass. File-based email system that lets agents send, receive, and process messages using `@branch` addresses. No SMTP, no external services — just JSON files and symbolic routing.
|
||||
|
||||
**Location**: `/home/aipass/aipass_core/ai_mail`
|
||||
**Profile**: Communication Infrastructure
|
||||
**Purpose**: Branch-to-branch email system providing file-based messaging for AI branches
|
||||
**Created**: 2025-11-07
|
||||
|
||||
## What I Do
|
||||
|
||||
AI_MAIL provides internal email system for AI branches within AIPass. Core responsibilities include:
|
||||
|
||||
- Deliver email messages between AI branches
|
||||
- Manage inbox, sent, and deleted mailboxes for each branch
|
||||
- Track email lifecycle: new → opened → closed
|
||||
- Support reply chains with auto-close behavior
|
||||
- Auto-generate per-branch configs (user_config.json in [branch]_json/)
|
||||
- Auto-detect calling branch via PWD/CWD for sender identity
|
||||
- Update dashboard on delivery (DASHBOARD.local.json)
|
||||
- Dispatch daemon: continuous polling engine spawns agents for `--dispatch` emails
|
||||
- Single instance per branch locking (PID-based dispatch lock)
|
||||
- Reply chain validation (sender must match dispatched_to)
|
||||
- Maintain 100% CLI standards compliance
|
||||
- Preserve branch email identities (no impersonation)
|
||||
|
||||
## What I Don't Do
|
||||
|
||||
- No SMTP/IMAP protocols
|
||||
- No external email delivery
|
||||
- No human email accounts
|
||||
- No centralized identity (each branch has independent config)
|
||||
- No manual setup required from branches
|
||||
|
||||
## How I Work
|
||||
|
||||
File-based architecture using JSON for message storage. PWD auto-detection finds calling branch, checks for local config in [branch]_json/, generates config if missing, sends from detected branch identity.
|
||||
|
||||
3-layer handler architecture:
|
||||
- **Modules**: Orchestrate commands
|
||||
- **Handlers**: Execute business logic
|
||||
- **Prax**: Provides logging infrastructure
|
||||
|
||||
## Architecture
|
||||
|
||||
**Pattern**: Modular architecture
|
||||
**Structure**: apps/ directory with modules/ and handlers/ subdirectories
|
||||
**Orchestrator**: apps/ai_mail.py - auto-discovers and routes to modules
|
||||
**Module Interface**: All modules implement handle_command(args) -> bool
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
apps/
|
||||
├── ai_mail.py # Main orchestrator
|
||||
├── __init__.py
|
||||
├── modules/
|
||||
│ ├── email.py # Core email functionality
|
||||
│ ├── branch_ping.py # Branch health monitoring
|
||||
│ ├── dispatch.py # Dispatch status tracking
|
||||
│ └── extensions/ # Module extensions
|
||||
├── handlers/
|
||||
│ ├── central_writer.py # Central file updates
|
||||
│ ├── dispatch/ # Dispatch operations
|
||||
│ │ ├── daemon.py # Continuous dispatch daemon (polling + spawning)
|
||||
│ │ ├── pending_work.py # Per-branch pending work tracking
|
||||
│ │ └── status.py # Dispatch log and status
|
||||
│ ├── email/ # Email operations
|
||||
│ │ ├── create.py # Create email messages
|
||||
│ │ ├── delivery.py # Deliver to branch inboxes (write-only, no spawning)
|
||||
│ │ ├── format.py # Email formatting
|
||||
│ │ ├── footer.py # Auto-footer for outgoing emails
|
||||
│ │ ├── header.py # Email header formatting
|
||||
│ │ ├── inbox_cleanup.py # Inbox cleanup operations
|
||||
│ │ ├── inbox_lock.py # Inbox file locking
|
||||
│ │ ├── inbox_ops.py # Inbox operations
|
||||
│ │ ├── lock_utils.py # PID-based dispatch lock per branch
|
||||
│ │ ├── purge.py # Auto-purge sent/deleted when >10 items
|
||||
│ │ └── reply.py # Reply handling with chain validation
|
||||
│ ├── json_utils/ # JSON operations
|
||||
│ │ └── json_handler.py # JSON file operations
|
||||
│ ├── monitoring/ # System monitoring
|
||||
│ │ ├── data_ops.py # Data operations
|
||||
│ │ ├── errors.py # Error handling
|
||||
│ │ └── memory.py # Memory health checks
|
||||
│ ├── persistence/ # Data persistence
|
||||
│ │ └── json_ops.py # JSON persistence ops
|
||||
│ ├── registry/ # Branch registry
|
||||
│ │ ├── load.py # Load registry
|
||||
│ │ ├── read.py # Read registry data
|
||||
│ │ ├── update.py # Update registry
|
||||
│ │ └── validate.py # Validate registry
|
||||
│ ├── trigger/ # Event consumers
|
||||
│ │ └── error_handler.py # Handle error_detected events
|
||||
│ └── users/ # User/branch config
|
||||
│ ├── branch_detection.py # Auto-detect calling branch
|
||||
│ ├── config_generator.py # Generate branch configs
|
||||
│ ├── load.py # Load configurations
|
||||
│ └── user.py # User operations
|
||||
├── extensions/ # App extensions (placeholder)
|
||||
├── plugins/ # Plugins (placeholder)
|
||||
└── json_templates/ # JSON templates
|
||||
├── custom/ # Custom templates
|
||||
├── default/ # Default templates
|
||||
└── registry/ # Registry templates
|
||||
```
|
||||
|
||||
### Modules
|
||||
|
||||
- `email` - Core email functionality (send, inbox, view, reply, close, sent, contacts)
|
||||
- `branch_ping` - Branch memory health monitoring
|
||||
- `dispatch` - Dispatch status tracking, log, and daemon management
|
||||
|
||||
### Handlers (by domain)
|
||||
|
||||
**Dispatch**: daemon, pending_work, status
|
||||
**Email**: create, delivery, footer, format, header, inbox_cleanup, inbox_lock, inbox_ops, lock_utils, purge, reply
|
||||
**JSON Utils**: json_handler
|
||||
**Monitoring**: data_ops, errors, memory
|
||||
**Persistence**: json_ops
|
||||
**Registry**: load, read, update, validate
|
||||
**Trigger**: error_handler
|
||||
**Users**: branch_detection, config_generator, load, user
|
||||
**Status:** Building. Core email workflow (send/inbox/reply/close) is functional. Dispatch system is working.
|
||||
|
||||
## Usage
|
||||
|
||||
Primary commands: `inbox`, `view`, `reply`, `close`, `send`, `sent`, `contacts`, `ping`, `dispatch`.
|
||||
|
||||
### Email Lifecycle v2
|
||||
|
||||
AI_MAIL uses a 3-state email model:
|
||||
|
||||
**States:**
|
||||
- `new` - Email just delivered, never viewed
|
||||
- `opened` - Email content has been viewed, awaiting resolution
|
||||
- `closed` - Email resolved (replied or dismissed), archived to deleted
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
Email sent → inbox (status: "new")
|
||||
↓
|
||||
view <id> → status: "opened"
|
||||
↓
|
||||
┌──────────┴──────────┐
|
||||
↓ ↓
|
||||
reply <id> "msg" close <id>
|
||||
↓ ↓
|
||||
auto-closes closes
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
archived to deleted/
|
||||
```
|
||||
|
||||
### Basic Commands
|
||||
### CLI (via drone)
|
||||
|
||||
```bash
|
||||
# Send email
|
||||
drone @ai_mail send @recipient "Subject" "Message"
|
||||
# Send a message
|
||||
drone @ai_mail send @flow "Bug Report" "Found an issue in plan closing"
|
||||
|
||||
# Send with dispatch (auto-execute at recipient)
|
||||
drone @ai_mail send @recipient "Task" "Details" --dispatch
|
||||
|
||||
# Broadcast to all branches
|
||||
drone @ai_mail send @all "Announcement" "Message"
|
||||
|
||||
# Check inbox (shows new + opened emails)
|
||||
# Check inbox
|
||||
drone @ai_mail inbox
|
||||
|
||||
# View email content (marks as opened)
|
||||
# View a message (marks as opened)
|
||||
drone @ai_mail view <message_id>
|
||||
|
||||
# Reply to email (sends reply + auto-closes original)
|
||||
drone @ai_mail reply <message_id> "Your reply message"
|
||||
# Reply (auto-closes original)
|
||||
drone @ai_mail reply <message_id> "Fixed in v2.1"
|
||||
|
||||
# Close email without replying (archives to deleted)
|
||||
# Close without reply
|
||||
drone @ai_mail close <message_id>
|
||||
|
||||
# View sent messages
|
||||
drone @ai_mail sent
|
||||
|
||||
# List contacts
|
||||
drone @ai_mail contacts
|
||||
# Send with dispatch flag (recipient auto-executes the task)
|
||||
drone @ai_mail send @flow "Task" "Details here" --dispatch
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
### Python
|
||||
|
||||
```bash
|
||||
# 'read' command now behaves like 'view'
|
||||
# (marks as opened, does NOT archive)
|
||||
drone @ai_mail read <message_id>
|
||||
```python
|
||||
from aipass.ai_mail.apps.modules.email import handle_command
|
||||
|
||||
# Module interface — all commands go through handle_command
|
||||
handle_command(["send", "@flow", "Subject", "Message body"])
|
||||
handle_command(["inbox"])
|
||||
```
|
||||
|
||||
### Dispatch System (v3.0)
|
||||
## Email Lifecycle
|
||||
|
||||
The `--dispatch` flag marks emails for autonomous execution. The **dispatch daemon** polls inboxes and spawns agents — delivery.py is write-only.
|
||||
Messages follow a 3-state model:
|
||||
|
||||
**Architecture:**
|
||||
- `delivery.py` writes to inbox + sends desktop notification. No spawning.
|
||||
- `daemon.py` polls all branch inboxes every 5 min for `--dispatch` emails
|
||||
- Agents spawned via `claude -c -p` from the branch's CWD (auto-continues most recent session)
|
||||
- Agents are ephemeral (wake, do work, exit). The daemon is the continuity.
|
||||
- All safety limits configured in `safety_config.json`
|
||||
|
||||
**Running the daemon:**
|
||||
```bash
|
||||
python3 apps/handlers/dispatch/daemon.py # Standalone
|
||||
ai_mail dispatch daemon # Via module
|
||||
tmux new-session -d -s dispatch 'python3 apps/handlers/dispatch/daemon.py'
|
||||
```
|
||||
new → opened → closed
|
||||
```
|
||||
|
||||
**Safety features:**
|
||||
- **Kill switch**: `touch /home/aipass/.aipass/autonomous_pause` freezes all dispatches
|
||||
- **Max turns**: 15 per wake (configurable)
|
||||
- **Max dispatches/branch/day**: 10 (configurable, resets at midnight)
|
||||
- **Single instance lock**: PID-based `.dispatch.lock` prevents concurrent agents per branch
|
||||
- **DEV_CENTRAL protection**: @dev_central is never auto-dispatched
|
||||
- **Notification throttling**: Max 3 desktop notifications per recipient in 30s window
|
||||
- **Stale lock timeout**: 10 minutes (auto-cleanup of dead locks)
|
||||
- **new** — Delivered to inbox, never viewed
|
||||
- **opened** — Viewed by recipient, awaiting action
|
||||
- **closed** — Replied or dismissed, archived automatically
|
||||
|
||||
### Direct Module Access
|
||||
## Dispatch System
|
||||
|
||||
```bash
|
||||
# Email operations
|
||||
python3 apps/ai_mail.py email [command]
|
||||
The `--dispatch` flag marks emails for autonomous execution. A polling daemon watches inboxes and spawns agents to process dispatch emails automatically.
|
||||
|
||||
# Branch health check
|
||||
python3 apps/ai_mail.py ping
|
||||
- Agents are ephemeral (wake, do work, exit)
|
||||
- Safety limits: max turns per wake, max dispatches per branch per day
|
||||
- PID-based locking prevents concurrent agents per branch
|
||||
- Failed agents trigger bounce emails back to sender
|
||||
|
||||
# Dispatch status
|
||||
python3 apps/ai_mail.py dispatch status
|
||||
## Architecture
|
||||
|
||||
# Start dispatch daemon
|
||||
python3 apps/ai_mail.py dispatch daemon
|
||||
Follows the standard AIPass 3-layer pattern:
|
||||
|
||||
```
|
||||
ai_mail/
|
||||
├── apps/
|
||||
│ ├── ai_mail.py # Entry point (auto-discovers modules)
|
||||
│ ├── modules/
|
||||
│ │ ├── email.py # Send, inbox, view, reply, close, contacts
|
||||
│ │ ├── dispatch.py # Dispatch status, daemon, wake
|
||||
│ │ └── branch_ping.py # Branch health monitoring
|
||||
│ └── handlers/
|
||||
│ ├── email/ # Delivery, formatting, inbox ops, purge
|
||||
│ ├── dispatch/ # Daemon, wake, monitoring
|
||||
│ ├── registry/ # Branch registry read/update
|
||||
│ └── users/ # Branch detection, config generation
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
## Dependencies
|
||||
|
||||
**Depends On**:
|
||||
- PRAX - Logging infrastructure
|
||||
- CLI - Display and formatting
|
||||
- DRONE - Command routing
|
||||
|
||||
**Provides To**:
|
||||
- All branches - Email communication
|
||||
- AIPASS - System coordination
|
||||
|
||||
## Memory System
|
||||
|
||||
### Core Files
|
||||
- `AI_MAIL.id.json` - Branch identity and architecture
|
||||
- `AI_MAIL.local.json` - Session history (max 600 lines)
|
||||
- `AI_MAIL.observations.json` - Collaboration patterns (max 600 lines)
|
||||
- `AI_MAIL.ai_mail.json` - Email dashboard
|
||||
|
||||
### Mailbox Structure
|
||||
- `ai_mail.local/inbox.json` - Incoming messages (new + opened status)
|
||||
- `ai_mail.local/sent/` - Sent messages (individual files, auto-purged at >10)
|
||||
- `ai_mail.local/deleted/` - Closed/archived messages (individual files, auto-purged at >10)
|
||||
- `ai_mail.local/.dispatch.lock` - PID-based dispatch lock (when agent active)
|
||||
- `ai_mail.local/daemon_state.json` - Daemon daily counts and session tracking
|
||||
- `ai_mail.local/dispatch_daemon.log` - Daemon activity log
|
||||
- `safety_config.json` - Kill switch, poll interval, max turns/dispatches, autonomous branch list
|
||||
|
||||
**Email Schema (v2):**
|
||||
```json
|
||||
{
|
||||
"id": "abc123",
|
||||
"from": "@sender",
|
||||
"from_name": "SENDER Branch",
|
||||
"to": "@recipient",
|
||||
"subject": "Subject line",
|
||||
"message": "Message body",
|
||||
"status": "new", // new | opened | closed
|
||||
"timestamp": "2025-11-30 12:00:00",
|
||||
"thread_id": null, // For future threading
|
||||
"reply_to": null // Original message ID if reply
|
||||
}
|
||||
```
|
||||
|
||||
### Health Monitoring
|
||||
- 🟢 **Healthy**: Under 80% of limits
|
||||
- 🟡 **Warning**: 80-100% of limits
|
||||
- 🔴 **Critical**: Over limits (compression needed)
|
||||
|
||||
## Standards Compliance
|
||||
|
||||
Seed audit score: **89%** (passing)
|
||||
|
||||
Following AIPass code standards:
|
||||
- 100% CLI integration
|
||||
- Proper import patterns
|
||||
- 3-layer architecture
|
||||
- Handler independence
|
||||
- Auto-discovery patterns
|
||||
|
||||
## Development Notes
|
||||
|
||||
- Code is truth - fail honestly
|
||||
- Simple solutions over complex architecture
|
||||
- Test incrementally, preserve what works
|
||||
- Each instance isolated with own memory
|
||||
- Never explain context again - memories persist
|
||||
|
||||
---
|
||||
|
||||
|
||||
*Last Updated: 2026-02-28*
|
||||
*Maintained by: AI_MAIL Branch*
|
||||
- `prax` — Logging
|
||||
- `cli` — Display formatting
|
||||
- `drone` — Command routing and `@branch` resolution
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""AI Mail - Inter-branch messaging for AIPass."""
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"module_name": "branch_ping",
|
||||
"version": "1.0.0",
|
||||
"timestamp": "2025-11-13",
|
||||
"config": {
|
||||
"auto_save": true,
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"module_name": "branch_ping",
|
||||
"created": "2025-11-13",
|
||||
"last_updated": "2025-11-13",
|
||||
"operations_total": 0,
|
||||
"operations_successful": 0,
|
||||
"operations_failed": 0
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-03-06T20:33:19.802974",
|
||||
"operation": "ping_executed",
|
||||
"data": {
|
||||
"branch": "AI_MAIL",
|
||||
"local_count": 0,
|
||||
"obs_count": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"last_updated": "2026-03-06T20:33:19.797905",
|
||||
"active_branches": {
|
||||
"/home/coder/workspace/src/aipass/ai_mail": {
|
||||
"branch_name": "AI_MAIL",
|
||||
"last_ping": "2026-03-06T20:33:19.797892",
|
||||
"local_md": {
|
||||
"line_count": 0,
|
||||
"status": "\ud83d\udfe2 Healthy"
|
||||
},
|
||||
"observations_md": {
|
||||
"line_count": 0,
|
||||
"status": "\ud83d\udfe2 Healthy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"total_branches": 1,
|
||||
"green_status": 0,
|
||||
"yellow_status": 0,
|
||||
"red_status": 0
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,8 @@ from typing import Dict, Any, Optional, List
|
||||
# Handle broken pipe gracefully (e.g. output piped to head)
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
|
||||
# Pre-import dashboard to ensure path is resolved before module discovery
|
||||
# This prevents import failures in handlers that depend on devpulse dashboard
|
||||
try:
|
||||
from aipass.dev_central.devpulse.apps.modules.dashboard import update_section as _update_section # noqa: F401
|
||||
except ImportError:
|
||||
# Dashboard is optional - branch works without it
|
||||
_update_section = None # type: ignore
|
||||
# Dashboard integration (optional, requires dev_central package)
|
||||
_update_section = None # type: ignore
|
||||
|
||||
# AIPass infrastructure imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
@@ -43,7 +43,7 @@ def _find_real_caller():
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = filepath.split("/")
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part in ("aipass", "MEMORY_BANK", "Nexus"):
|
||||
if i + 1 < len(parts):
|
||||
|
||||
@@ -22,7 +22,7 @@ Aggregates branch inbox stats and writes to AI_MAIL.central.json.
|
||||
This file serves as AI_MAIL's API output for AIPASS dashboard integration.
|
||||
|
||||
Architecture:
|
||||
- Scans all ai_mail.local/inbox.json files across the system
|
||||
- Scans all .ai_mail.local/inbox.json files across the system
|
||||
- Calculates per-branch unread/total message counts
|
||||
- Writes aggregated stats to AI_CENTRAL/AI_MAIL.central.json (under repo root)
|
||||
"""
|
||||
@@ -71,9 +71,9 @@ BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json"
|
||||
|
||||
def find_all_inbox_files() -> List[Path]:
|
||||
"""
|
||||
Find all inbox.json files in ai_mail.local directories.
|
||||
Find all inbox.json files in .ai_mail.local directories.
|
||||
|
||||
Scans the repo root directory for ai_mail.local/inbox.json files.
|
||||
Scans the repo root directory for .ai_mail.local/inbox.json files.
|
||||
Excludes backup directories to avoid counting archived data.
|
||||
|
||||
Returns:
|
||||
@@ -84,8 +84,8 @@ def find_all_inbox_files() -> List[Path]:
|
||||
"""
|
||||
inbox_files = []
|
||||
|
||||
# Search pattern: any directory ending in ai_mail.local containing inbox.json
|
||||
for ai_mail_dir in _REPO_ROOT.rglob("ai_mail.local"):
|
||||
# Search pattern: any directory ending in .ai_mail.local containing inbox.json
|
||||
for ai_mail_dir in _REPO_ROOT.rglob(".ai_mail.local"):
|
||||
# Skip backup/archive directories (but NOT backup_system branch itself)
|
||||
path_str = str(ai_mail_dir)
|
||||
if ".backup" in path_str or ".archive" in path_str or "/backups/" in path_str:
|
||||
@@ -102,10 +102,10 @@ def extract_branch_name(inbox_path: Path) -> str:
|
||||
"""
|
||||
Extract branch name from inbox.json path.
|
||||
|
||||
Given: .../seed/ai_mail.local/inbox.json
|
||||
Given: .../seed/.ai_mail.local/inbox.json
|
||||
Returns: SEED
|
||||
|
||||
Given: .../prax/ai_mail.local/inbox.json
|
||||
Given: .../prax/.ai_mail.local/inbox.json
|
||||
Returns: PRAX
|
||||
|
||||
Args:
|
||||
@@ -114,7 +114,7 @@ def extract_branch_name(inbox_path: Path) -> str:
|
||||
Returns:
|
||||
Uppercase branch name
|
||||
"""
|
||||
# Parent of ai_mail.local is the branch directory
|
||||
# Parent of .ai_mail.local is the branch directory
|
||||
branch_dir = inbox_path.parent.parent
|
||||
branch_name = branch_dir.name.upper()
|
||||
|
||||
@@ -280,7 +280,7 @@ def update_central() -> Dict[str, Any]:
|
||||
Should be called whenever mail is sent/received to keep dashboard in sync.
|
||||
|
||||
Process:
|
||||
1. Scans all branch ai_mail.local/inbox.json files
|
||||
1. Scans all branch .ai_mail.local/inbox.json files
|
||||
2. Aggregates unread and total message counts per branch
|
||||
3. Calculates system-wide totals
|
||||
4. Writes results to AI_CENTRAL/AI_MAIL.central.json (under repo root)
|
||||
|
||||
@@ -64,9 +64,9 @@ _AI_MAIL_DIR = Path(__file__).resolve().parents[3] # ai_mail/
|
||||
|
||||
# Paths
|
||||
CONFIG_FILE = _AI_MAIL_DIR / "safety_config.json"
|
||||
DAEMON_STATE_FILE = _AI_MAIL_DIR / "ai_mail.local" / "daemon_state.json"
|
||||
DAEMON_LOG_FILE = _AI_MAIL_DIR / "ai_mail.local" / "dispatch_daemon.log"
|
||||
DAEMON_PID_FILE = _AI_MAIL_DIR / "ai_mail.local" / "daemon.pid"
|
||||
DAEMON_STATE_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "daemon_state.json"
|
||||
DAEMON_LOG_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "dispatch_daemon.log"
|
||||
DAEMON_PID_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "daemon.pid"
|
||||
BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json"
|
||||
|
||||
# Telegram notifications (scheduler bot)
|
||||
@@ -168,7 +168,7 @@ def _set_session_name(branch_path: Path, name: str) -> bool:
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
if not lock_file.exists():
|
||||
return None
|
||||
try:
|
||||
@@ -212,7 +212,7 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
|
||||
def _acquire_lock(branch_path: Path, pid: int) -> tuple[bool, str]:
|
||||
"""Acquire dispatch lock for branch. Atomic creation via O_CREAT|O_EXCL."""
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_data = {
|
||||
"pid": pid,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
@@ -336,7 +336,7 @@ def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
Also retries opened dispatch emails orphaned for >30 min (agent crashed
|
||||
before completing).
|
||||
"""
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
|
||||
inbox_data = _read_json(inbox_file)
|
||||
if inbox_data is None:
|
||||
return None
|
||||
@@ -372,7 +372,7 @@ def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
|
||||
def count_new_emails(branch_path: Path) -> int:
|
||||
"""Count new (unread) emails in a branch's inbox."""
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
|
||||
inbox_data = _read_json(inbox_file)
|
||||
if inbox_data is None:
|
||||
return 0
|
||||
@@ -409,7 +409,7 @@ def spawn_agent(
|
||||
subject = message.get("subject", "")
|
||||
max_turns = config.get("max_turns_per_wake", 15)
|
||||
|
||||
lock_file_path = str(branch_path / "ai_mail.local" / ".dispatch.lock")
|
||||
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
|
||||
|
||||
# Prompt — no lock cleanup instruction (dispatch_monitor handles it)
|
||||
prompt = (
|
||||
@@ -426,7 +426,7 @@ def spawn_agent(
|
||||
|
||||
# Build monitor command (dispatch_monitor wraps claude, handles bounce + lock cleanup)
|
||||
MONITOR_SCRIPT = Path(__file__).resolve().parent / "dispatch_monitor.py"
|
||||
LOG_DIR = branch_path / "ai_mail.local"
|
||||
LOG_DIR = branch_path / ".ai_mail.local"
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STDERR_LOG = str(LOG_DIR / "agent_stderr.log")
|
||||
|
||||
@@ -504,6 +504,8 @@ def is_protected_branch(branch_email: str) -> bool:
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
|
||||
if sys.platform != "linux":
|
||||
return 'interactive'
|
||||
try:
|
||||
with open(f'/proc/{pid_str}/environ', 'rb') as f:
|
||||
data = f.read()
|
||||
@@ -540,6 +542,8 @@ def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
if sys.platform != "linux":
|
||||
continue
|
||||
cwd = os.readlink(f'/proc/{pid_str}/cwd')
|
||||
if Path(cwd).resolve() == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
|
||||
@@ -124,7 +124,7 @@ def main():
|
||||
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
||||
spawn_env.pop(key)
|
||||
|
||||
# Extract CWD from lock file path (branch_path/ai_mail.local/.dispatch.lock)
|
||||
# Extract CWD from lock file path (branch_path/.ai_mail.local/.dispatch.lock)
|
||||
lock_path = Path(lock_file)
|
||||
branch_path = lock_path.parent.parent
|
||||
cwd = str(branch_path)
|
||||
|
||||
@@ -32,8 +32,8 @@ PENDING_WORK_FILENAME = ".pending_work.json"
|
||||
def _get_pending_path(branch_path: Path) -> Path:
|
||||
"""Get the pending work file path for a branch."""
|
||||
if branch_path == Path("/"):
|
||||
return Path.cwd() / "ai_mail.local" / PENDING_WORK_FILENAME
|
||||
return branch_path / "ai_mail.local" / PENDING_WORK_FILENAME
|
||||
return Path.cwd() / ".ai_mail.local" / PENDING_WORK_FILENAME
|
||||
return branch_path / ".ai_mail.local" / PENDING_WORK_FILENAME
|
||||
|
||||
|
||||
def load_pending_work(branch_path: Path) -> Dict[str, Any]:
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import Dict, Any, List, Optional
|
||||
|
||||
# Dispatch log location (package-relative)
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[3] # ai_mail/
|
||||
DISPATCH_LOG_FILE = _AI_MAIL_DIR / "ai_mail.local" / "dispatch_log.json"
|
||||
DISPATCH_LOG_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "dispatch_log.json"
|
||||
|
||||
|
||||
def load_dispatch_log() -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -122,7 +122,7 @@ def _read_json(filepath: Path) -> Optional[dict]:
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[dict]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
if not lock_file.exists():
|
||||
return None
|
||||
try:
|
||||
@@ -158,7 +158,7 @@ def _check_lock(branch_path: Path) -> Optional[dict]:
|
||||
|
||||
def _acquire_lock(branch_path: Path, pid: int) -> Tuple[bool, str]:
|
||||
"""Acquire dispatch lock for branch. Atomic creation."""
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_data = {
|
||||
"pid": pid,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
@@ -218,6 +218,8 @@ def _set_session_name(branch_path: Path, name: str) -> bool:
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
|
||||
if sys.platform != "linux":
|
||||
return 'interactive'
|
||||
try:
|
||||
with open(f'/proc/{pid_str}/environ', 'rb') as f:
|
||||
data = f.read()
|
||||
@@ -248,6 +250,8 @@ def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
if sys.platform != "linux":
|
||||
continue
|
||||
cwd = os.readlink(f'/proc/{pid_str}/cwd')
|
||||
if str(Path(cwd).resolve()) == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
@@ -283,11 +287,12 @@ def _check_pid_alive(pid: int) -> bool:
|
||||
"""Check if a process is alive (not zombie)."""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
# Also verify not zombie
|
||||
with open(f'/proc/{pid}/status', 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('State:'):
|
||||
return 'Z' not in line
|
||||
# Also verify not zombie via /proc (Linux only)
|
||||
if sys.platform == "linux":
|
||||
with open(f'/proc/{pid}/status', 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('State:'):
|
||||
return 'Z' not in line
|
||||
return True
|
||||
except (ProcessLookupError, FileNotFoundError):
|
||||
return False
|
||||
@@ -376,7 +381,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None,
|
||||
config = _load_config()
|
||||
max_turns = config.get("max_turns_per_wake", 50)
|
||||
|
||||
lock_file_path = str(branch_path / "ai_mail.local" / ".dispatch.lock")
|
||||
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
|
||||
if custom_message:
|
||||
prompt = f"Hi. {custom_message} "
|
||||
else:
|
||||
@@ -406,7 +411,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None,
|
||||
_set_session_name(branch_path, session_label)
|
||||
|
||||
# Step 7: Spawn via dispatch_monitor
|
||||
log_dir = branch_path / "ai_mail.local"
|
||||
log_dir = branch_path / ".ai_mail.local"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
stderr_log = str(log_dir / "agent_stderr.log")
|
||||
|
||||
@@ -459,7 +464,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None,
|
||||
else:
|
||||
status.fail("alive", f"Agent died immediately (PID {monitor_pid})")
|
||||
# Clean up lock
|
||||
lock_file = branch_path / "ai_mail.local" / ".dispatch.lock"
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return status, False
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ def push_dashboard_update(branch_path: Path) -> bool:
|
||||
Read branch inbox and push ai_mail section to dashboard.
|
||||
|
||||
This is the primary public function. It:
|
||||
1. Reads the branch's ai_mail.local/inbox.json
|
||||
1. Reads the branch's .ai_mail.local/inbox.json
|
||||
2. Calculates section data (new, opened, total, oldest_unread_age, etc.)
|
||||
3. Calls write_section() to update DASHBOARD.local.json
|
||||
|
||||
@@ -171,9 +171,9 @@ def push_dashboard_update(branch_path: Path) -> bool:
|
||||
|
||||
# Determine inbox path
|
||||
if branch_path == Path("/"):
|
||||
inbox_file = Path.cwd() / "ai_mail.local" / "inbox.json"
|
||||
inbox_file = Path.cwd() / ".ai_mail.local" / "inbox.json"
|
||||
else:
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
|
||||
|
||||
# Read inbox (BYPASS: direct json.load - this is a data file, not a template)
|
||||
if not inbox_file.exists():
|
||||
|
||||
@@ -229,7 +229,7 @@ def deliver_email_to_branch(
|
||||
on_delivered: Optional[Callable] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Deliver email to target branch's ai_mail.local/inbox.json file.
|
||||
Deliver email to target branch's .ai_mail.local/inbox.json file.
|
||||
|
||||
Appends message to inbox JSON messages array.
|
||||
|
||||
@@ -283,11 +283,11 @@ def deliver_email_to_branch(
|
||||
|
||||
branch_path = Path(branches[to_branch])
|
||||
|
||||
# Find the branch's ai_mail.local/inbox.json file
|
||||
# Find the branch's .ai_mail.local/inbox.json file
|
||||
if branch_path == Path("/") or branch_path == _REPO_ROOT:
|
||||
inbox_file = _REPO_ROOT / "ai_mail.local" / "inbox.json"
|
||||
inbox_file = _REPO_ROOT / ".ai_mail.local" / "inbox.json"
|
||||
else:
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
# Auto-provision inbox for new branches (self-healing)
|
||||
|
||||
@@ -80,7 +80,7 @@ def _save_to_deleted_folder(mailbox_path: Path, message: Dict) -> Path:
|
||||
Save a message to the deleted/ folder as individual JSON file.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
mailbox_path: Path to .ai_mail.local directory
|
||||
message: Email message dict to archive
|
||||
|
||||
Returns:
|
||||
@@ -111,7 +111,7 @@ def _migrate_deleted_json_if_exists(mailbox_path: Path) -> int:
|
||||
Migrate existing deleted.json to deleted/ directory on first access.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
mailbox_path: Path to .ai_mail.local directory
|
||||
|
||||
Returns:
|
||||
Number of messages migrated
|
||||
@@ -165,7 +165,7 @@ def mark_read_and_archive(branch_path: Path, message_id: str) -> Tuple[bool, str
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
mailbox_path = branch_path / ".ai_mail.local"
|
||||
inbox_file = mailbox_path / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
@@ -240,7 +240,7 @@ def mark_all_read_and_archive(branch_path: Path) -> Tuple[bool, str, int]:
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str, count: int)
|
||||
"""
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
mailbox_path = branch_path / ".ai_mail.local"
|
||||
inbox_file = mailbox_path / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
@@ -308,7 +308,7 @@ def _trigger_deleted_purge(branch_path: Path) -> None:
|
||||
"""
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.email.purge import purge_deleted_folder
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
mailbox_path = branch_path / ".ai_mail.local"
|
||||
purge_deleted_folder(mailbox_path)
|
||||
except Exception:
|
||||
pass # Silent fail - purge is best-effort
|
||||
@@ -331,7 +331,7 @@ def mark_as_opened(branch_path: Path, message_id: str) -> Tuple[bool, str, Optio
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str, email_data: dict or None)
|
||||
"""
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
return False, f"Inbox not found: {inbox_file}", None
|
||||
@@ -390,7 +390,7 @@ def mark_as_closed_and_archive(branch_path: Path, message_id: str, skip_post_ops
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
mailbox_path = branch_path / ".ai_mail.local"
|
||||
inbox_file = mailbox_path / "inbox.json"
|
||||
|
||||
if not inbox_file.exists():
|
||||
|
||||
@@ -31,7 +31,7 @@ from datetime import datetime
|
||||
|
||||
# Standard logging
|
||||
|
||||
# Lock file name - placed in branch's ai_mail.local/ directory
|
||||
# Lock file name - placed in branch's .ai_mail.local/ directory
|
||||
LOCK_FILENAME = ".dispatch.lock"
|
||||
|
||||
# Stale lock timeout in seconds (10 minutes)
|
||||
@@ -41,8 +41,8 @@ STALE_LOCK_TIMEOUT = 600
|
||||
def _get_lock_path(branch_path: Path) -> Path:
|
||||
"""Get the lock file path for a branch."""
|
||||
if branch_path == Path("/"):
|
||||
return Path.cwd() / "ai_mail.local" / LOCK_FILENAME
|
||||
return branch_path / "ai_mail.local" / LOCK_FILENAME
|
||||
return Path.cwd() / ".ai_mail.local" / LOCK_FILENAME
|
||||
return branch_path / ".ai_mail.local" / LOCK_FILENAME
|
||||
|
||||
|
||||
def _is_pid_running(pid: int) -> bool:
|
||||
|
||||
@@ -63,7 +63,7 @@ def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]:
|
||||
Keeps 10 most recent emails, vectorizes and archives older ones.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
mailbox_path: Path to .ai_mail.local directory
|
||||
|
||||
Returns:
|
||||
Dict with success, purged_count, archived_paths
|
||||
@@ -97,7 +97,7 @@ def purge_deleted_folder(mailbox_path: Path) -> Dict[str, Any]:
|
||||
Keeps 10 most recent emails, vectorizes and archives older ones.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
mailbox_path: Path to .ai_mail.local directory
|
||||
|
||||
Returns:
|
||||
Dict with success, purged_count, archived_count
|
||||
@@ -129,7 +129,7 @@ def _purge_email_files(mailbox_path: Path, files: List[Path], folder_type: str)
|
||||
Purge list of email files (vectorize, archive, delete).
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
mailbox_path: Path to .ai_mail.local directory
|
||||
files: List of file paths to purge
|
||||
folder_type: "sent" or "deleted" for logging
|
||||
|
||||
@@ -246,7 +246,7 @@ def _archive_email_files(mailbox_path: Path, files: List[Path], folder_type: str
|
||||
Archive email files to .archive/ directory.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
mailbox_path: Path to .ai_mail.local directory
|
||||
files: List of file paths to archive
|
||||
folder_type: "sent" or "deleted" for subdirectory
|
||||
|
||||
@@ -280,7 +280,7 @@ def run_purge(mailbox_path: Path) -> Dict[str, Any]:
|
||||
Convenience function to run both purges.
|
||||
|
||||
Args:
|
||||
mailbox_path: Path to ai_mail.local directory
|
||||
mailbox_path: Path to .ai_mail.local directory
|
||||
|
||||
Returns:
|
||||
Dict with combined results
|
||||
|
||||
@@ -151,7 +151,7 @@ def send_reply(
|
||||
return False, f"Failed to deliver reply: {error_msg}", None
|
||||
|
||||
# Save to sender's sent folder
|
||||
sent_folder = from_branch_path / "ai_mail.local" / "sent"
|
||||
sent_folder = from_branch_path / ".ai_mail.local" / "sent"
|
||||
sent_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
reply_id = str(uuid.uuid4())[:8]
|
||||
|
||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
||||
|
||||
# Infrastructure paths (package-relative)
|
||||
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
|
||||
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / "ai_mail_json"
|
||||
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / ".ai_mail.local"
|
||||
|
||||
from aipass.ai_mail.apps.handlers.json_utils.json_handler import ( # noqa: F401
|
||||
load_json,
|
||||
|
||||
@@ -32,7 +32,7 @@ import inspect
|
||||
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
|
||||
|
||||
# Constants - Updated for AI_MAIL
|
||||
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / "ai_mail_json"
|
||||
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / ".ai_mail.local"
|
||||
JSON_TEMPLATES_DIR = _AI_MAIL_ROOT / "apps" / "json_templates"
|
||||
|
||||
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
|
||||
# =============================================
|
||||
# META DATA HEADER
|
||||
# Name: json_ops.py - Persistence Handler (JSON Operations)
|
||||
# Date: 2025-11-15
|
||||
# Version: 1.0.0
|
||||
# Category: ai_mail/handlers/persistence
|
||||
#
|
||||
# CHANGELOG:
|
||||
# - v1.0.0 (2025-11-15): Initial version with auto-creating & self-healing JSON system
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Auto-creates JSON files from templates
|
||||
# - Self-healing for corrupted files
|
||||
# - Auto-detects calling module for logging
|
||||
# - Implements log rotation based on config limits
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Persistence Handler - JSON Operations
|
||||
|
||||
Handles persistent data operations for ai_mail modules.
|
||||
Manages default JSON files (config, data, log).
|
||||
Auto-creating & self-healing JSON system.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
import inspect
|
||||
|
||||
# Infrastructure paths (package-relative)
|
||||
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
|
||||
|
||||
# Constants
|
||||
AI_MAIL_JSON_DIR = _AI_MAIL_ROOT / "ai_mail_json"
|
||||
JSON_TEMPLATES_DIR = _AI_MAIL_ROOT / "apps" / "json_templates"
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""
|
||||
Auto-detect calling module name from call stack
|
||||
|
||||
Returns:
|
||||
Module name (e.g., "imports_standard" from imports_standard.py)
|
||||
"""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
|
||||
if len(stack) > 2:
|
||||
caller_frame = stack[2]
|
||||
caller_path = Path(caller_frame.filename)
|
||||
module_name = caller_path.stem
|
||||
|
||||
# Validate module name
|
||||
if module_name and not module_name.startswith('_'):
|
||||
return module_name
|
||||
|
||||
# Fallback
|
||||
return "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def load_template(json_type: str, module_name: str) -> Any:
|
||||
"""Load JSON template from template file"""
|
||||
template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json"
|
||||
|
||||
if not template_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
template = json.load(f)
|
||||
|
||||
# Replace placeholders
|
||||
template_str = json.dumps(template)
|
||||
template_str = template_str.replace("{{MODULE_NAME}}", module_name)
|
||||
template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat())
|
||||
|
||||
return json.loads(template_str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate JSON structure matches expected type"""
|
||||
if json_type == "config":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["module_name", "version", "config"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "data":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["created", "last_updated"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "log":
|
||||
return isinstance(data, list)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file"""
|
||||
filename = f"{module_name}_{json_type}.json"
|
||||
return AI_MAIL_JSON_DIR / filename
|
||||
|
||||
|
||||
def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
"""Ensure JSON file exists, create from template if missing"""
|
||||
AI_MAIL_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
template = load_template(json_type, module_name)
|
||||
if template is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Optional[Any]:
|
||||
"""Load JSON file, auto-create if missing"""
|
||||
if not ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file"""
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if not validate_json_structure(data, json_type):
|
||||
return False
|
||||
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
|
||||
try:
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_module_jsons(module_name: str) -> bool:
|
||||
"""Ensure all 3 JSON files exist for a module"""
|
||||
ensure_json_exists(module_name, "config")
|
||||
ensure_json_exists(module_name, "data")
|
||||
ensure_json_exists(module_name, "log")
|
||||
return True
|
||||
|
||||
|
||||
def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool:
|
||||
"""
|
||||
Add entry to module log with automatic rotation
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
Implements config-controlled log limits to prevent unbounded growth.
|
||||
When max_log_entries is reached, removes oldest entries (FIFO).
|
||||
|
||||
Args:
|
||||
operation: Operation name to log
|
||||
data: Optional data dict
|
||||
module_name: Optional module name (auto-detected if not provided)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Auto-detect module name if not provided
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
# Load config to get max_log_entries
|
||||
config = load_json(module_name, "config")
|
||||
max_entries = 100 # Default
|
||||
if config and "config" in config:
|
||||
max_entries = config["config"].get("max_log_entries", 100)
|
||||
|
||||
# Load existing log
|
||||
log = load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
# Create new entry
|
||||
entry: Dict[str, Any] = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"operation": operation
|
||||
}
|
||||
|
||||
if data:
|
||||
entry["data"] = data
|
||||
|
||||
# Add new entry
|
||||
log.append(entry)
|
||||
|
||||
# Rotate if exceeds max (keep most recent entries)
|
||||
if len(log) > max_entries:
|
||||
log = log[-max_entries:]
|
||||
|
||||
return save_json(module_name, "log", log)
|
||||
|
||||
|
||||
def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool:
|
||||
"""Increment a counter in data JSON"""
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
data = load_json(module_name, "data")
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
if counter_name not in data:
|
||||
data[counter_name] = 0
|
||||
|
||||
data[counter_name] += amount
|
||||
|
||||
return save_json(module_name, "data", data)
|
||||
|
||||
|
||||
def update_data_metrics(module_name: str, **metrics: Any) -> bool:
|
||||
"""Update data metrics"""
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
data = load_json(module_name, "data")
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
for key, value in metrics.items():
|
||||
data[key] = value
|
||||
|
||||
return save_json(module_name, "data", data)
|
||||
@@ -39,7 +39,7 @@ from aipass.cli.apps.modules import console
|
||||
# Constants
|
||||
MODULE_NAME = "registry.update"
|
||||
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
|
||||
AI_MAIL_JSON = _AI_MAIL_ROOT / "ai_mail_json"
|
||||
AI_MAIL_JSON = _AI_MAIL_ROOT / ".ai_mail.local"
|
||||
REGISTRY_PATH = AI_MAIL_JSON / "local_memory_monitor_registry.json"
|
||||
THRESHOLDS = {
|
||||
"green": (0, 400),
|
||||
|
||||
@@ -67,8 +67,9 @@ def detect_branch_from_pwd() -> Dict | None:
|
||||
None if no branch detected
|
||||
"""
|
||||
try:
|
||||
# Get current working directory
|
||||
cwd = Path.cwd()
|
||||
# Use caller's CWD if passed by drone, otherwise fall back to process CWD
|
||||
caller_cwd = os.environ.get("AIPASS_CALLER_CWD")
|
||||
cwd = Path(caller_cwd) if caller_cwd else Path.cwd()
|
||||
|
||||
# Find branch root
|
||||
branch_root = find_branch_root(cwd)
|
||||
@@ -103,9 +104,12 @@ def find_branch_root(start_path: Path) -> Path | None:
|
||||
|
||||
# Walk up directory tree (max 10 levels to prevent infinite loop)
|
||||
for _ in range(10):
|
||||
# Check if this directory has a [BRANCH].id.json file
|
||||
for file in current.glob("*.id.json"):
|
||||
# Found a .id.json file - this is likely a branch root
|
||||
# Check for dev-pass pattern: [BRANCH].id.json
|
||||
for _ in current.glob("*.id.json"):
|
||||
return current
|
||||
|
||||
# Check for public/trinity pattern: .trinity/passport.json
|
||||
if (current / ".trinity" / "passport.json").exists():
|
||||
return current
|
||||
|
||||
# Move up one level
|
||||
@@ -134,13 +138,18 @@ def get_branch_info_from_registry(branch_path: Path) -> Dict | None:
|
||||
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
|
||||
# Normalize branch_path for comparison
|
||||
branch_path_str = str(branch_path.resolve())
|
||||
registry_dir = BRANCH_REGISTRY_PATH.parent
|
||||
branch_path_resolved = branch_path.resolve()
|
||||
|
||||
# Search registry for matching path
|
||||
for branch in registry.get("branches", []):
|
||||
if Path(branch["path"]).resolve() == Path(branch_path_str):
|
||||
# Found match - return branch info
|
||||
reg_path = Path(branch["path"])
|
||||
# Resolve relative paths against registry location, not CWD
|
||||
if not reg_path.is_absolute():
|
||||
reg_path = (registry_dir / reg_path).resolve()
|
||||
else:
|
||||
reg_path = reg_path.resolve()
|
||||
if reg_path == branch_path_resolved:
|
||||
return branch
|
||||
|
||||
return None
|
||||
|
||||
@@ -54,8 +54,8 @@ def generate_local_config(branch_info: Dict) -> Dict:
|
||||
# Generate display name from branch info
|
||||
display_name = generate_display_name(branch_info)
|
||||
|
||||
# Generate mailbox path (ai_mail.local/ in branch directory)
|
||||
mailbox_path = str(branch_path / "ai_mail.local")
|
||||
# Generate mailbox path (.ai_mail.local/ in branch directory)
|
||||
mailbox_path = str(branch_path / ".ai_mail.local")
|
||||
|
||||
config = {
|
||||
"version": "1.0.0",
|
||||
@@ -155,7 +155,7 @@ def create_local_config_file(branch_info: Dict, force: bool = False) -> Path | N
|
||||
|
||||
def create_mailbox_directory(branch_path: Path) -> Path | None:
|
||||
"""
|
||||
Create ai_mail.local/ mailbox directory for a branch.
|
||||
Create .ai_mail.local/ mailbox directory for a branch.
|
||||
|
||||
Creates subdirectories: inbox/, sent/, deleted/
|
||||
|
||||
@@ -166,7 +166,7 @@ def create_mailbox_directory(branch_path: Path) -> Path | None:
|
||||
Path to mailbox directory, or None if failed
|
||||
"""
|
||||
try:
|
||||
mailbox_dir = branch_path / "ai_mail.local"
|
||||
mailbox_dir = branch_path / ".ai_mail.local"
|
||||
mailbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create subdirectories
|
||||
@@ -207,9 +207,9 @@ def setup_branch_for_aimail(branch_info: Dict, force: bool = False) -> bool:
|
||||
|
||||
Creates:
|
||||
- ai_mail_config/user_config.json
|
||||
- ai_mail.local/ mailbox directory
|
||||
- ai_mail.local/inbox.json
|
||||
- ai_mail.local/sent.json
|
||||
- .ai_mail.local/ mailbox directory
|
||||
- .ai_mail.local/inbox.json
|
||||
- .ai_mail.local/sent.json
|
||||
|
||||
Args:
|
||||
branch_info: Branch info dict from registry
|
||||
@@ -263,7 +263,7 @@ if __name__ == "__main__":
|
||||
console.print(" 1. Generate config from branch registry info")
|
||||
console.print(" 2. Create ai_mail_config/ directory")
|
||||
console.print(" 3. Write user_config.json with branch-specific settings")
|
||||
console.print(" 4. Create ai_mail.local/ mailbox directory")
|
||||
console.print(" 4. Create .ai_mail.local/ mailbox directory")
|
||||
console.print(" 5. Initialize inbox.json and sent.json")
|
||||
console.print()
|
||||
console.print("="*70 + "\n")
|
||||
|
||||
@@ -35,7 +35,7 @@ from typing import Dict
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
_AI_MAIL_ROOT = Path(__file__).resolve().parents[3] # ai_mail/
|
||||
AI_MAIL_JSON = _AI_MAIL_ROOT / "ai_mail_json"
|
||||
AI_MAIL_JSON = _AI_MAIL_ROOT / ".ai_mail.local"
|
||||
USER_CONFIG_FILE = AI_MAIL_JSON / "user_config.json"
|
||||
|
||||
# Import branch detection (after constants defined)
|
||||
|
||||
@@ -46,7 +46,7 @@ def get_current_user() -> Dict:
|
||||
{
|
||||
"email_address": "@branch",
|
||||
"display_name": "BRANCH_NAME",
|
||||
"mailbox_path": "/path/to/branch/ai_mail.local",
|
||||
"mailbox_path": "/path/to/branch/.ai_mail.local",
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ def get_current_user() -> Dict:
|
||||
|
||||
# Construct mailbox path (branch_path guaranteed non-None by check above)
|
||||
assert branch_path is not None
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
mailbox_path = branch_path / ".ai_mail.local"
|
||||
|
||||
# Return user info in expected format
|
||||
return {
|
||||
@@ -121,7 +121,7 @@ def get_user_by_email(email: str) -> Dict | None:
|
||||
return {
|
||||
"email_address": branch.get("email"),
|
||||
"display_name": branch.get("name"),
|
||||
"mailbox_path": str(branch_path / "ai_mail.local"),
|
||||
"mailbox_path": str(branch_path / ".ai_mail.local"),
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
return None
|
||||
@@ -154,7 +154,7 @@ def get_all_users() -> Dict[str, Dict]:
|
||||
users[email] = {
|
||||
"email_address": email,
|
||||
"display_name": branch.get("name"),
|
||||
"mailbox_path": str(branch_path / "ai_mail.local"),
|
||||
"mailbox_path": str(branch_path / ".ai_mail.local"),
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
return users
|
||||
|
||||
@@ -41,7 +41,7 @@ from aipass.ai_mail.apps.handlers.registry.update import (
|
||||
get_branch_context,
|
||||
update_json_memory_health
|
||||
)
|
||||
from aipass.ai_mail.apps.handlers.persistence.json_ops import log_operation
|
||||
from aipass.ai_mail.apps.handlers.json_utils.json_handler import log_operation
|
||||
|
||||
MODULE_NAME = "branch_ping"
|
||||
THRESHOLDS = {"green": (0, 400), "yellow": (401, 550), "red": (551, float('inf'))}
|
||||
|
||||
@@ -68,7 +68,7 @@ from aipass.ai_mail.apps.handlers.email.reply import get_email_by_id, send_reply
|
||||
from aipass.ai_mail.apps.handlers.email.header import prepend_dispatch_header
|
||||
from aipass.ai_mail.apps.handlers.users.user import get_current_user
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches, get_branch_by_email
|
||||
from aipass.ai_mail.apps.handlers.persistence.json_ops import log_operation
|
||||
from aipass.ai_mail.apps.handlers.json_utils.json_handler import log_operation
|
||||
|
||||
|
||||
def _on_email_delivered(branch_path, new_count, opened_count, total):
|
||||
@@ -457,7 +457,7 @@ def send_email_direct(to_branch: str, subject: str, message: str, auto_execute:
|
||||
user_info = {
|
||||
"email_address": email_addr,
|
||||
"display_name": branch_info["name"],
|
||||
"mailbox_path": str(branch_path / "ai_mail.local"),
|
||||
"mailbox_path": str(branch_path / ".ai_mail.local"),
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
else:
|
||||
@@ -466,7 +466,7 @@ def send_email_direct(to_branch: str, subject: str, message: str, auto_execute:
|
||||
user_info = {
|
||||
"email_address": email_addr,
|
||||
"display_name": branch_name,
|
||||
"mailbox_path": str(_AI_MAIL_DIR.parent / from_branch.lstrip('@').lower() / "ai_mail.local"),
|
||||
"mailbox_path": str(_AI_MAIL_DIR.parent / from_branch.lstrip('@').lower() / ".ai_mail.local"),
|
||||
"timestamp_format": "%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
else:
|
||||
@@ -605,7 +605,7 @@ def handle_inbox(args: List[str]) -> bool:
|
||||
console.print(f"❌ Unknown branch: {target_branch}")
|
||||
return False
|
||||
branch_path = Path(branch_info["path"])
|
||||
mailbox_path = branch_path / "ai_mail.local"
|
||||
mailbox_path = branch_path / ".ai_mail.local"
|
||||
display_name = branch_info.get("name", target_branch)
|
||||
else:
|
||||
# Use current branch (detected from PWD)
|
||||
@@ -670,7 +670,7 @@ def handle_read(args: List[str]) -> bool:
|
||||
|
||||
try:
|
||||
user_info = get_current_user()
|
||||
# mailbox_path is ai_mail.local/, branch_path is parent
|
||||
# mailbox_path is .ai_mail.local/, branch_path is parent
|
||||
branch_path = Path(user_info["mailbox_path"]).parent
|
||||
|
||||
# Archive all messages
|
||||
@@ -801,7 +801,7 @@ def handle_close(args: List[str]) -> bool:
|
||||
pass # Dashboard/central update is best-effort
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.email.purge import purge_deleted_folder
|
||||
purge_deleted_folder(branch_path / "ai_mail.local")
|
||||
purge_deleted_folder(branch_path / ".ai_mail.local")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -830,7 +830,7 @@ def handle_reply(args: List[str]) -> bool:
|
||||
try:
|
||||
user_info = get_current_user()
|
||||
branch_path = Path(user_info["mailbox_path"]).parent
|
||||
inbox_file = branch_path / "ai_mail.local" / "inbox.json"
|
||||
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
|
||||
|
||||
message_id = args[0]
|
||||
reply_message = args[1]
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enabledMcpjsonServers": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-03-06T03:16:10.876557",
|
||||
"created": "2026-03-06T14:27:31.471013",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [],
|
||||
|
||||
+50
-23
@@ -1,51 +1,78 @@
|
||||
# API
|
||||
|
||||
**Purpose:** Model routing, provider abstraction, LLM API access layer
|
||||
**Purpose:** LLM API access layer with provider abstraction, key management, model routing, and usage tracking.
|
||||
**Module:** `aipass.api`
|
||||
**Created:** 2026-03-05
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What I Do
|
||||
|
||||
- Manage API keys for LLM providers (currently OpenRouter)
|
||||
- Route requests to LLM models with provider-level abstraction
|
||||
- Track API usage metrics and provide statistics
|
||||
- Validate credentials and test provider connectivity
|
||||
- Discover available models from configured providers
|
||||
|
||||
### How I Work
|
||||
- **Entry Point:** `apps/api.py`
|
||||
- **Pattern:** Auto-discovers and routes to modules
|
||||
- **Entry Point:** `apps/api.py` -- auto-discovers and routes to modules
|
||||
- **Pattern:** Standard AIPass 3-tier architecture (entry point / modules / handlers)
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
drone @api get-key # Retrieve API key for provider
|
||||
drone @api validate # Validate API credentials and connection
|
||||
drone @api test # Test OpenRouter connection status
|
||||
drone @api models # List available models from provider
|
||||
drone @api track # Track API usage metrics
|
||||
drone @api stats # Display API usage statistics
|
||||
drone @api --help # Full help output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
API/
|
||||
api/
|
||||
├── __init__.py # Public API exports
|
||||
├── apps/
|
||||
│ ├── api.py # Entry point
|
||||
│ ├── modules/ # Business logic
|
||||
│ ├── handlers/ # Implementation
|
||||
│ └── plugins/ # Extensions
|
||||
├── docs/
|
||||
├── tests/
|
||||
├── passport.json # Identity
|
||||
├── local.json # Session history
|
||||
├── observations.json # Collaboration patterns
|
||||
│ ├── api.py # Entry point (module discovery, command routing)
|
||||
│ ├── modules/
|
||||
│ │ ├── api_key.py # Key retrieval and validation logic
|
||||
│ │ ├── openrouter_client.py # OpenRouter API client
|
||||
│ │ └── usage_tracker.py # Usage metrics tracking
|
||||
│ └── handlers/
|
||||
│ ├── auth/
|
||||
│ │ ├── env.py # Environment variable credential loading
|
||||
│ │ └── keys.py # API key storage and retrieval
|
||||
│ ├── config/
|
||||
│ │ └── provider.py # Provider configuration management
|
||||
│ ├── json/
|
||||
│ │ └── json_handler.py # JSON operation logging
|
||||
│ ├── openrouter/
|
||||
│ │ ├── caller.py # HTTP request execution
|
||||
│ │ ├── client.py # OpenRouter client implementation
|
||||
│ │ ├── models.py # Model discovery and listing
|
||||
│ │ └── provision.py # Provider provisioning
|
||||
│ └── usage/
|
||||
│ ├── aggregation.py # Usage data aggregation
|
||||
│ ├── cleanup.py # Usage data cleanup
|
||||
│ └── tracking.py # Usage event tracking
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
*Configure after initialization*
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
|
||||
- `aipass.prax` -- structured logging via `system_logger`
|
||||
- `aipass.cli` -- Rich console output formatting
|
||||
|
||||
### Provides To
|
||||
|
||||
- All modules -- LLM API access for any branch that needs model inference
|
||||
- System-wide API key management and credential validation
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""API - External API integrations for AIPass."""
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
|
||||
# Standard library imports
|
||||
import importlib
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Any, List
|
||||
|
||||
# AIPass infrastructure imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
@@ -164,10 +164,6 @@ def print_help():
|
||||
table.add_row("models", "List available models from provider")
|
||||
table.add_row("track", "Track API usage metrics")
|
||||
table.add_row("stats", "Display API usage statistics")
|
||||
table.add_row("telegram start", "Start Telegram bridge service")
|
||||
table.add_row("telegram stop", "Stop Telegram bridge service")
|
||||
table.add_row("telegram status", "Check Telegram bridge status")
|
||||
table.add_row("telegram logs", "View Telegram bridge logs")
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
@@ -26,7 +26,7 @@ def _find_real_caller():
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = filepath.split("/")
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part in ("MEMORY_BANK", "seed", ".vscode"):
|
||||
if i + 1 < len(parts):
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - Telegram Handlers Package
|
||||
# Date: 2026-02-03
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-03): Initial package for Telegram bridge
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram Handlers Package
|
||||
|
||||
Provides Telegram bot integration for AIPass:
|
||||
- config.py: Token and configuration loading
|
||||
- bridge.py: Core bot service with message handling
|
||||
"""
|
||||
|
||||
from aipass.api.apps.handlers.telegram.config import (
|
||||
load_telegram_config,
|
||||
get_bot_token,
|
||||
get_bot_username
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"load_telegram_config",
|
||||
"get_bot_token",
|
||||
"get_bot_username"
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,555 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: bot_factory.py - Bot creation and deletion factory
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.2.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.2.0 (2026-02-25): Fix: create_bot always uses registry path when branch_name provided
|
||||
# - v1.1.0 (2026-02-24): Auto-start bot process after create_bot via Popen fire-and-forget
|
||||
# - v1.0.0 (2026-02-24): Initial - bot lifecycle management (create, delete, validate, systemd)
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error handling (graceful - never raise)
|
||||
# - Uses Prax system_logger (FPLAN-0382 migration)
|
||||
# - Stdlib only (urllib for HTTP, subprocess for systemd)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Bot Creation and Deletion Factory
|
||||
|
||||
Manages the full lifecycle of Telegram bots in the multi-bot architecture:
|
||||
- Validate bot tokens via Telegram getMe API
|
||||
- Validate branch existence against BRANCH_REGISTRY.json
|
||||
- Write per-bot config files to ~/.aipass/telegram_bots/{bot_id}.json
|
||||
- Register/deregister bots in the central bot registry
|
||||
- Set BotFather commands via setMyCommands API
|
||||
- Enable/disable/stop systemd user services
|
||||
|
||||
All HTTP calls use urllib (stdlib). No external dependencies.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
# Logging (Prax system_logger — FPLAN-0382)
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Internal imports
|
||||
from aipass.api.apps.handlers.telegram.bot_registry import (
|
||||
ensure_registry, register_bot, deregister_bot, get_bot, get_bot_by_branch
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
TELEGRAM_API = "https://api.telegram.org/bot{token}"
|
||||
|
||||
def _aipass_data_dir() -> Path:
|
||||
"""User data directory for AIPass runtime files."""
|
||||
env = os.environ.get("AIPASS_DATA_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file to find AIPASS_REGISTRY.json (repo root)."""
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in [current] + list(current.parents):
|
||||
if (parent / "AIPASS_REGISTRY.json").exists():
|
||||
return parent
|
||||
return Path.cwd()
|
||||
|
||||
_DATA_DIR = _aipass_data_dir()
|
||||
BOT_CONFIG_DIR = _DATA_DIR / "telegram_bots"
|
||||
BRANCH_REGISTRY = _find_repo_root() / "AIPASS_REGISTRY.json"
|
||||
SYSTEMD_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "systemd" / "user"
|
||||
|
||||
# Default commands set on every new bot via BotFather
|
||||
DEFAULT_BOT_COMMANDS = [
|
||||
{"command": "start", "description": "Start the bot"},
|
||||
{"command": "help", "description": "Show available commands"},
|
||||
{"command": "status", "description": "Show session status"},
|
||||
{"command": "new", "description": "Start a fresh session"},
|
||||
]
|
||||
|
||||
# =============================================
|
||||
# TELEGRAM API HELPERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def validate_token(bot_token: str) -> Optional[dict]:
|
||||
"""
|
||||
Validate a bot token via the Telegram getMe API call.
|
||||
|
||||
Args:
|
||||
bot_token: Telegram bot token string (e.g., "123456:ABC-DEF...").
|
||||
|
||||
Returns:
|
||||
Bot info dict with keys like "id", "username", "first_name" on success.
|
||||
None if the token is invalid or the API is unreachable.
|
||||
"""
|
||||
url = f"{TELEGRAM_API.format(token=bot_token)}/getMe"
|
||||
|
||||
try:
|
||||
req = Request(url, method="GET")
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if result.get("ok") and result.get("result"):
|
||||
bot_info = result["result"]
|
||||
logger.info("Token validated: @%s (id=%s)", bot_info.get("username"), bot_info.get("id"))
|
||||
return bot_info
|
||||
|
||||
logger.warning("Token validation failed: API returned ok=false")
|
||||
return None
|
||||
|
||||
except HTTPError as e:
|
||||
logger.warning("Token validation HTTP error %d: %s", e.code, e.reason)
|
||||
return None
|
||||
except URLError as e:
|
||||
logger.warning("Token validation network error: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Token validation unexpected error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def validate_branch(branch_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Check that a branch exists in BRANCH_REGISTRY.json.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name to look up (case-insensitive, matches email field).
|
||||
|
||||
Returns:
|
||||
Branch info dict from the registry on success, None if not found.
|
||||
"""
|
||||
try:
|
||||
if not BRANCH_REGISTRY.exists():
|
||||
logger.warning("Branch registry not found: %s", BRANCH_REGISTRY)
|
||||
return None
|
||||
|
||||
with open(BRANCH_REGISTRY, "r", encoding="utf-8") as f:
|
||||
registry = json.load(f)
|
||||
|
||||
branches = registry.get("branches", [])
|
||||
target = branch_name.lower()
|
||||
|
||||
for branch_entry in branches:
|
||||
clean_email = branch_entry.get("email", "").replace("@", "").lower()
|
||||
if clean_email == target:
|
||||
logger.info("Branch validated: %s -> %s", branch_name, branch_entry.get("path"))
|
||||
return branch_entry
|
||||
|
||||
logger.warning("Branch '%s' not found in registry", branch_name)
|
||||
return None
|
||||
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to read branch registry: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def set_bot_commands(bot_token: str, commands: list[dict]) -> bool:
|
||||
"""
|
||||
Set BotFather commands via the Telegram setMyCommands API.
|
||||
|
||||
Args:
|
||||
bot_token: Telegram bot token.
|
||||
commands: List of command dicts, each with "command" and "description" keys.
|
||||
|
||||
Returns:
|
||||
True if commands were set successfully, False otherwise.
|
||||
"""
|
||||
url = f"{TELEGRAM_API.format(token=bot_token)}/setMyCommands"
|
||||
|
||||
payload = {"commands": commands}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
if result.get("ok"):
|
||||
logger.info("Bot commands set successfully (%d commands)", len(commands))
|
||||
return True
|
||||
logger.warning("setMyCommands failed: %s", result.get("description"))
|
||||
return False
|
||||
|
||||
except (HTTPError, URLError) as e:
|
||||
logger.warning("Failed to set bot commands: %s", e)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("Unexpected error setting bot commands: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# SYSTEMD SERVICE MANAGEMENT
|
||||
# =============================================
|
||||
|
||||
|
||||
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}
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier used in the service template.
|
||||
|
||||
Returns:
|
||||
True if the service was enabled successfully, False otherwise.
|
||||
"""
|
||||
SERVICE_NAME = f"telegram-bot@{bot_id}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "enable", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Enabled systemd service: %s", SERVICE_NAME)
|
||||
return True
|
||||
|
||||
logger.warning("Failed to enable service %s: %s", SERVICE_NAME, result.stderr.strip())
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout enabling service: %s", SERVICE_NAME)
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning("Error enabling service %s: %s", SERVICE_NAME, e)
|
||||
return False
|
||||
|
||||
|
||||
def disable_service(bot_id: str) -> bool:
|
||||
"""
|
||||
Disable the systemd user service for a bot.
|
||||
|
||||
Runs: systemctl --user disable telegram-bot@{bot_id}
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier used in the service template.
|
||||
|
||||
Returns:
|
||||
True if the service was disabled successfully, False otherwise.
|
||||
"""
|
||||
SERVICE_NAME = f"telegram-bot@{bot_id}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "disable", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Disabled systemd service: %s", SERVICE_NAME)
|
||||
return True
|
||||
|
||||
logger.warning("Failed to disable service %s: %s", SERVICE_NAME, result.stderr.strip())
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout disabling service: %s", SERVICE_NAME)
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning("Error disabling service %s: %s", SERVICE_NAME, e)
|
||||
return False
|
||||
|
||||
|
||||
def start_bot_process(bot_id: str) -> bool:
|
||||
"""
|
||||
Launch the bot process via subprocess.Popen (fire-and-forget).
|
||||
|
||||
Starts base_bot.py --bot-id {bot_id} as a detached process.
|
||||
This is called after create_bot() to auto-start the new bot.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to start.
|
||||
|
||||
Returns:
|
||||
True if the process was launched successfully, False otherwise.
|
||||
"""
|
||||
BASE_BOT_PATH = Path(__file__).resolve().parent / "base_bot.py"
|
||||
PYTHON = shutil.which("python3") or sys.executable
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[PYTHON, str(BASE_BOT_PATH), "--bot-id", bot_id],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("Started bot process: bot_id=%s, pid=%d", bot_id, proc.pid)
|
||||
return True
|
||||
|
||||
except OSError as e:
|
||||
logger.warning("Failed to start bot process for '%s': %s", bot_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def stop_service(bot_id: str) -> bool:
|
||||
"""
|
||||
Stop the systemd user service for a bot.
|
||||
|
||||
Runs: systemctl --user stop telegram-bot@{bot_id}
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier used in the service template.
|
||||
|
||||
Returns:
|
||||
True if the service was stopped successfully, False otherwise.
|
||||
"""
|
||||
SERVICE_NAME = f"telegram-bot@{bot_id}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "stop", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("Stopped systemd service: %s", SERVICE_NAME)
|
||||
return True
|
||||
|
||||
logger.warning("Failed to stop service %s: %s", SERVICE_NAME, result.stderr.strip())
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout stopping service: %s", SERVICE_NAME)
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning("Error stopping service %s: %s", SERVICE_NAME, e)
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# BOT LIFECYCLE
|
||||
# =============================================
|
||||
|
||||
|
||||
def create_bot(
|
||||
bot_id: str,
|
||||
bot_token: str,
|
||||
branch_name: Optional[str] = None,
|
||||
work_dir: Optional[str] = None,
|
||||
bot_name: Optional[str] = None,
|
||||
allowed_user_ids: Optional[list[int]] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Create a new bot: validate, write config, register, setup systemd.
|
||||
|
||||
Steps:
|
||||
1. Validate token via getMe
|
||||
2. If branch_name provided: validate branch exists in BRANCH_REGISTRY.json
|
||||
3. Check bot_id not already registered
|
||||
4. Write config file: ~/.aipass/telegram_bots/{bot_id}.json
|
||||
5. Register in bot registry
|
||||
6. Set BotFather commands via setMyCommands API
|
||||
7. Enable systemd service (don't start - leave that to caller)
|
||||
|
||||
Args:
|
||||
bot_id: Unique identifier for this bot (e.g., "dev_central", "base").
|
||||
bot_token: Telegram bot token from BotFather.
|
||||
branch_name: AIPass branch name to associate, or None for base bot.
|
||||
work_dir: Working directory for Claude sessions. Auto-resolved from branch if None.
|
||||
bot_name: Human-readable bot name. Auto-generated if None.
|
||||
allowed_user_ids: List of Telegram user IDs allowed to use this bot.
|
||||
|
||||
Returns:
|
||||
Bot info dict on success, None on any failure.
|
||||
"""
|
||||
# Step 1: Validate token
|
||||
bot_info = validate_token(bot_token)
|
||||
if not bot_info:
|
||||
logger.warning("create_bot failed: invalid token for bot_id '%s'", bot_id)
|
||||
return None
|
||||
|
||||
BOT_USERNAME = bot_info.get("username", "unknown")
|
||||
|
||||
# Step 2: Validate branch if provided
|
||||
RESOLVED_WORK_DIR = str(Path.cwd()) if work_dir is None else str(work_dir)
|
||||
if branch_name:
|
||||
branch_info = validate_branch(branch_name)
|
||||
if not branch_info:
|
||||
logger.warning("create_bot failed: branch '%s' not found", branch_name)
|
||||
return None
|
||||
# Always use registry path as source of truth when branch_name is provided
|
||||
REGISTRY_PATH = branch_info.get("path", "")
|
||||
if REGISTRY_PATH:
|
||||
if work_dir and str(work_dir) != REGISTRY_PATH:
|
||||
logger.warning(
|
||||
"create_bot: explicit work_dir '%s' differs from registry path '%s' — using registry",
|
||||
work_dir, REGISTRY_PATH,
|
||||
)
|
||||
RESOLVED_WORK_DIR = REGISTRY_PATH
|
||||
|
||||
# Step 3: Check not already registered
|
||||
existing = get_bot(bot_id)
|
||||
if existing:
|
||||
logger.warning("create_bot failed: bot_id '%s' already registered", bot_id)
|
||||
return None
|
||||
|
||||
# Also check no other bot owns this branch
|
||||
if branch_name:
|
||||
branch_bot = get_bot_by_branch(branch_name)
|
||||
if branch_bot:
|
||||
logger.warning(
|
||||
"create_bot failed: branch '%s' already has bot '%s'",
|
||||
branch_name, branch_bot.get("bot_id"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Step 4: Write config file
|
||||
ensure_registry()
|
||||
BOT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
RESOLVED_BOT_NAME = bot_name or f"AIPass {bot_id.replace('_', ' ').title()} Bot"
|
||||
CONFIG_PATH = BOT_CONFIG_DIR / f"{bot_id}.json"
|
||||
|
||||
config_data = {
|
||||
"bot_id": bot_id,
|
||||
"bot_token": bot_token,
|
||||
"bot_name": RESOLVED_BOT_NAME,
|
||||
"branch_name": branch_name,
|
||||
"work_dir": RESOLVED_WORK_DIR,
|
||||
"allowed_user_ids": allowed_user_ids or [],
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
CONFIG_PATH.write_text(
|
||||
json.dumps(config_data, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info("Wrote bot config: %s", CONFIG_PATH)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to write bot config: %s", e)
|
||||
return None
|
||||
|
||||
# Step 5: Register in bot registry
|
||||
registered = register_bot(
|
||||
bot_id=bot_id,
|
||||
username=BOT_USERNAME,
|
||||
branch_name=branch_name,
|
||||
work_dir=RESOLVED_WORK_DIR,
|
||||
config_path=str(CONFIG_PATH),
|
||||
)
|
||||
if not registered:
|
||||
# Clean up config file on registration failure
|
||||
CONFIG_PATH.unlink(missing_ok=True)
|
||||
logger.warning("create_bot failed: registry registration failed for '%s'", bot_id)
|
||||
return None
|
||||
|
||||
# Step 6: Set BotFather commands
|
||||
set_bot_commands(bot_token, DEFAULT_BOT_COMMANDS)
|
||||
|
||||
# Step 7: Enable systemd service
|
||||
enable_service(bot_id)
|
||||
|
||||
# Step 8: Auto-start the bot process
|
||||
started = start_bot_process(bot_id)
|
||||
|
||||
logger.info(
|
||||
"Bot created: %s (@%s, branch=%s, work_dir=%s, started=%s)",
|
||||
bot_id, BOT_USERNAME, branch_name, RESOLVED_WORK_DIR, started,
|
||||
)
|
||||
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"username": BOT_USERNAME,
|
||||
"bot_name": RESOLVED_BOT_NAME,
|
||||
"branch_name": branch_name,
|
||||
"work_dir": RESOLVED_WORK_DIR,
|
||||
"config_path": str(CONFIG_PATH),
|
||||
"service_name": f"telegram-bot@{bot_id}",
|
||||
"auto_started": started,
|
||||
}
|
||||
|
||||
|
||||
def delete_bot(bot_id: str, kill_tmux: bool = True) -> bool:
|
||||
"""
|
||||
Delete a bot: stop service, kill tmux, remove config, deregister.
|
||||
|
||||
Steps:
|
||||
1. Stop systemd service
|
||||
2. Kill tmux session if exists and kill_tmux is True
|
||||
3. Remove config file
|
||||
4. Clean up pending file (both v1 and v2 naming)
|
||||
5. Deregister from registry
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to delete.
|
||||
kill_tmux: Whether to kill the associated tmux session (default True).
|
||||
|
||||
Returns:
|
||||
True if the bot was fully cleaned up, False on any failure.
|
||||
"""
|
||||
bot = get_bot(bot_id)
|
||||
if not bot:
|
||||
logger.warning("delete_bot failed: bot '%s' not found in registry", bot_id)
|
||||
return False
|
||||
|
||||
# Step 1: Stop systemd service
|
||||
stop_service(bot_id)
|
||||
disable_service(bot_id)
|
||||
|
||||
# Step 2: Kill tmux session if requested
|
||||
if kill_tmux:
|
||||
TMUX_SESSION_NAME = f"telegram-{bot_id}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["tmux", "kill-session", "-t", TMUX_SESSION_NAME],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
logger.info("Killed tmux session: %s", TMUX_SESSION_NAME)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.info("tmux kill-session for '%s' skipped (may not exist): %s", TMUX_SESSION_NAME, e)
|
||||
|
||||
# Step 3: Remove config file
|
||||
CONFIG_PATH = bot.get("config_path", "")
|
||||
if CONFIG_PATH:
|
||||
try:
|
||||
Path(CONFIG_PATH).unlink(missing_ok=True)
|
||||
logger.info("Removed config file: %s", CONFIG_PATH)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove config file: %s", e)
|
||||
|
||||
# Step 4: Clean up pending files (both v1 and v2 naming)
|
||||
PENDING_DIR = _DATA_DIR / "telegram_pending"
|
||||
BRANCH_NAME = bot.get("branch_name", bot_id)
|
||||
|
||||
# v2 naming: bot-{bot_id}.json
|
||||
PENDING_V2 = PENDING_DIR / f"bot-{bot_id}.json"
|
||||
PENDING_V2.unlink(missing_ok=True)
|
||||
|
||||
# v1 naming: telegram-{branch_name}.json
|
||||
if BRANCH_NAME:
|
||||
PENDING_V1 = PENDING_DIR / f"telegram-{BRANCH_NAME}.json"
|
||||
PENDING_V1.unlink(missing_ok=True)
|
||||
|
||||
# Step 5: Deregister from registry
|
||||
if not deregister_bot(bot_id):
|
||||
logger.warning("delete_bot: deregistration failed for '%s'", bot_id)
|
||||
return False
|
||||
|
||||
logger.info("Bot deleted: %s", bot_id)
|
||||
return True
|
||||
@@ -1,240 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: bot_operations.py - Bot operation handlers for multi-bot module
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - start, stop, status, list operations for multi-bot system
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error handling (graceful - never raise)
|
||||
# - No Prax imports (handler tier 3)
|
||||
# - Stdlib only (subprocess for systemd)
|
||||
# - Returns values for caller to log/display - no handler-level logging
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Bot Operation Handlers for Multi-Bot Architecture
|
||||
|
||||
Implementation logic for bot lifecycle operations:
|
||||
- start_bot: load config and run polling loop
|
||||
- stop_bot: stop systemd service
|
||||
- get_status: query registry for bot status
|
||||
- get_all_bots: list all registered bots
|
||||
- format_bot_details: format a bot entry for display
|
||||
|
||||
Called by the telegram_bot module (thin orchestration layer).
|
||||
All functions return values - the module layer handles logging and display.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import subprocess
|
||||
|
||||
# Internal handler imports
|
||||
from aipass.api.apps.handlers.telegram.base_bot import BaseBot
|
||||
from aipass.api.apps.handlers.telegram.branch_plugin import BranchPlugin
|
||||
from aipass.api.apps.handlers.telegram.bot_registry import list_bots, get_bot
|
||||
from aipass.api.apps.handlers.telegram.config import load_bot_config
|
||||
|
||||
# =============================================
|
||||
# BOT OPERATIONS
|
||||
# =============================================
|
||||
|
||||
|
||||
def start_bot(bot_id: str) -> int | None:
|
||||
"""
|
||||
Load config and start a bot's polling loop.
|
||||
|
||||
Loads config from ~/.aipass/telegram_bots/{bot_id}.json.
|
||||
If config has "branch_name", creates a BranchPlugin, else a BaseBot.
|
||||
Calls bot.run() which blocks until terminated.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to start
|
||||
|
||||
Returns:
|
||||
Bot exit code, or None if config loading failed.
|
||||
"""
|
||||
config = load_bot_config(bot_id)
|
||||
if not config:
|
||||
return None
|
||||
|
||||
bot_token = config.get("bot_token")
|
||||
if not bot_token:
|
||||
return None
|
||||
|
||||
work_dir = Path(config.get("work_dir", str(Path.cwd())))
|
||||
bot_name = config.get("bot_name", f"AIPass {bot_id} Bot")
|
||||
allowed_user_ids = config.get("allowed_user_ids", [])
|
||||
branch_name = config.get("branch_name")
|
||||
|
||||
if branch_name:
|
||||
bot = BranchPlugin(
|
||||
branch_name=branch_name,
|
||||
bot_id=bot_id,
|
||||
bot_token=bot_token,
|
||||
work_dir=work_dir,
|
||||
bot_name=bot_name,
|
||||
allowed_user_ids=allowed_user_ids,
|
||||
)
|
||||
else:
|
||||
bot = BaseBot(
|
||||
bot_id=bot_id,
|
||||
bot_token=bot_token,
|
||||
work_dir=work_dir,
|
||||
bot_name=bot_name,
|
||||
allowed_user_ids=allowed_user_ids,
|
||||
)
|
||||
|
||||
return bot.run()
|
||||
|
||||
|
||||
def stop_bot(bot_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Stop a bot's systemd service.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to stop
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
"""
|
||||
service_name = f"telegram-bot@{bot_id}"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "stop", service_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, f"Stopped {service_name}"
|
||||
|
||||
return False, f"Failed to stop {service_name}: {result.stderr.strip()}"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"Timeout stopping {service_name}"
|
||||
except OSError as e:
|
||||
return False, f"Error stopping {service_name}: {e}"
|
||||
|
||||
|
||||
def get_status(bot_id: str | None = None) -> list[dict]:
|
||||
"""
|
||||
Get bot status entries. If no bot_id, returns all bots.
|
||||
|
||||
Args:
|
||||
bot_id: Specific bot to check, or None for all bots
|
||||
|
||||
Returns:
|
||||
List of bot entry dicts. Empty list if not found.
|
||||
"""
|
||||
if bot_id:
|
||||
bot = get_bot(bot_id)
|
||||
return [bot] if bot else []
|
||||
|
||||
return list_bots()
|
||||
|
||||
|
||||
def get_all_bots() -> list[dict]:
|
||||
"""
|
||||
Get all registered bots.
|
||||
|
||||
Returns:
|
||||
List of bot entry dicts.
|
||||
"""
|
||||
return list_bots()
|
||||
|
||||
|
||||
def format_bot_details(bot: dict) -> list[str]:
|
||||
"""
|
||||
Format a single bot entry into display lines.
|
||||
|
||||
Args:
|
||||
bot: Bot entry dict from registry.
|
||||
|
||||
Returns:
|
||||
List of formatted strings for display.
|
||||
"""
|
||||
bot_id = bot.get("bot_id", "?")
|
||||
username = bot.get("username", "?")
|
||||
branch = bot.get("branch_name") or "none (base bot)"
|
||||
work_dir = bot.get("work_dir", "?")
|
||||
status = bot.get("status", "?")
|
||||
service = bot.get("service_name", f"telegram-bot@{bot_id}")
|
||||
|
||||
return [
|
||||
f"Bot ID: {bot_id}",
|
||||
f"Username: @{username}",
|
||||
f"Branch: {branch}",
|
||||
f"Work Dir: {work_dir}",
|
||||
f"Status: {status}",
|
||||
f"Service: {service}",
|
||||
]
|
||||
|
||||
|
||||
def format_bot_table(bots: list[dict]) -> list[str]:
|
||||
"""
|
||||
Format a list of bots into table rows.
|
||||
|
||||
Args:
|
||||
bots: List of bot entry dicts.
|
||||
|
||||
Returns:
|
||||
List of formatted strings (header + separator + rows + total).
|
||||
"""
|
||||
lines = []
|
||||
lines.append(f" {'Bot ID':<18} {'Branch':<16} {'Username':<24} {'Status':<10}")
|
||||
lines.append(f" {'---' * 6:<18} {'---' * 5:<16} {'---' * 8:<24} {'---' * 3:<10}")
|
||||
|
||||
for bot in bots:
|
||||
bot_id = bot.get("bot_id", "?")
|
||||
branch = bot.get("branch_name") or "-"
|
||||
username = f"@{bot.get('username', '?')}"
|
||||
status = bot.get("status", "?")
|
||||
lines.append(f" {bot_id:<18} {branch:<16} {username:<24} {status}")
|
||||
|
||||
lines.append(f" Total: {len(bots)} bot(s)")
|
||||
return lines
|
||||
|
||||
|
||||
def parse_create_args(args: list) -> dict | None:
|
||||
"""
|
||||
Parse create command arguments.
|
||||
|
||||
Args:
|
||||
args: Arguments after 'create' (bot_id, token, [--branch name], [--work-dir path])
|
||||
|
||||
Returns:
|
||||
Dict with parsed values, or None if args are insufficient.
|
||||
"""
|
||||
if len(args) < 2:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"bot_id": args[0],
|
||||
"bot_token": args[1],
|
||||
"branch_name": None,
|
||||
"work_dir": None,
|
||||
}
|
||||
|
||||
i = 2
|
||||
while i < len(args):
|
||||
if args[i] == "--branch" and i + 1 < len(args):
|
||||
result["branch_name"] = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--work-dir" and i + 1 < len(args):
|
||||
result["work_dir"] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return result
|
||||
@@ -1,371 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: bot_registry.py - Bot registry management for multi-bot architecture
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - registry CRUD with fcntl locking for multi-bot management
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error handling (graceful - never raise)
|
||||
# - Uses Prax system_logger (FPLAN-0382 migration)
|
||||
# - Thread-safe via fcntl.flock (shared read, exclusive write)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Bot Registry Management for Multi-Bot Architecture
|
||||
|
||||
Manages a central registry of all Telegram bots in the AIPass ecosystem.
|
||||
Each bot entry tracks its ID, branch association, working directory,
|
||||
config path, systemd service name, and status.
|
||||
|
||||
Registry location: ~/.aipass/telegram_bots/_registry.json
|
||||
|
||||
Thread-safe via fcntl.flock file locking (same pattern as session_store.py).
|
||||
All public functions return None/False on errors - never raise exceptions.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
# Logging (Prax system_logger — FPLAN-0382)
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
def _aipass_data_dir() -> Path:
|
||||
"""User data directory for AIPass runtime files."""
|
||||
env = os.environ.get("AIPASS_DATA_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
|
||||
|
||||
REGISTRY_DIR = _aipass_data_dir() / "telegram_bots"
|
||||
REGISTRY_FILE = REGISTRY_DIR / "_registry.json"
|
||||
|
||||
# =============================================
|
||||
# EMPTY REGISTRY TEMPLATE
|
||||
# =============================================
|
||||
|
||||
|
||||
def _empty_registry() -> dict:
|
||||
"""Return a fresh empty registry structure."""
|
||||
return {
|
||||
"bots": {},
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Return current UTC timestamp in ISO format."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# =============================================
|
||||
# REGISTRY LIFECYCLE
|
||||
# =============================================
|
||||
|
||||
|
||||
def ensure_registry() -> None:
|
||||
"""
|
||||
Create registry directory and file if they don't exist.
|
||||
|
||||
Safe to call multiple times - only creates what is missing.
|
||||
"""
|
||||
try:
|
||||
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not REGISTRY_FILE.exists():
|
||||
data = _empty_registry()
|
||||
with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(data, f, indent=2)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
logger.info("Created new bot registry at %s", REGISTRY_FILE)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to ensure registry: %s", e)
|
||||
|
||||
|
||||
# =============================================
|
||||
# READ / WRITE WITH LOCKING
|
||||
# =============================================
|
||||
|
||||
|
||||
def load_registry() -> dict:
|
||||
"""
|
||||
Load registry with fcntl shared lock.
|
||||
|
||||
Returns:
|
||||
Registry dict. Returns empty structure if file missing or corrupt.
|
||||
"""
|
||||
if not REGISTRY_FILE.exists():
|
||||
return _empty_registry()
|
||||
|
||||
try:
|
||||
with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
try:
|
||||
data = json.load(f)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
if not isinstance(data, dict) or "bots" not in data:
|
||||
logger.warning("Registry file has unexpected structure, returning empty")
|
||||
return _empty_registry()
|
||||
|
||||
return data
|
||||
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load registry: %s", e)
|
||||
return _empty_registry()
|
||||
|
||||
|
||||
def save_registry(data: dict) -> bool:
|
||||
"""
|
||||
Save registry with fcntl exclusive lock.
|
||||
|
||||
Args:
|
||||
data: Full registry dict to write.
|
||||
|
||||
Returns:
|
||||
True if saved successfully, False on error.
|
||||
"""
|
||||
try:
|
||||
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Update metadata timestamp
|
||||
if "metadata" not in data:
|
||||
data["metadata"] = {}
|
||||
data["metadata"]["last_updated"] = _now_iso()
|
||||
|
||||
with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(data, f, indent=2)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
return True
|
||||
|
||||
except OSError as e:
|
||||
logger.warning("Failed to save registry: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# CRUD OPERATIONS
|
||||
# =============================================
|
||||
|
||||
|
||||
def get_bot(bot_id: str) -> Optional[dict]:
|
||||
"""
|
||||
Get a single bot entry by bot_id.
|
||||
|
||||
Args:
|
||||
bot_id: Unique bot identifier.
|
||||
|
||||
Returns:
|
||||
Bot entry dict or None if not found.
|
||||
"""
|
||||
registry = load_registry()
|
||||
return registry.get("bots", {}).get(bot_id)
|
||||
|
||||
|
||||
def list_bots(status: Optional[str] = None) -> list[dict]:
|
||||
"""
|
||||
List all bots, optionally filtered by status.
|
||||
|
||||
Args:
|
||||
status: Filter by status (e.g., "active", "inactive"). None returns all.
|
||||
|
||||
Returns:
|
||||
List of bot entry dicts.
|
||||
"""
|
||||
registry = load_registry()
|
||||
bots = list(registry.get("bots", {}).values())
|
||||
|
||||
if status is not None:
|
||||
bots = [b for b in bots if b.get("status") == status]
|
||||
|
||||
return bots
|
||||
|
||||
|
||||
def register_bot(
|
||||
bot_id: str,
|
||||
username: str,
|
||||
branch_name: Optional[str],
|
||||
work_dir: str,
|
||||
config_path: str,
|
||||
bot_token_ref: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Register a new bot in the registry.
|
||||
|
||||
Args:
|
||||
bot_id: Unique bot identifier.
|
||||
username: Telegram bot username (e.g., "aipass_dev_central_bot").
|
||||
branch_name: AIPass branch name, or None for the base bot.
|
||||
work_dir: Working directory for Claude sessions.
|
||||
config_path: Path to the bot's config JSON file.
|
||||
bot_token_ref: Optional env var name or reference for the token.
|
||||
|
||||
Returns:
|
||||
True on success, False if bot_id already exists or on error.
|
||||
"""
|
||||
registry = load_registry()
|
||||
bots = registry.get("bots", {})
|
||||
|
||||
if bot_id in bots:
|
||||
logger.warning("Bot '%s' already registered", bot_id)
|
||||
return False
|
||||
|
||||
now = _now_iso()
|
||||
entry = {
|
||||
"bot_id": bot_id,
|
||||
"username": username,
|
||||
"branch_name": branch_name,
|
||||
"work_dir": str(work_dir),
|
||||
"config_path": str(config_path),
|
||||
"service_name": f"telegram-bot@{bot_id}",
|
||||
"status": "active",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
if bot_token_ref:
|
||||
entry["bot_token_env"] = bot_token_ref
|
||||
|
||||
bots[bot_id] = entry
|
||||
registry["bots"] = bots
|
||||
|
||||
if not save_registry(registry):
|
||||
return False
|
||||
|
||||
logger.info("Registered bot '%s' (branch=%s, work_dir=%s)", bot_id, branch_name, work_dir)
|
||||
return True
|
||||
|
||||
|
||||
def update_bot(bot_id: str, **kwargs) -> bool:
|
||||
"""
|
||||
Update specific fields of a bot entry.
|
||||
|
||||
Auto-updates the updated_at timestamp.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to update.
|
||||
**kwargs: Fields to update (e.g., status="inactive", username="new_name").
|
||||
|
||||
Returns:
|
||||
True on success, False if bot not found or on error.
|
||||
"""
|
||||
registry = load_registry()
|
||||
bots = registry.get("bots", {})
|
||||
|
||||
if bot_id not in bots:
|
||||
logger.warning("Cannot update bot '%s': not found", bot_id)
|
||||
return False
|
||||
|
||||
for key, value in kwargs.items():
|
||||
bots[bot_id][key] = value
|
||||
|
||||
bots[bot_id]["updated_at"] = _now_iso()
|
||||
registry["bots"] = bots
|
||||
|
||||
if not save_registry(registry):
|
||||
return False
|
||||
|
||||
logger.info("Updated bot '%s': %s", bot_id, list(kwargs.keys()))
|
||||
return True
|
||||
|
||||
|
||||
def deregister_bot(bot_id: str) -> bool:
|
||||
"""
|
||||
Remove a bot from the registry.
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier to remove.
|
||||
|
||||
Returns:
|
||||
True on success, False if bot not found or on error.
|
||||
"""
|
||||
registry = load_registry()
|
||||
bots = registry.get("bots", {})
|
||||
|
||||
if bot_id not in bots:
|
||||
logger.warning("Cannot deregister bot '%s': not found", bot_id)
|
||||
return False
|
||||
|
||||
del bots[bot_id]
|
||||
registry["bots"] = bots
|
||||
|
||||
if not save_registry(registry):
|
||||
return False
|
||||
|
||||
logger.info("Deregistered bot '%s'", bot_id)
|
||||
return True
|
||||
|
||||
|
||||
# =============================================
|
||||
# LOOKUP HELPERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def get_bot_by_branch(branch_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Find a bot by its branch_name.
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (e.g., "dev_central").
|
||||
|
||||
Returns:
|
||||
Bot entry dict or None if not found.
|
||||
"""
|
||||
registry = load_registry()
|
||||
for bot in registry.get("bots", {}).values():
|
||||
if bot.get("branch_name") == branch_name:
|
||||
return bot
|
||||
return None
|
||||
|
||||
|
||||
def get_bot_by_work_dir(work_dir) -> Optional[dict]:
|
||||
"""
|
||||
Find a bot whose work_dir matches the given path.
|
||||
|
||||
Used by the response router to match CWD to a bot.
|
||||
|
||||
Args:
|
||||
work_dir: Path (str or Path) to match against bot work_dir fields.
|
||||
|
||||
Returns:
|
||||
Bot entry dict or None if not found.
|
||||
"""
|
||||
target = str(Path(work_dir).resolve())
|
||||
registry = load_registry()
|
||||
|
||||
for bot in registry.get("bots", {}).values():
|
||||
bot_dir = bot.get("work_dir", "")
|
||||
if bot_dir:
|
||||
try:
|
||||
if str(Path(bot_dir).resolve()) == target:
|
||||
return bot
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
|
||||
return None
|
||||
@@ -1,539 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: botfather_client.py - Telethon-based BotFather automation client
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - automated bot creation via BotFather using Telethon
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Telethon for Telegram user-account interaction with @BotFather
|
||||
# - Graceful error handling (never raise - return None on failure)
|
||||
# - Sync wrapper for async Telethon calls (callable from stdlib code)
|
||||
# - Uses Prax system_logger (FPLAN-0382 migration)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
BotFather Automation Client
|
||||
|
||||
Automates Telegram bot creation by driving a conversation with @BotFather
|
||||
using Telethon (user-account MTProto client). This replaces the manual
|
||||
"go to BotFather, create a bot, paste the token" workflow.
|
||||
|
||||
Flow:
|
||||
1. Connect to Telegram as Patrick's user account (pre-authenticated session)
|
||||
2. Send /newbot to @BotFather
|
||||
3. Provide display name and username
|
||||
4. Parse the bot token from BotFather's success response
|
||||
5. Return token + metadata for bot_factory.py to complete registration
|
||||
|
||||
Requirements:
|
||||
- Telethon 1.42.0+ installed in .venv
|
||||
- One-time manual phone auth to create .telethon.session file
|
||||
- API credentials in ~/.aipass/telegram_bots/.telethon_config.json
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
# Logging (Prax system_logger — FPLAN-0382)
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Third party (Telethon) — runtime-imported in methods to avoid Pyright issues
|
||||
TELETHON_AVAILABLE = False
|
||||
try:
|
||||
import telethon as _telethon_check # noqa: F401
|
||||
TELETHON_AVAILABLE = True
|
||||
del _telethon_check
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
def _aipass_data_dir() -> Path:
|
||||
"""User data directory for AIPass runtime files."""
|
||||
env = os.environ.get("AIPASS_DATA_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
|
||||
|
||||
BOT_CONFIG_DIR = _aipass_data_dir() / "telegram_bots"
|
||||
TELETHON_CONFIG_PATH = BOT_CONFIG_DIR / ".telethon_config.json"
|
||||
SESSION_PATH = BOT_CONFIG_DIR / ".telethon" # Telethon appends .session automatically
|
||||
|
||||
BOTFATHER_USERNAME = "BotFather"
|
||||
BOT_TOKEN_PATTERN = re.compile(r"\d+:[A-Za-z0-9_-]+")
|
||||
|
||||
# Timeouts
|
||||
MESSAGE_TIMEOUT = 30 # seconds to wait for BotFather response
|
||||
MAX_USERNAME_ATTEMPTS = 3
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONFIG LOADER
|
||||
# =============================================
|
||||
|
||||
|
||||
def _load_telethon_config() -> Optional[dict]:
|
||||
"""
|
||||
Load Telethon API credentials from .telethon_config.json.
|
||||
|
||||
Expected format:
|
||||
{"api_id": 12345, "api_hash": "abc123..."}
|
||||
|
||||
Returns:
|
||||
Dict with "api_id" (int) and "api_hash" (str), or None on failure.
|
||||
"""
|
||||
try:
|
||||
if not TELETHON_CONFIG_PATH.exists():
|
||||
logger.warning("Telethon config not found: %s", TELETHON_CONFIG_PATH)
|
||||
return None
|
||||
|
||||
with open(TELETHON_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
api_id = config.get("api_id")
|
||||
api_hash = config.get("api_hash")
|
||||
|
||||
if not api_id or not api_hash:
|
||||
logger.warning("Telethon config missing api_id or api_hash")
|
||||
return None
|
||||
|
||||
# Ensure api_id is an integer
|
||||
config["api_id"] = int(api_id)
|
||||
config["api_hash"] = str(api_hash)
|
||||
|
||||
logger.info("Telethon config loaded successfully")
|
||||
return config
|
||||
|
||||
except (json.JSONDecodeError, ValueError, OSError) as e:
|
||||
logger.warning("Failed to load Telethon config: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# SETUP CHECK
|
||||
# =============================================
|
||||
|
||||
|
||||
def check_telethon_setup() -> tuple[bool, str]:
|
||||
"""
|
||||
Check whether Telethon is ready for BotFather automation.
|
||||
|
||||
Verifies:
|
||||
1. Telethon library is importable
|
||||
2. .telethon_config.json exists with valid credentials
|
||||
3. .telethon.session exists (phone auth already completed)
|
||||
|
||||
Returns:
|
||||
(True, "ready") if everything is in place.
|
||||
(False, "reason") with a human-readable explanation of what is missing.
|
||||
"""
|
||||
if not TELETHON_AVAILABLE:
|
||||
return (False, "Telethon library not installed. Run: pip install telethon")
|
||||
|
||||
if not TELETHON_CONFIG_PATH.exists():
|
||||
return (False, f"Telethon config not found at {TELETHON_CONFIG_PATH}")
|
||||
|
||||
config = _load_telethon_config()
|
||||
if config is None:
|
||||
return (False, "Telethon config is invalid (missing api_id or api_hash)")
|
||||
|
||||
# Telethon creates session files with .session extension
|
||||
session_file = Path(str(SESSION_PATH) + ".session")
|
||||
if not session_file.exists():
|
||||
return (False, f"Telethon session not found at {session_file}. Run one-time phone auth first.")
|
||||
|
||||
return (True, "ready")
|
||||
|
||||
|
||||
# =============================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _format_display_name(branch_name: str) -> str:
|
||||
"""
|
||||
Convert a branch_name to a BotFather display name.
|
||||
|
||||
Examples:
|
||||
"dev_central" -> "AIPass Dev Central"
|
||||
"flow" -> "AIPass Flow"
|
||||
"vera" -> "AIPass Vera"
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (snake_case).
|
||||
|
||||
Returns:
|
||||
Display name string.
|
||||
"""
|
||||
title = branch_name.replace("_", " ").title()
|
||||
return f"AIPass {title}"
|
||||
|
||||
|
||||
def _format_username(branch_name: str, suffix: int = 0) -> str:
|
||||
"""
|
||||
Generate a BotFather username from a branch name.
|
||||
|
||||
Examples:
|
||||
("dev_central", 0) -> "aipass_dev_central_bot"
|
||||
("dev_central", 1) -> "aipass_dev_central_1_bot"
|
||||
("dev_central", 2) -> "aipass_dev_central_2_bot"
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (snake_case).
|
||||
suffix: Numeric suffix for retries (0 = no suffix).
|
||||
|
||||
Returns:
|
||||
Username string ending in _bot.
|
||||
"""
|
||||
if suffix == 0:
|
||||
return f"aipass_{branch_name}_bot"
|
||||
return f"aipass_{branch_name}_{suffix}_bot"
|
||||
|
||||
|
||||
# =============================================
|
||||
# BOTFATHER CLIENT
|
||||
# =============================================
|
||||
|
||||
|
||||
class BotFatherClient:
|
||||
"""
|
||||
Telethon-based client that automates bot creation via @BotFather.
|
||||
|
||||
Uses Patrick's authenticated user session to send commands to BotFather
|
||||
and parse the resulting bot token.
|
||||
|
||||
Usage:
|
||||
client = BotFatherClient(api_id=12345, api_hash="abc...")
|
||||
result = await client.create_bot("dev_central")
|
||||
# result = {"token": "123:ABC", "username": "aipass_dev_central_bot", "display_name": "AIPass Dev Central"}
|
||||
"""
|
||||
|
||||
def __init__(self, api_id: int, api_hash: str) -> None:
|
||||
self._api_id = api_id
|
||||
self._api_hash = api_hash
|
||||
self._client: Any = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""
|
||||
Connect to Telegram using the existing session file.
|
||||
|
||||
The session file must already exist from a prior manual phone auth.
|
||||
This method will NOT prompt for phone/code input.
|
||||
|
||||
Returns:
|
||||
True if connected and authorized, False otherwise.
|
||||
"""
|
||||
try:
|
||||
from telethon import TelegramClient as _TelegramClient
|
||||
|
||||
self._client = _TelegramClient(
|
||||
str(SESSION_PATH),
|
||||
self._api_id,
|
||||
self._api_hash,
|
||||
)
|
||||
await self._client.connect()
|
||||
|
||||
if not await self._client.is_user_authorized():
|
||||
logger.warning("Telethon session exists but is not authorized. Re-run phone auth.")
|
||||
await self._client.disconnect()
|
||||
self._client = None
|
||||
return False
|
||||
|
||||
me = await self._client.get_me()
|
||||
if me:
|
||||
logger.info(
|
||||
"Connected to Telegram as: %s (id=%s)",
|
||||
getattr(me, "first_name", "?"),
|
||||
getattr(me, "id", "?"),
|
||||
)
|
||||
else:
|
||||
logger.info("Connected to Telegram (could not resolve self)")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to Telegram: %s", e)
|
||||
self._client = None
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Telegram gracefully."""
|
||||
if self._client:
|
||||
try:
|
||||
await self._client.disconnect()
|
||||
logger.info("Disconnected from Telegram")
|
||||
except Exception as e:
|
||||
logger.warning("Error during disconnect: %s", e)
|
||||
finally:
|
||||
self._client = None
|
||||
|
||||
async def _send_and_wait(self, entity: Any, message: str) -> Optional[str]:
|
||||
"""
|
||||
Send a message to BotFather and wait for a response.
|
||||
|
||||
Handles FloodWaitError by sleeping for the required duration and retrying once.
|
||||
|
||||
Args:
|
||||
entity: The BotFather entity to send to.
|
||||
message: The text message to send.
|
||||
|
||||
Returns:
|
||||
BotFather's response text, or None on timeout/error.
|
||||
"""
|
||||
if not self._client:
|
||||
logger.warning("_send_and_wait called without active client")
|
||||
return None
|
||||
|
||||
from telethon.errors import FloodWaitError as _FloodWaitError
|
||||
from telethon.errors import RPCError as _RPCError
|
||||
|
||||
try:
|
||||
await self._client.send_message(entity, message)
|
||||
logger.info("Sent to BotFather: %s", message)
|
||||
except _FloodWaitError as e:
|
||||
wait_seconds = e.seconds
|
||||
logger.warning("FloodWaitError: waiting %d seconds before retry", wait_seconds)
|
||||
await asyncio.sleep(wait_seconds)
|
||||
try:
|
||||
await self._client.send_message(entity, message)
|
||||
logger.info("Sent to BotFather (after flood wait): %s", message)
|
||||
except Exception as retry_err:
|
||||
logger.warning("Failed to send after flood wait: %s", retry_err)
|
||||
return None
|
||||
except _RPCError as e:
|
||||
logger.warning("RPC error sending to BotFather: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Unexpected error sending to BotFather: %s", e)
|
||||
return None
|
||||
|
||||
# Wait for BotFather's response
|
||||
deadline = time.monotonic() + MESSAGE_TIMEOUT
|
||||
# Brief pause to let BotFather process
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
# Get the most recent messages from BotFather
|
||||
messages = await self._client.get_messages(entity, limit=1)
|
||||
if not messages:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
# get_messages returns a list-like object
|
||||
msg_list = list(messages)
|
||||
if msg_list:
|
||||
latest = msg_list[0]
|
||||
# Check that this message is FROM BotFather (not our own)
|
||||
if getattr(latest, "out", True) is False and getattr(latest, "text", None):
|
||||
response_text: str = latest.text
|
||||
logger.info("BotFather response received (%d chars)", len(response_text))
|
||||
return response_text
|
||||
|
||||
# Poll interval
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
logger.warning("Timeout waiting for BotFather response (after %ds)", MESSAGE_TIMEOUT)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error reading BotFather response: %s", e)
|
||||
return None
|
||||
|
||||
async def create_bot(self, branch_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Create a new Telegram bot via @BotFather conversation.
|
||||
|
||||
Conversation flow:
|
||||
1. /newbot
|
||||
2. Display name (e.g., "AIPass Dev Central")
|
||||
3. Username (e.g., "aipass_dev_central_bot")
|
||||
4. Parse token from success response
|
||||
|
||||
If the username is taken, retries with numeric suffixes up to MAX_USERNAME_ATTEMPTS.
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (e.g., "dev_central", "flow").
|
||||
|
||||
Returns:
|
||||
Dict with "token", "username", "display_name" on success.
|
||||
None on any failure.
|
||||
"""
|
||||
if not self._client:
|
||||
logger.warning("create_bot called without active connection")
|
||||
return None
|
||||
|
||||
display_name = _format_display_name(branch_name)
|
||||
|
||||
# Resolve BotFather entity
|
||||
try:
|
||||
botfather = await self._client.get_entity(BOTFATHER_USERNAME)
|
||||
logger.info("Resolved BotFather entity: %s", getattr(botfather, "id", "?"))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to resolve @BotFather entity: %s", e)
|
||||
return None
|
||||
|
||||
# Step 1: Send /newbot
|
||||
response = await self._send_and_wait(botfather, "/newbot")
|
||||
if not response:
|
||||
logger.warning("BotFather did not respond to /newbot")
|
||||
return None
|
||||
|
||||
# BotFather should ask for a name
|
||||
if "name" not in response.lower():
|
||||
logger.warning("Unexpected BotFather response to /newbot: %s", response[:200])
|
||||
return None
|
||||
|
||||
logger.info("BotFather asked for bot name")
|
||||
|
||||
# Step 2: Send display name
|
||||
response = await self._send_and_wait(botfather, display_name)
|
||||
if not response:
|
||||
logger.warning("BotFather did not respond to display name")
|
||||
return None
|
||||
|
||||
# BotFather should ask for a username
|
||||
if "username" not in response.lower():
|
||||
logger.warning("Unexpected BotFather response to display name: %s", response[:200])
|
||||
return None
|
||||
|
||||
logger.info("BotFather asked for username")
|
||||
|
||||
# Step 3: Try usernames with incrementing suffix
|
||||
for attempt in range(MAX_USERNAME_ATTEMPTS):
|
||||
username = _format_username(branch_name, suffix=attempt)
|
||||
logger.info("Trying username: %s (attempt %d/%d)", username, attempt + 1, MAX_USERNAME_ATTEMPTS)
|
||||
|
||||
response = await self._send_and_wait(botfather, username)
|
||||
if not response:
|
||||
logger.warning("BotFather did not respond to username '%s'", username)
|
||||
return None
|
||||
|
||||
# Check if the username was accepted (token in response)
|
||||
token_match = BOT_TOKEN_PATTERN.search(response)
|
||||
if token_match:
|
||||
token = token_match.group()
|
||||
logger.info(
|
||||
"Bot created successfully: @%s (token: %s...%s)",
|
||||
username, token[:8], token[-4:],
|
||||
)
|
||||
return {
|
||||
"token": token,
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
# Username taken - BotFather says "Sorry" or mentions "already"
|
||||
if "sorry" in response.lower() or "already" in response.lower() or "taken" in response.lower():
|
||||
logger.info("Username '%s' is taken, trying next", username)
|
||||
# If not the last attempt, BotFather is still waiting for a username
|
||||
# so we can send another one directly without restarting /newbot
|
||||
continue
|
||||
|
||||
# Unexpected response
|
||||
logger.warning(
|
||||
"Unexpected BotFather response for username '%s': %s",
|
||||
username, response[:200],
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"All %d username attempts exhausted for branch '%s'",
|
||||
MAX_USERNAME_ATTEMPTS, branch_name,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# SYNCHRONOUS WRAPPER
|
||||
# =============================================
|
||||
|
||||
|
||||
def create_bot_via_botfather(branch_name: str) -> Optional[dict]:
|
||||
"""
|
||||
Synchronous wrapper to create a Telegram bot via BotFather automation.
|
||||
|
||||
This is the main entry point for stdlib-based callers (e.g., base_bot.py).
|
||||
Loads config, connects via Telethon, drives the BotFather conversation,
|
||||
and returns the result.
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (e.g., "dev_central").
|
||||
|
||||
Returns:
|
||||
Dict with "token", "username", "display_name" on success.
|
||||
None on any failure (config missing, connection failed, BotFather error, etc.).
|
||||
"""
|
||||
# Pre-flight checks
|
||||
ready, reason = check_telethon_setup()
|
||||
if not ready:
|
||||
logger.warning("Telethon setup check failed: %s", reason)
|
||||
return None
|
||||
|
||||
# Load config
|
||||
config = _load_telethon_config()
|
||||
if config is None:
|
||||
logger.warning("Cannot create bot: Telethon config not loaded")
|
||||
return None
|
||||
|
||||
api_id = config["api_id"]
|
||||
api_hash = config["api_hash"]
|
||||
|
||||
# Run the async flow
|
||||
client = BotFatherClient(api_id, api_hash)
|
||||
result = None
|
||||
|
||||
async def _run() -> Optional[dict]:
|
||||
connected = await client.connect()
|
||||
if not connected:
|
||||
return None
|
||||
try:
|
||||
return await client.create_bot(branch_name)
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
try:
|
||||
# Handle the case where an event loop is already running
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# We're inside an existing event loop (unlikely for our stdlib callers,
|
||||
# but handle gracefully). Create a new loop in a thread.
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, _run())
|
||||
result = future.result(timeout=120)
|
||||
else:
|
||||
result = asyncio.run(_run())
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("create_bot_via_botfather failed: %s", e)
|
||||
return None
|
||||
|
||||
if result:
|
||||
logger.info(
|
||||
"Bot created via BotFather: @%s for branch '%s'",
|
||||
result.get("username"), branch_name,
|
||||
)
|
||||
else:
|
||||
logger.warning("Bot creation via BotFather failed for branch '%s'", branch_name)
|
||||
|
||||
return result
|
||||
@@ -1,170 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: branch_plugin.py - BranchPlugin extends BaseBot for per-branch bots
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - BranchPlugin with message prefixing and startup injection
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Inherits Prax get_direct_logger() from BaseBot (FPLAN-0382 migration)
|
||||
# - Extends BaseBot via hook overrides
|
||||
# - No duplicated logic - all core behavior lives in BaseBot
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
BranchPlugin - Per-branch Telegram bot extending BaseBot.
|
||||
|
||||
Each AIPass branch gets its own dedicated Telegram bot. BranchPlugin overrides
|
||||
BaseBot's hooks to:
|
||||
- Prefix incoming messages with "Patrick via Telegram: "
|
||||
- Prefix outgoing responses with "@branch_name"
|
||||
- Inject "hi" on session creation to trigger the branch startup protocol
|
||||
|
||||
Usage:
|
||||
bot = BranchPlugin(
|
||||
branch_name="dev_central",
|
||||
bot_id="dev_central",
|
||||
bot_token="123:ABC",
|
||||
work_dir=Path.cwd(),
|
||||
bot_name="AIPass Dev Central Bot",
|
||||
allowed_user_ids=[7235222625],
|
||||
)
|
||||
sys.exit(bot.run())
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
|
||||
# Sibling import
|
||||
from aipass.api.apps.handlers.telegram.base_bot import BaseBot
|
||||
|
||||
|
||||
# =============================================
|
||||
# BranchPlugin CLASS
|
||||
# =============================================
|
||||
|
||||
class BranchPlugin(BaseBot):
|
||||
"""
|
||||
Per-branch Telegram bot that extends BaseBot with branch-specific behavior.
|
||||
|
||||
Overrides BaseBot hooks to prefix messages, tag responses, and trigger
|
||||
the AIPass startup protocol when a new tmux session is created.
|
||||
"""
|
||||
|
||||
def __init__(self, branch_name: str, **kwargs) -> None:
|
||||
"""
|
||||
Initialize BranchPlugin.
|
||||
|
||||
Args:
|
||||
branch_name: AIPass branch name (e.g., "dev_central", "seed")
|
||||
**kwargs: All BaseBot constructor arguments (bot_id, bot_token, etc.)
|
||||
"""
|
||||
self.branch_name = branch_name
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# =============================================
|
||||
# HOOK OVERRIDES
|
||||
# =============================================
|
||||
|
||||
def on_message(self, text: str) -> str:
|
||||
"""
|
||||
Prefix incoming messages with sender attribution.
|
||||
|
||||
Args:
|
||||
text: Raw message text from Telegram
|
||||
|
||||
Returns:
|
||||
Prefixed text for Claude: "Patrick via Telegram: {text}"
|
||||
"""
|
||||
return f"Patrick via Telegram: {text}"
|
||||
|
||||
def on_response(self, text: str) -> str:
|
||||
"""
|
||||
Prefix outgoing responses with branch tag.
|
||||
|
||||
Args:
|
||||
text: Raw response text from Claude
|
||||
|
||||
Returns:
|
||||
Tagged text: "@{branch_name}\n{text}"
|
||||
"""
|
||||
return f"@{self.branch_name}\n{text}"
|
||||
|
||||
def on_session_create(self, session_name: str, work_dir: Path) -> None:
|
||||
"""
|
||||
Inject "hi" after tmux session creation to trigger startup protocol.
|
||||
|
||||
Waits 2 seconds for Claude to fully initialize, then injects "hi"
|
||||
which triggers the AIPass startup sequence (reading memories, etc.).
|
||||
|
||||
Args:
|
||||
session_name: The tmux session name that was created
|
||||
work_dir: The working directory of the session
|
||||
"""
|
||||
self.logger.info(
|
||||
"Branch session created for @%s, injecting startup greeting",
|
||||
self.branch_name,
|
||||
)
|
||||
time.sleep(2)
|
||||
self.inject_message("hi")
|
||||
|
||||
|
||||
# =============================================
|
||||
# CLI ENTRY POINT
|
||||
# =============================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="AIPass Telegram Branch Bot")
|
||||
parser.add_argument("--bot-id", required=True, help="Bot identifier")
|
||||
parser.add_argument("--config", help="Path to bot config JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load config from data dir telegram_bots/{bot_id}.json or --config path
|
||||
_env_data = os.environ.get("AIPASS_DATA_DIR")
|
||||
_data_dir = Path(_env_data) if _env_data else (
|
||||
Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
|
||||
)
|
||||
config_path = (
|
||||
Path(args.config)
|
||||
if args.config
|
||||
else _data_dir / "telegram_bots" / f"{args.bot_id}.json"
|
||||
)
|
||||
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# If config has "branch_name", create BranchPlugin; otherwise BaseBot
|
||||
shared_session = config.get("shared_session")
|
||||
|
||||
if config.get("branch_name"):
|
||||
bot = BranchPlugin(
|
||||
branch_name=config["branch_name"],
|
||||
bot_id=args.bot_id,
|
||||
bot_token=config["bot_token"],
|
||||
work_dir=Path(config["work_dir"]),
|
||||
bot_name=config.get("bot_name", f"AIPass {config['branch_name']} Bot"),
|
||||
allowed_user_ids=config.get("allowed_user_ids", []),
|
||||
shared_session=shared_session,
|
||||
)
|
||||
else:
|
||||
bot = BaseBot(
|
||||
bot_id=args.bot_id,
|
||||
bot_token=config["bot_token"],
|
||||
work_dir=Path(config.get("work_dir", str(Path.cwd()))),
|
||||
bot_name=config.get("bot_name", "AIPass Bot"),
|
||||
allowed_user_ids=config.get("allowed_user_ids", []),
|
||||
shared_session=shared_session,
|
||||
)
|
||||
|
||||
sys.exit(bot.run())
|
||||
@@ -1,267 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: config.py - Telegram Configuration Handler
|
||||
# Date: 2026-02-03
|
||||
# Version: 1.2.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.2.0 (2026-02-24): Add multi-bot config: load_bot_config, list_bot_configs, validate_bot_config
|
||||
# - v1.1.0 (2026-02-03): Add allowed_user_ids loading for user allowlist
|
||||
# - v1.0.0 (2026-02-03): Initial config loader for Telegram bridge
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram Configuration Handler
|
||||
|
||||
Manages Telegram bot configuration:
|
||||
- Load bot token from config file (legacy single-bot: ~/.aipass/telegram_config.json)
|
||||
- Load bot username
|
||||
- Load allowed user IDs for access control
|
||||
- Load per-bot configs (multi-bot: ~/.aipass/telegram_bots/{bot_id}.json)
|
||||
- List and validate bot configs
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
from typing import Optional, List
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
def _aipass_data_dir() -> Path:
|
||||
"""User data directory for AIPass runtime files."""
|
||||
env = os.environ.get("AIPASS_DATA_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
|
||||
|
||||
_DATA_DIR = _aipass_data_dir()
|
||||
CONFIG_PATH = _DATA_DIR / "telegram_config.json"
|
||||
BOT_CONFIG_DIR = _DATA_DIR / "telegram_bots"
|
||||
|
||||
REQUIRED_BOT_FIELDS = ("bot_id", "bot_token")
|
||||
|
||||
# =============================================
|
||||
# CONFIGURATION LOADING
|
||||
# =============================================
|
||||
|
||||
def load_telegram_config() -> Optional[dict]:
|
||||
"""
|
||||
Load Telegram configuration from config file.
|
||||
|
||||
Config file location: ~/.aipass/telegram_config.json
|
||||
Expected structure:
|
||||
{
|
||||
"telegram_bot_token": "...",
|
||||
"telegram_bot_username": "aipass_bridge_bot",
|
||||
"allowed_user_ids": []
|
||||
}
|
||||
|
||||
Returns:
|
||||
Configuration dict or None if load fails
|
||||
"""
|
||||
try:
|
||||
if not CONFIG_PATH.exists():
|
||||
return None
|
||||
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
return config
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_bot_token() -> Optional[str]:
|
||||
"""
|
||||
Get Telegram bot token from config.
|
||||
|
||||
Returns:
|
||||
Bot token string or None if not found
|
||||
"""
|
||||
config = load_telegram_config()
|
||||
if not config:
|
||||
return None
|
||||
|
||||
token = config.get("telegram_bot_token")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def get_bot_username() -> Optional[str]:
|
||||
"""
|
||||
Get Telegram bot username from config.
|
||||
|
||||
Returns:
|
||||
Bot username string or None if not found
|
||||
"""
|
||||
config = load_telegram_config()
|
||||
if not config:
|
||||
return None
|
||||
|
||||
username = config.get("telegram_bot_username")
|
||||
if not username:
|
||||
return None
|
||||
|
||||
return username
|
||||
|
||||
|
||||
def get_allowed_user_ids() -> List[int]:
|
||||
"""
|
||||
Get list of allowed Telegram user IDs from config.
|
||||
|
||||
Returns:
|
||||
List of allowed user IDs. Empty list means allow all (for testing).
|
||||
"""
|
||||
config = load_telegram_config()
|
||||
if not config:
|
||||
return []
|
||||
|
||||
allowed = config.get("allowed_user_ids", [])
|
||||
if not isinstance(allowed, list):
|
||||
return []
|
||||
|
||||
return [int(uid) for uid in allowed if isinstance(uid, (int, str))]
|
||||
|
||||
|
||||
def validate_config() -> bool:
|
||||
"""
|
||||
Validate that Telegram configuration is complete.
|
||||
|
||||
Returns:
|
||||
True if config is valid, False otherwise
|
||||
"""
|
||||
config = load_telegram_config()
|
||||
if not config:
|
||||
return False
|
||||
|
||||
if not config.get("telegram_bot_token"):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================
|
||||
# MULTI-BOT CONFIGURATION (per-bot configs)
|
||||
# =============================================
|
||||
|
||||
|
||||
def load_bot_config(bot_id: str) -> dict | None:
|
||||
"""
|
||||
Load per-bot config from ~/.aipass/telegram_bots/{bot_id}.json.
|
||||
|
||||
Config format:
|
||||
{
|
||||
"bot_id": "dev_central",
|
||||
"bot_token": "123:ABC...",
|
||||
"bot_name": "AIPass Dev Central Bot",
|
||||
"branch_name": "dev_central", // null for base bot
|
||||
"work_dir": "/path/to/branch/work_dir",
|
||||
"allowed_user_ids": [7235222625]
|
||||
}
|
||||
|
||||
Args:
|
||||
bot_id: Bot identifier matching the config filename.
|
||||
|
||||
Returns:
|
||||
Config dict or None if not found/invalid.
|
||||
"""
|
||||
config_path = BOT_CONFIG_DIR / f"{bot_id}.json"
|
||||
|
||||
try:
|
||||
if not config_path.exists():
|
||||
return None
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
|
||||
return config
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def list_bot_configs() -> list[str]:
|
||||
"""
|
||||
List all bot config files (returns list of bot_ids).
|
||||
|
||||
Scans ~/.aipass/telegram_bots/ for .json files, excluding
|
||||
internal files that start with underscore (e.g., _registry.json).
|
||||
|
||||
Returns:
|
||||
List of bot_id strings derived from config filenames.
|
||||
"""
|
||||
if not BOT_CONFIG_DIR.exists():
|
||||
return []
|
||||
|
||||
bot_ids = []
|
||||
try:
|
||||
for path in sorted(BOT_CONFIG_DIR.glob("*.json")):
|
||||
# Skip internal files (e.g., _registry.json)
|
||||
if path.stem.startswith("_"):
|
||||
continue
|
||||
bot_ids.append(path.stem)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
return bot_ids
|
||||
|
||||
|
||||
def validate_bot_config(config: object) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate a bot config dict.
|
||||
|
||||
Checks for required fields and basic type correctness.
|
||||
|
||||
Args:
|
||||
config: Bot config dict to validate.
|
||||
|
||||
Returns:
|
||||
Tuple of (valid, error_message). error_message is empty on success.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return False, "Config must be a dict"
|
||||
|
||||
# Check required fields
|
||||
for field in REQUIRED_BOT_FIELDS:
|
||||
if not config.get(field):
|
||||
return False, f"Missing required field: {field}"
|
||||
|
||||
# Type checks
|
||||
bot_token = config.get("bot_token", "")
|
||||
if not isinstance(bot_token, str) or ":" not in bot_token:
|
||||
return False, "bot_token must be a string in format 'id:hash'"
|
||||
|
||||
if "work_dir" in config and config["work_dir"] is not None:
|
||||
work_dir = Path(config["work_dir"])
|
||||
if not work_dir.is_absolute():
|
||||
return False, "work_dir must be an absolute path"
|
||||
|
||||
if "allowed_user_ids" in config:
|
||||
allowed = config["allowed_user_ids"]
|
||||
if not isinstance(allowed, list):
|
||||
return False, "allowed_user_ids must be a list"
|
||||
|
||||
return True, ""
|
||||
@@ -1,220 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: file_handler.py - Telegram File Upload Handler
|
||||
# Date: 2026-02-10
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-10): Initial file handler - photo, document, file upload support (Phase 5 FPLAN-0312)
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram File Upload Handler
|
||||
|
||||
Handles file uploads from Telegram:
|
||||
- Downloads files to temp directory
|
||||
- Detects file type (text, image, pdf, binary)
|
||||
- Builds Claude prompts with file content inline or path reference
|
||||
- Cleans up temp files after processing
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
# Constants
|
||||
TEMP_DIR = Path('/tmp/telegram_uploads')
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
TEXT_CONTENT_LIMIT = 50000
|
||||
|
||||
# Text file extensions that can be read as UTF-8
|
||||
SUPPORTED_TEXT_EXTENSIONS = {
|
||||
'.py', '.js', '.ts', '.java', '.go', '.rs', '.rb', '.php', '.c', '.cpp',
|
||||
'.h', '.hpp', '.cs', '.swift', '.kt',
|
||||
'.sh', '.bash', '.zsh', '.sql', '.html', '.css', '.scss',
|
||||
'.json', '.yaml', '.yml', '.xml', '.toml', '.ini', '.cfg',
|
||||
'.md', '.txt', '.rst', '.log', '.csv', '.env', '.gitignore', '.dockerfile',
|
||||
}
|
||||
|
||||
# Image file extensions
|
||||
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'}
|
||||
|
||||
# Map file extensions to language names for code blocks
|
||||
LANGUAGE_MAP = {
|
||||
'.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.java': 'java',
|
||||
'.go': 'go', '.rs': 'rust', '.rb': 'ruby', '.php': 'php', '.c': 'c',
|
||||
'.cpp': 'cpp', '.h': 'c', '.cs': 'csharp', '.swift': 'swift', '.kt': 'kotlin',
|
||||
'.sh': 'bash', '.bash': 'bash', '.sql': 'sql', '.html': 'html', '.css': 'css',
|
||||
'.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.xml': 'xml',
|
||||
'.toml': 'toml', '.md': 'markdown',
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_filename(raw_filename: str) -> str:
|
||||
"""
|
||||
Sanitize a filename by removing path separators and dangerous characters.
|
||||
|
||||
Args:
|
||||
raw_filename: The original filename to sanitize
|
||||
|
||||
Returns:
|
||||
A safe filename string
|
||||
"""
|
||||
safe_name = Path(raw_filename).name
|
||||
safe_name = "".join(c if c.isalnum() or c in '.-_' else '_' for c in safe_name)
|
||||
return safe_name or str(uuid.uuid4())
|
||||
|
||||
|
||||
async def download_telegram_file(file_obj, filename: str | None = None) -> Path:
|
||||
"""
|
||||
Download a Telegram file to the temp directory.
|
||||
|
||||
Args:
|
||||
file_obj: Telegram File object (from get_file())
|
||||
filename: Optional original filename
|
||||
|
||||
Returns:
|
||||
Path to the downloaded file
|
||||
|
||||
Raises:
|
||||
ValueError: If file exceeds MAX_FILE_SIZE
|
||||
"""
|
||||
if file_obj.file_size and file_obj.file_size > MAX_FILE_SIZE:
|
||||
raise ValueError(
|
||||
f"File too large: {file_obj.file_size} bytes "
|
||||
f"(max {MAX_FILE_SIZE // (1024 * 1024)}MB)"
|
||||
)
|
||||
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if filename:
|
||||
safe_name = _sanitize_filename(filename)
|
||||
else:
|
||||
ext = ''
|
||||
if file_obj.file_path:
|
||||
ext = Path(file_obj.file_path).suffix
|
||||
safe_name = f"{uuid.uuid4()}{ext}"
|
||||
|
||||
dest = TEMP_DIR / safe_name
|
||||
await file_obj.download_to_drive(dest)
|
||||
print("[INFO]", "Downloaded file to %s (%s bytes)", dest, file_obj.file_size)
|
||||
return dest
|
||||
|
||||
|
||||
def detect_file_type(file_path: Path) -> str:
|
||||
"""
|
||||
Detect the type of a file based on extension and content.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
One of: 'text', 'image', 'pdf', 'binary'
|
||||
"""
|
||||
suffix = file_path.suffix.lower()
|
||||
|
||||
if suffix in SUPPORTED_TEXT_EXTENSIONS:
|
||||
return 'text'
|
||||
|
||||
if suffix in IMAGE_EXTENSIONS:
|
||||
return 'image'
|
||||
|
||||
if suffix == '.pdf':
|
||||
return 'pdf'
|
||||
|
||||
# Unknown extension - try reading as UTF-8
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
sample = f.read(1024)
|
||||
sample.decode('utf-8')
|
||||
return 'text'
|
||||
except (UnicodeDecodeError, OSError):
|
||||
return 'binary'
|
||||
|
||||
|
||||
def build_file_prompt(
|
||||
file_path: Path,
|
||||
file_type: str,
|
||||
caption: str | None = None,
|
||||
sender_name: str = 'Patrick'
|
||||
) -> str:
|
||||
"""
|
||||
Build a Claude prompt that includes file content.
|
||||
|
||||
Args:
|
||||
file_path: Path to the downloaded file
|
||||
file_type: One of 'text', 'image', 'pdf', 'binary'
|
||||
caption: Optional caption from the Telegram message
|
||||
sender_name: Name of the sender
|
||||
|
||||
Returns:
|
||||
Formatted prompt string for Claude
|
||||
"""
|
||||
FILE_NAME = file_path.name
|
||||
|
||||
if file_type == 'text':
|
||||
try:
|
||||
FILE_CONTENT = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
except OSError:
|
||||
FILE_CONTENT = '[Error reading file]'
|
||||
|
||||
if len(FILE_CONTENT) > TEXT_CONTENT_LIMIT:
|
||||
FILE_CONTENT = FILE_CONTENT[:TEXT_CONTENT_LIMIT] + '\n[...truncated]'
|
||||
|
||||
FILE_SUFFIX = file_path.suffix.lower()
|
||||
FILE_LANGUAGE = LANGUAGE_MAP.get(FILE_SUFFIX, '')
|
||||
|
||||
PROMPT_CAPTION = caption or 'Review this file'
|
||||
return (
|
||||
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
|
||||
f"File: {FILE_NAME}\n\n"
|
||||
f"```{FILE_LANGUAGE}\n{FILE_CONTENT}\n```"
|
||||
)
|
||||
|
||||
elif file_type == 'image':
|
||||
PROMPT_CAPTION = caption or 'What do you see in this image?'
|
||||
return (
|
||||
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
|
||||
f"[Image attached at: {file_path}]\n"
|
||||
f"Please use the Read tool to view the image file at the path above."
|
||||
)
|
||||
|
||||
elif file_type == 'pdf':
|
||||
PROMPT_CAPTION = caption or 'Review this document'
|
||||
return (
|
||||
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
|
||||
f"[PDF document at: {file_path}]\n"
|
||||
f"Please use the Read tool to view the PDF file at the path above."
|
||||
)
|
||||
|
||||
else: # binary
|
||||
try:
|
||||
FILE_SIZE = file_path.stat().st_size
|
||||
except OSError:
|
||||
FILE_SIZE = 0
|
||||
PROMPT_CAPTION = caption or 'I sent a file'
|
||||
return (
|
||||
f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n"
|
||||
f"[File at: {file_path}] (binary, {FILE_SIZE} bytes)\n"
|
||||
f"Note: This is a binary file that may not be directly readable."
|
||||
)
|
||||
|
||||
|
||||
def cleanup_file(file_path: Path) -> None:
|
||||
"""
|
||||
Remove a temporary file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to clean up
|
||||
"""
|
||||
file_path.unlink(missing_ok=True)
|
||||
print("[INFO]", "Cleaned up temp file: %s", file_path)
|
||||
@@ -1,263 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: log_streamer.py - Stream system logs to Telegram
|
||||
# Date: 2026-02-26
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-26): Initial - daemon thread log tailing with batched Telegram delivery
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Uses Prax get_direct_logger() — no event pipeline (FPLAN-0382 migration)
|
||||
# - Silent failure on Telegram errors (log warning, never crash)
|
||||
# - Handlers implement logic, modules orchestrate
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
LogStreamer - Stream system log lines to a Telegram chat.
|
||||
|
||||
v1.0.0
|
||||
|
||||
Runs as a background daemon thread, tailing log files for a specific branch
|
||||
and batching new lines to send via the Telegram Bot API. Tracks file positions
|
||||
to only deliver new content, handles file rotation, and discovers new log files
|
||||
each cycle.
|
||||
|
||||
Usage:
|
||||
streamer = LogStreamer(bot_token="...", chat_id=123456, branch_name="api")
|
||||
streamer.start()
|
||||
# ... later ...
|
||||
streamer.stop()
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
import threading
|
||||
from typing import Dict, List
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# Logging (Prax direct logger — FPLAN-0382, no event pipeline)
|
||||
from aipass.prax.apps.modules.logger import get_direct_logger
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file to find AIPASS_REGISTRY.json (repo root)."""
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in [current] + list(current.parents):
|
||||
if (parent / "AIPASS_REGISTRY.json").exists():
|
||||
return parent
|
||||
return Path.cwd()
|
||||
|
||||
SYSTEM_LOGS_DIR = _find_repo_root() / "logs"
|
||||
BATCH_INTERVAL = 5.0
|
||||
TELEGRAM_MAX_LENGTH = 4000
|
||||
|
||||
|
||||
# =============================================
|
||||
# LOG STREAMER
|
||||
# =============================================
|
||||
|
||||
|
||||
class LogStreamer:
|
||||
"""Stream system log lines for a branch to Telegram via batched sends."""
|
||||
|
||||
def __init__(self, bot_token: str, chat_id: int, branch_name: str) -> None:
|
||||
self.bot_token = bot_token
|
||||
self.chat_id = chat_id
|
||||
self.branch_name = branch_name
|
||||
|
||||
self._running = False
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self.log_positions: Dict[str, int] = {}
|
||||
|
||||
# Direct logger (no event pipeline — avoids recursion with log tailing)
|
||||
self.logger = get_direct_logger()
|
||||
|
||||
# Initialize positions to end of all existing log files
|
||||
self._init_positions()
|
||||
|
||||
# -----------------------------------------
|
||||
# POSITION TRACKING
|
||||
# -----------------------------------------
|
||||
|
||||
def _get_log_files(self) -> List[Path]:
|
||||
"""Find all log files matching this branch's pattern."""
|
||||
if not SYSTEM_LOGS_DIR.exists():
|
||||
return []
|
||||
return sorted(SYSTEM_LOGS_DIR.glob(f"{self.branch_name}_*.log"))
|
||||
|
||||
def _init_positions(self) -> None:
|
||||
"""Set initial positions to end of file so we only tail new lines."""
|
||||
for log_file in self._get_log_files():
|
||||
file_path = str(log_file)
|
||||
try:
|
||||
self.log_positions[file_path] = log_file.stat().st_size
|
||||
except OSError:
|
||||
self.log_positions[file_path] = 0
|
||||
self.logger.info(
|
||||
"Initialized positions for %d log files (branch: %s)",
|
||||
len(self.log_positions), self.branch_name
|
||||
)
|
||||
|
||||
def _read_new_lines(self) -> List[str]:
|
||||
"""Read new lines from all tracked log files."""
|
||||
all_new_lines: List[str] = []
|
||||
|
||||
for log_file in self._get_log_files():
|
||||
file_path = str(log_file)
|
||||
|
||||
try:
|
||||
current_size = log_file.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
last_pos = self.log_positions.get(file_path, 0)
|
||||
|
||||
try:
|
||||
# New file discovered mid-run: start from beginning
|
||||
if file_path not in self.log_positions:
|
||||
last_pos = 0
|
||||
self.logger.info("New log file discovered: %s", file_path)
|
||||
|
||||
# File rotation: size shrank, reset to beginning
|
||||
if current_size < last_pos:
|
||||
self.logger.info("File rotation detected: %s", file_path)
|
||||
last_pos = 0
|
||||
|
||||
# Read new content
|
||||
if current_size > last_pos:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
f.seek(last_pos)
|
||||
new_content = f.read()
|
||||
self.log_positions[file_path] = f.tell()
|
||||
|
||||
lines = new_content.splitlines()
|
||||
if lines:
|
||||
all_new_lines.extend(lines)
|
||||
else:
|
||||
# Update position even when nothing new (handles new file registration)
|
||||
self.log_positions[file_path] = current_size
|
||||
except OSError as e:
|
||||
self.logger.warning("Failed to process %s: %s", file_path, e)
|
||||
continue
|
||||
|
||||
return all_new_lines
|
||||
|
||||
# -----------------------------------------
|
||||
# TELEGRAM DELIVERY
|
||||
# -----------------------------------------
|
||||
|
||||
def _send_message(self, message: str) -> bool:
|
||||
"""Send a message to Telegram. Returns True on success."""
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
payload = json.dumps({
|
||||
"chat_id": self.chat_id,
|
||||
"text": message,
|
||||
"disable_notification": True
|
||||
}).encode("utf-8")
|
||||
req = Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result.get("ok", False)
|
||||
except (URLError, Exception) as e:
|
||||
self.logger.warning("Telegram send failed: %s", e)
|
||||
return False
|
||||
|
||||
def _send_batched(self, lines: List[str]) -> None:
|
||||
"""Split lines into messages respecting TELEGRAM_MAX_LENGTH, send each."""
|
||||
if not lines:
|
||||
return
|
||||
|
||||
batch: List[str] = []
|
||||
batch_len = 0
|
||||
|
||||
for line in lines:
|
||||
# +1 for the newline separator between lines
|
||||
line_len = len(line) + (1 if batch else 0)
|
||||
|
||||
if batch_len + line_len > TELEGRAM_MAX_LENGTH and batch:
|
||||
# Send current batch
|
||||
message = "\n".join(batch)
|
||||
self._send_message(message)
|
||||
batch = []
|
||||
batch_len = 0
|
||||
|
||||
batch.append(line)
|
||||
batch_len += line_len
|
||||
|
||||
# Send remaining
|
||||
if batch:
|
||||
message = "\n".join(batch)
|
||||
self._send_message(message)
|
||||
|
||||
# -----------------------------------------
|
||||
# DAEMON THREAD
|
||||
# -----------------------------------------
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Main loop: read new lines, batch, send, sleep."""
|
||||
self.logger.info("Log streamer started for branch: %s", self.branch_name)
|
||||
self.logger.info("Watching: %s/%s_*.log (chat_id=%s)", SYSTEM_LOGS_DIR, self.branch_name, self.chat_id)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
new_lines = self._read_new_lines()
|
||||
if new_lines:
|
||||
self.logger.info("Found %d new log lines, sending to Telegram", len(new_lines))
|
||||
self._send_batched(new_lines)
|
||||
except Exception as e:
|
||||
self.logger.warning("Streamer cycle error: %s", e)
|
||||
|
||||
# Interruptible sleep
|
||||
self._stop_event.wait(BATCH_INTERVAL)
|
||||
|
||||
self.logger.info("Log streamer stopped for branch: %s", self.branch_name)
|
||||
|
||||
# -----------------------------------------
|
||||
# PUBLIC API
|
||||
# -----------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the log streamer daemon thread."""
|
||||
if self._running:
|
||||
self.logger.warning("Log streamer already running")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name=f"log-streamer-{self.branch_name}",
|
||||
daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self.logger.info("Daemon thread started: %s", self._thread.name)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the log streamer and wait for thread to finish."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
self._stop_event.set()
|
||||
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=BATCH_INTERVAL + 2)
|
||||
if self._thread.is_alive():
|
||||
self.logger.warning("Daemon thread did not exit cleanly")
|
||||
self._thread = None
|
||||
|
||||
self.logger.info("Log streamer stopped")
|
||||
@@ -1,122 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: notifier.py - Telegram Push Notifications
|
||||
# Date: 2026-02-17
|
||||
# Version: 1.1.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.1.0 (2026-02-18): DPLAN-005 - Add silent mode, CLI interface for cross-branch use, markdown support
|
||||
# - v1.0.0 (2026-02-17): Initial implementation - reusable Telegram notification sender
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Handlers implement logic, modules orchestrate
|
||||
# - No cross-branch imports, no Prax logger
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram notification sender for the scheduler bot.
|
||||
|
||||
Can be used two ways:
|
||||
1. Import (within API branch): send_telegram_notification("message")
|
||||
2. CLI (cross-branch, no import guard): python3 notifier.py "message"
|
||||
Flags: --silent (silent push), --markdown (Markdown parse mode)
|
||||
|
||||
Reads bot token and chat_id from ~/.aipass/scheduler_config.json.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
def _aipass_data_dir() -> Path:
|
||||
"""User data directory for AIPass runtime files."""
|
||||
env = os.environ.get("AIPASS_DATA_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
|
||||
|
||||
CONFIG_PATH = _aipass_data_dir() / "scheduler_config.json"
|
||||
|
||||
# =============================================
|
||||
# PUBLIC API
|
||||
# =============================================
|
||||
|
||||
|
||||
def send_telegram_notification(
|
||||
message: str,
|
||||
silent: bool = False,
|
||||
parse_mode: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send a message to Telegram via the scheduler bot.
|
||||
|
||||
Args:
|
||||
message: Text to send (plain text or Markdown)
|
||||
silent: If True, send as silent notification (no sound on phone)
|
||||
parse_mode: Telegram parse mode ("Markdown" or "HTML"). None for plain text.
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
bot_token = config["telegram_bot_token"]
|
||||
chat_id = config["telegram_chat_id"]
|
||||
except (FileNotFoundError, KeyError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload_dict: dict[str, object] = {"chat_id": chat_id, "text": message}
|
||||
if silent:
|
||||
payload_dict["disable_notification"] = True
|
||||
if parse_mode:
|
||||
payload_dict["parse_mode"] = parse_mode
|
||||
|
||||
data = json.dumps(payload_dict).encode("utf-8")
|
||||
req = Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result.get("ok", False)
|
||||
except (URLError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# CLI INTERFACE (cross-branch use)
|
||||
# =============================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
if not args or args[0] in ("-h", "--help"):
|
||||
print("Usage: python3 notifier.py [--silent] [--markdown] \"message\"")
|
||||
print(" --silent Send as silent notification (no sound)")
|
||||
print(" --markdown Use Telegram Markdown parse mode")
|
||||
sys.exit(0)
|
||||
|
||||
silent_flag = "--silent" in args
|
||||
markdown_flag = "--markdown" in args
|
||||
msg_args = [a for a in args if not a.startswith("--")]
|
||||
|
||||
if not msg_args:
|
||||
print("Error: no message provided", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
msg = " ".join(msg_args)
|
||||
mode = "Markdown" if markdown_flag else None
|
||||
ok = send_telegram_notification(msg, silent=silent_flag, parse_mode=mode)
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: output_parser.py - Claude Stream JSON Output Parser
|
||||
# Date: 2026-02-10
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-10): Initial - parse stream-json from Claude CLI
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Claude Stream JSON Output Parser
|
||||
|
||||
Parses newline-delimited JSON output from Claude CLI when invoked with
|
||||
--output-format stream-json --verbose.
|
||||
|
||||
Output format (3 line types):
|
||||
1. system/init - session_id, model, tools
|
||||
2. assistant - message content blocks (text, tool_use)
|
||||
3. result - success/error, session_id, cost, duration
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# Infrastructure — disabled file, no sys.path manipulation needed
|
||||
|
||||
logger = logging.getLogger("telegram_output_parser")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedResult:
|
||||
"""Structured result from parsing Claude stream-json output."""
|
||||
success: bool
|
||||
text: str
|
||||
session_id: Optional[str] = None
|
||||
cost_usd: Optional[float] = None
|
||||
duration_ms: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class OutputParser:
|
||||
"""Parse Claude CLI stream-json output into structured results."""
|
||||
|
||||
@staticmethod
|
||||
def parse_stream(raw_output: str) -> ParsedResult:
|
||||
"""
|
||||
Parse raw stream-json output from Claude CLI.
|
||||
|
||||
Splits output by newlines, parses each line as JSON, and extracts:
|
||||
- Text from assistant message content blocks (type='text' only)
|
||||
- session_id from result line
|
||||
- cost from result line total_cost_usd
|
||||
- Error detection from result line is_error / subtype='error'
|
||||
|
||||
Falls back to returning raw text if JSON parsing fails entirely.
|
||||
|
||||
Args:
|
||||
raw_output: Raw stdout from Claude CLI with --output-format stream-json
|
||||
|
||||
Returns:
|
||||
ParsedResult with extracted fields
|
||||
"""
|
||||
if not raw_output or not raw_output.strip():
|
||||
return ParsedResult(success=False, text="", error_message="Empty output")
|
||||
|
||||
text_parts: List[str] = []
|
||||
session_id: Optional[str] = None
|
||||
cost_usd: Optional[float] = None
|
||||
duration_ms: Optional[int] = None
|
||||
is_error = False
|
||||
error_message: Optional[str] = None
|
||||
parsed_any = False
|
||||
|
||||
for line in raw_output.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.info("Skipping non-JSON line: %s", line[:100])
|
||||
continue
|
||||
|
||||
parsed_any = True
|
||||
line_type = data.get("type")
|
||||
|
||||
if line_type == "assistant":
|
||||
# Extract text from content blocks
|
||||
message = data.get("message", {})
|
||||
content_blocks = message.get("content", [])
|
||||
for block in content_blocks:
|
||||
if block.get("type") == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
|
||||
elif line_type == "result":
|
||||
# Extract session_id, cost, duration, error status
|
||||
session_id = data.get("session_id", session_id)
|
||||
cost_usd = data.get("total_cost_usd", cost_usd)
|
||||
duration_ms = data.get("duration_ms", duration_ms)
|
||||
|
||||
if data.get("is_error", False) or data.get("subtype") == "error":
|
||||
is_error = True
|
||||
error_message = data.get("result", "Unknown error")
|
||||
|
||||
elif line_type == "system":
|
||||
# Init line - capture session_id as fallback
|
||||
if not session_id:
|
||||
session_id = data.get("session_id")
|
||||
|
||||
# If we couldn't parse any JSON at all, fall back to raw text
|
||||
if not parsed_any:
|
||||
logger.warning("No valid JSON lines found, returning raw output")
|
||||
return ParsedResult(success=True, text=raw_output.strip())
|
||||
|
||||
combined_text = "\n".join(text_parts) if text_parts else ""
|
||||
|
||||
if is_error:
|
||||
return ParsedResult(
|
||||
success=False,
|
||||
text=combined_text,
|
||||
session_id=session_id,
|
||||
cost_usd=cost_usd,
|
||||
duration_ms=duration_ms,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
if not combined_text:
|
||||
return ParsedResult(
|
||||
success=False,
|
||||
text="",
|
||||
session_id=session_id,
|
||||
cost_usd=cost_usd,
|
||||
duration_ms=duration_ms,
|
||||
error_message="No text content in response",
|
||||
)
|
||||
|
||||
return ParsedResult(
|
||||
success=True,
|
||||
text=combined_text,
|
||||
session_id=session_id,
|
||||
cost_usd=cost_usd,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
@@ -1,345 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: response_router.py - CWD-safe response routing for multi-bot architecture
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - CWD-safe pending file matching with directory tree resolution
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error handling (graceful - never raise)
|
||||
# - Uses Prax system_logger (FPLAN-0382 migration)
|
||||
# - Stdlib only (no external deps)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
CWD-Safe Response Routing for Multi-Bot Architecture
|
||||
|
||||
Fixes the CWD mismatch bug in the Stop hook. When Claude fires the Stop hook,
|
||||
the working directory may be a subdirectory of the branch root (e.g.,
|
||||
<branch_root>/git_repo/ instead of <branch_root>/). The old logic used
|
||||
Path.cwd().name which fails in subdirectories.
|
||||
|
||||
New logic uses cwd.relative_to(work_dir) which succeeds if CWD is ANYWHERE
|
||||
in the bot's directory tree.
|
||||
|
||||
Pending file naming:
|
||||
- v2 (new): bot-{bot_id}.json
|
||||
- v1 (legacy): telegram-{branch_name}.json
|
||||
Both formats are supported during the transition period.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Standard library
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
# Logging (Prax system_logger — FPLAN-0382)
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
def _aipass_data_dir() -> Path:
|
||||
"""User data directory for AIPass runtime files."""
|
||||
env = os.environ.get("AIPASS_DATA_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "aipass"
|
||||
|
||||
_DATA_DIR = _aipass_data_dir()
|
||||
PENDING_DIR = _DATA_DIR / "telegram_pending"
|
||||
PENDING_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
# =============================================
|
||||
# DIRECTORY TREE MATCHING
|
||||
# =============================================
|
||||
|
||||
|
||||
def is_cwd_in_tree(cwd: Path, work_dir) -> bool:
|
||||
"""
|
||||
Check if cwd is within work_dir's directory tree using relative_to().
|
||||
|
||||
This is the core fix for the CWD mismatch bug. Instead of comparing
|
||||
directory names (which fails in subdirectories), we check if cwd is
|
||||
a child of work_dir at any depth.
|
||||
|
||||
Args:
|
||||
cwd: Current working directory to check.
|
||||
work_dir: Bot's configured working directory (str or Path).
|
||||
|
||||
Returns:
|
||||
True if cwd is within work_dir's tree, False otherwise.
|
||||
"""
|
||||
try:
|
||||
cwd.relative_to(Path(work_dir))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# TMUX SESSION CHECKING
|
||||
# =============================================
|
||||
|
||||
|
||||
def is_tmux_alive(session_name: str) -> bool:
|
||||
"""
|
||||
Check if a tmux session exists.
|
||||
|
||||
Args:
|
||||
session_name: Name of the tmux session to check.
|
||||
|
||||
Returns:
|
||||
True if the session exists, False otherwise.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "has-session", "-t", session_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# PENDING FILE EXPIRY
|
||||
# =============================================
|
||||
|
||||
|
||||
def is_pending_expired(pending_data: dict) -> bool:
|
||||
"""
|
||||
Check if a pending file is expired.
|
||||
|
||||
A pending file is considered expired only when BOTH conditions are met:
|
||||
1. The timestamp is older than PENDING_TTL seconds
|
||||
2. The associated tmux session is no longer alive
|
||||
|
||||
This prevents premature cleanup of pending files for long-running sessions.
|
||||
|
||||
Args:
|
||||
pending_data: Parsed contents of a pending file.
|
||||
|
||||
Returns:
|
||||
True if the pending file should be cleaned up, False otherwise.
|
||||
"""
|
||||
# Condition 1: Check TTL
|
||||
timestamp = pending_data.get("timestamp", 0)
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
timestamp = float(timestamp)
|
||||
except ValueError:
|
||||
timestamp = 0
|
||||
|
||||
if time.time() - timestamp <= PENDING_TTL:
|
||||
return False # Still within TTL, not expired
|
||||
|
||||
# Condition 2: Check tmux session
|
||||
# Derive session name from bot_id or branch_name
|
||||
bot_id = pending_data.get("bot_id", "")
|
||||
branch_name = pending_data.get("branch_name", "")
|
||||
|
||||
# Try the bot_id-based tmux session name first (v2)
|
||||
if bot_id:
|
||||
if is_tmux_alive(f"telegram-{bot_id}"):
|
||||
return False # Session alive, not expired
|
||||
|
||||
# Try the branch-based tmux session name (v1)
|
||||
if branch_name and branch_name != bot_id:
|
||||
if is_tmux_alive(f"telegram-{branch_name}"):
|
||||
return False # Session alive, not expired
|
||||
|
||||
# Past TTL AND no tmux session alive
|
||||
return True
|
||||
|
||||
|
||||
# =============================================
|
||||
# PENDING FILE LOADING
|
||||
# =============================================
|
||||
|
||||
|
||||
def _load_pending_file(pending_path: Path) -> Optional[dict]:
|
||||
"""
|
||||
Load and parse a pending file from disk.
|
||||
|
||||
Args:
|
||||
pending_path: Path to the pending JSON file.
|
||||
|
||||
Returns:
|
||||
Parsed dict with "pending_path" key added, or None on error.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(pending_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
data["pending_path"] = str(pending_path)
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN ROUTING LOGIC
|
||||
# =============================================
|
||||
|
||||
|
||||
def find_pending_bot(
|
||||
cwd: Optional[Path] = None,
|
||||
session_id: Optional[str] = None,
|
||||
env_bot_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Find which bot's pending file matches the current context.
|
||||
|
||||
Uses a priority-based matching strategy:
|
||||
|
||||
Priority 1: AIPASS_BOT_ID env var (set in tmux session by BaseBot)
|
||||
Direct match: look for bot-{env_bot_id}.json
|
||||
|
||||
Priority 2: cwd.relative_to(work_dir) - CWD anywhere in bot's directory tree
|
||||
Load each pending file, check if cwd is within its work_dir
|
||||
|
||||
Priority 3: session_id match - fallback for legacy compatibility
|
||||
Check session_id field in each pending file
|
||||
|
||||
Args:
|
||||
cwd: Current working directory. Defaults to Path.cwd().
|
||||
session_id: Claude Code session ID for fallback matching.
|
||||
env_bot_id: Bot ID from environment. Defaults to AIPASS_BOT_ID env var.
|
||||
|
||||
Returns:
|
||||
Pending file data dict with "pending_path" key, or None if no match.
|
||||
"""
|
||||
if not PENDING_DIR.exists():
|
||||
return None
|
||||
|
||||
if cwd is None:
|
||||
try:
|
||||
cwd = Path.cwd()
|
||||
except OSError:
|
||||
cwd = Path.cwd()
|
||||
|
||||
if env_bot_id is None:
|
||||
env_bot_id = os.environ.get("AIPASS_BOT_ID")
|
||||
|
||||
# Priority 1: Direct match via AIPASS_BOT_ID env var
|
||||
if env_bot_id:
|
||||
# v2 naming: bot-{bot_id}.json
|
||||
PENDING_V2 = PENDING_DIR / f"bot-{env_bot_id}.json"
|
||||
if PENDING_V2.exists():
|
||||
data = _load_pending_file(PENDING_V2)
|
||||
if data and not is_pending_expired(data):
|
||||
logger.info("Matched pending by AIPASS_BOT_ID: %s", env_bot_id)
|
||||
return data
|
||||
|
||||
# Also check v1 naming for this bot_id
|
||||
PENDING_V1 = PENDING_DIR / f"telegram-{env_bot_id}.json"
|
||||
if PENDING_V1.exists():
|
||||
data = _load_pending_file(PENDING_V1)
|
||||
if data and not is_pending_expired(data):
|
||||
logger.info("Matched pending by AIPASS_BOT_ID (v1 naming): %s", env_bot_id)
|
||||
return data
|
||||
|
||||
# Priority 2: CWD directory tree matching
|
||||
# Check all pending files and see if CWD is within any bot's work_dir
|
||||
ALL_PENDING = list(PENDING_DIR.glob("bot-*.json")) + list(PENDING_DIR.glob("telegram-*.json"))
|
||||
|
||||
for pending_path in ALL_PENDING:
|
||||
data = _load_pending_file(pending_path)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
if is_pending_expired(data):
|
||||
continue
|
||||
|
||||
work_dir = data.get("work_dir", "")
|
||||
if work_dir and is_cwd_in_tree(cwd, work_dir):
|
||||
logger.info("Matched pending by CWD tree: cwd=%s within work_dir=%s", cwd, work_dir)
|
||||
return data
|
||||
|
||||
# Legacy v1 files may not have work_dir - try branch_name directory matching
|
||||
branch_name = data.get("branch_name", "")
|
||||
if branch_name and not work_dir:
|
||||
# CWD's directory name or any parent matches branch_name
|
||||
path_cursor = cwd
|
||||
while path_cursor != path_cursor.parent:
|
||||
if path_cursor.name == branch_name:
|
||||
logger.info("Matched pending by branch name in CWD path: %s", branch_name)
|
||||
return data
|
||||
path_cursor = path_cursor.parent
|
||||
|
||||
# Priority 3: Session ID fallback
|
||||
if session_id:
|
||||
for pending_path in ALL_PENDING:
|
||||
data = _load_pending_file(pending_path)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
if is_pending_expired(data):
|
||||
continue
|
||||
|
||||
if data.get("session_id") == session_id:
|
||||
logger.info("Matched pending by session_id: %s", session_id[:8])
|
||||
return data
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# CLEANUP
|
||||
# =============================================
|
||||
|
||||
|
||||
def clean_expired_pending() -> int:
|
||||
"""
|
||||
Remove all expired pending files from the pending directory.
|
||||
|
||||
A file is expired when it is past TTL AND its tmux session is dead.
|
||||
|
||||
Returns:
|
||||
Number of expired files removed.
|
||||
"""
|
||||
if not PENDING_DIR.exists():
|
||||
return 0
|
||||
|
||||
REMOVED_COUNT = 0
|
||||
ALL_PENDING = list(PENDING_DIR.glob("bot-*.json")) + list(PENDING_DIR.glob("telegram-*.json"))
|
||||
|
||||
for pending_path in ALL_PENDING:
|
||||
data = _load_pending_file(pending_path)
|
||||
if not data:
|
||||
# Corrupt or unreadable file - remove it
|
||||
try:
|
||||
pending_path.unlink(missing_ok=True)
|
||||
REMOVED_COUNT += 1
|
||||
logger.info("Removed corrupt pending file: %s", pending_path.name)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove corrupt pending file %s: %s", pending_path.name, e)
|
||||
continue
|
||||
|
||||
if is_pending_expired(data):
|
||||
try:
|
||||
pending_path.unlink(missing_ok=True)
|
||||
REMOVED_COUNT += 1
|
||||
logger.info("Removed expired pending file: %s", pending_path.name)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove expired pending file %s: %s", pending_path.name, e)
|
||||
|
||||
if REMOVED_COUNT > 0:
|
||||
logger.info("Cleaned %d expired pending file(s)", REMOVED_COUNT)
|
||||
|
||||
return REMOVED_COUNT
|
||||
@@ -1,573 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: spawner.py - Claude Session Spawner
|
||||
# Date: 2026-02-03
|
||||
# Version: 4.3.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v4.3.0 (2026-02-12): Branch targeting - @branch prefix routes Claude to branch CWD
|
||||
# - v4.2.0 (2026-02-10): Add raw_prompt flag to run_claude_capture (Phase 5 FPLAN-0312)
|
||||
# - v4.1.0 (2026-02-10): Session persistence - use stored session_id for resume, deterministic as fallback
|
||||
# - v4.0.0 (2026-02-10): Stream JSON output parsing - structured results with session_id, cost
|
||||
# - v3.2.0 (2026-02-10): Security fix - subprocess_exec for capture mode (no shell injection)
|
||||
# - v3.1.0 (2026-02-09): Fix session ID collision - random UUID for new sessions, deterministic for resume only
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Claude Session Spawner
|
||||
|
||||
Two modes of operation:
|
||||
1. Visual mode: Spawn visible sessions (gnome-terminal/tmux) - fire and forget
|
||||
2. Capture mode: Run Claude directly and capture stdout for response
|
||||
|
||||
Supports branch targeting: messages starting with @branch_name spawn Claude
|
||||
in that branch's directory.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import subprocess
|
||||
import shutil
|
||||
import asyncio
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List
|
||||
|
||||
# Infrastructure — disabled file, kept for reference only
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger("telegram_spawner")
|
||||
|
||||
# NOTE: These imports reference old non-aipass namespace and won't work.
|
||||
# from api.apps.handlers.telegram.output_parser import OutputParser, ParsedResult
|
||||
# from api.apps.handlers.telegram.session_store import get_session, save_session
|
||||
|
||||
# Constants — package-relative
|
||||
DEFAULT_SESSION_PATH = Path.cwd()
|
||||
BRANCH_REGISTRY_PATH = Path.cwd() / "AIPASS_REGISTRY.json"
|
||||
CLAUDE_BIN = shutil.which("claude") or "claude"
|
||||
TMUX_SESSION_NAME = "telegram-claude"
|
||||
TELEGRAM_CHAR_LIMIT = 4096
|
||||
DEFAULT_TIMEOUT = 120 # seconds
|
||||
|
||||
# Backwards compatibility
|
||||
SESSION_PATH = DEFAULT_SESSION_PATH
|
||||
|
||||
|
||||
def resolve_branch_target(message: str) -> Tuple[str, Path]:
|
||||
"""
|
||||
Extract @branch target from message and resolve to a directory path.
|
||||
|
||||
If message starts with @branch_name, look up the branch in BRANCH_REGISTRY.json
|
||||
and return the cleaned message (without the @branch prefix) and the branch path.
|
||||
If no @branch prefix or branch not found, returns original message and DEFAULT_SESSION_PATH.
|
||||
|
||||
Returns:
|
||||
Tuple of (cleaned_message, target_path)
|
||||
"""
|
||||
match = re.match(r'^@(\w+)\s*(.*)', message, re.DOTALL)
|
||||
if not match:
|
||||
return message, DEFAULT_SESSION_PATH
|
||||
|
||||
branch_name = match.group(1).lower()
|
||||
rest_of_message = match.group(2).strip()
|
||||
|
||||
try:
|
||||
with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f:
|
||||
registry = json.load(f)
|
||||
|
||||
branches = registry.get("branches", [])
|
||||
for branch_entry in branches:
|
||||
# Match against email (@branch_name) or name field
|
||||
clean_email = branch_entry.get("email", "").replace("@", "").lower()
|
||||
if clean_email == branch_name:
|
||||
branch_path = Path(branch_entry.get("path", ""))
|
||||
if branch_path.is_dir():
|
||||
logger.info("Branch target resolved: @%s -> %s", branch_name, branch_path)
|
||||
return rest_of_message or "hi", branch_path
|
||||
else:
|
||||
logger.warning("Branch path not found on disk: %s", branch_path)
|
||||
return message, DEFAULT_SESSION_PATH
|
||||
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning("Failed to resolve branch @%s: %s", branch_name, e)
|
||||
|
||||
return message, DEFAULT_SESSION_PATH
|
||||
|
||||
|
||||
def has_display() -> bool:
|
||||
"""Check if DISPLAY environment variable is set (GUI available)."""
|
||||
return bool(os.environ.get("DISPLAY"))
|
||||
|
||||
|
||||
def has_gnome_terminal() -> bool:
|
||||
"""Check if gnome-terminal is available."""
|
||||
return shutil.which("gnome-terminal") is not None
|
||||
|
||||
|
||||
def has_tmux() -> bool:
|
||||
"""Check if tmux is available."""
|
||||
return shutil.which("tmux") is not None
|
||||
|
||||
|
||||
def build_prompt(message: str) -> str:
|
||||
"""
|
||||
Build the Claude prompt from Telegram message (shell-escaped).
|
||||
|
||||
Used by visual mode (gnome-terminal, tmux) which still requires shell escaping.
|
||||
|
||||
Args:
|
||||
message: The message text
|
||||
|
||||
Returns:
|
||||
Formatted prompt string with shell-safe escaping
|
||||
"""
|
||||
# Escape single quotes for shell safety
|
||||
safe_message = message.replace("'", "'\"'\"'")
|
||||
return f"Patrick via Telegram: {safe_message}"
|
||||
|
||||
|
||||
def build_prompt_clean(message: str) -> str:
|
||||
"""
|
||||
Build the Claude prompt from Telegram message (no shell escaping).
|
||||
|
||||
Used by capture mode where subprocess_exec passes args directly,
|
||||
bypassing the shell entirely.
|
||||
|
||||
Args:
|
||||
message: The message text
|
||||
|
||||
Returns:
|
||||
Formatted prompt string (raw, no escaping needed)
|
||||
"""
|
||||
return f"Patrick via Telegram: {message}"
|
||||
|
||||
|
||||
def spawn_gnome_terminal(prompt: str) -> Tuple[bool, str, Optional[int]]:
|
||||
"""
|
||||
Spawn Claude in a visible gnome-terminal window.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to pass to Claude
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message, pid or None)
|
||||
"""
|
||||
try:
|
||||
# Escape prompt for shell
|
||||
safe_prompt = prompt.replace("'", "'\"'\"'")
|
||||
|
||||
# Build command: gnome-terminal spawns, runs claude, stays open
|
||||
cmd = [
|
||||
"gnome-terminal",
|
||||
"--",
|
||||
"bash", "-c",
|
||||
f"cd {SESSION_PATH} && claude -p '{safe_prompt}' --session-id {uuid.uuid4()} --permission-mode bypassPermissions; exec bash"
|
||||
]
|
||||
|
||||
logger.info("Spawning gnome-terminal at %s", SESSION_PATH)
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
return True, f"gnome-terminal spawned (PID: {process.pid})", process.pid
|
||||
|
||||
except Exception as e:
|
||||
logger.error("gnome-terminal spawn failed: %s", e)
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def spawn_tmux(prompt: str) -> Tuple[bool, str, Optional[int]]:
|
||||
"""
|
||||
Spawn Claude in a tmux session (headless fallback).
|
||||
|
||||
Creates or attaches to session named 'telegram-claude'.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to pass to Claude
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message, pid or None)
|
||||
"""
|
||||
try:
|
||||
# Escape prompt for shell
|
||||
safe_prompt = prompt.replace("'", "'\"'\"'")
|
||||
|
||||
# Check if session already exists
|
||||
check_cmd = ["tmux", "has-session", "-t", TMUX_SESSION_NAME]
|
||||
session_exists = subprocess.run(
|
||||
check_cmd,
|
||||
capture_output=True
|
||||
).returncode == 0
|
||||
|
||||
if session_exists:
|
||||
# Kill existing session to start fresh
|
||||
subprocess.run(
|
||||
["tmux", "kill-session", "-t", TMUX_SESSION_NAME],
|
||||
capture_output=True
|
||||
)
|
||||
logger.info("Killed existing tmux session: %s", TMUX_SESSION_NAME)
|
||||
|
||||
# Create new session with Claude
|
||||
cmd = [
|
||||
"tmux", "new-session",
|
||||
"-d", # Detached
|
||||
"-s", TMUX_SESSION_NAME,
|
||||
"-c", str(SESSION_PATH), # Working directory
|
||||
f"claude -p '{safe_prompt}' --session-id {uuid.uuid4()} --permission-mode bypassPermissions"
|
||||
]
|
||||
|
||||
logger.info("Spawning tmux session '%s' at %s", TMUX_SESSION_NAME, SESSION_PATH)
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
msg = f"tmux session '{TMUX_SESSION_NAME}' created. Attach with: tmux attach -t {TMUX_SESSION_NAME}"
|
||||
return True, msg, None # tmux doesn't give us the claude PID directly
|
||||
else:
|
||||
return False, f"tmux exited with code {process.returncode}", None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("tmux spawn failed: %s", e)
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def spawn_claude_session(sender_name: str, message: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Spawn a visible Claude session for a Telegram message.
|
||||
|
||||
Strategy:
|
||||
1. If DISPLAY exists and gnome-terminal available → use gnome-terminal
|
||||
2. Otherwise if tmux available → use tmux
|
||||
3. If neither available → return error
|
||||
|
||||
Args:
|
||||
sender_name: Telegram sender name/username
|
||||
message: The message text from Telegram
|
||||
|
||||
Returns:
|
||||
Tuple of (success, status_message)
|
||||
"""
|
||||
_ = sender_name # Reserved for future use (e.g., multi-user filtering)
|
||||
prompt = build_prompt(message)
|
||||
|
||||
# Strategy 1: gnome-terminal (GUI)
|
||||
if has_display() and has_gnome_terminal():
|
||||
logger.info("Using gnome-terminal (DISPLAY available)")
|
||||
success, msg, _ = spawn_gnome_terminal(prompt)
|
||||
return success, msg
|
||||
|
||||
# Strategy 2: tmux (headless)
|
||||
if has_tmux():
|
||||
logger.info("Using tmux (headless fallback)")
|
||||
success, msg, _ = spawn_tmux(prompt)
|
||||
return success, msg
|
||||
|
||||
# No spawner available
|
||||
error_msg = "No spawner available. Install gnome-terminal (GUI) or tmux (headless)."
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
|
||||
# =============================================
|
||||
# RESPONSE CHUNKING
|
||||
# =============================================
|
||||
|
||||
def chunk_response(text: str, limit: int = TELEGRAM_CHAR_LIMIT) -> List[str]:
|
||||
"""
|
||||
Split text into chunks for Telegram's message limit.
|
||||
|
||||
Attempts to split at sentence boundaries when possible.
|
||||
|
||||
Args:
|
||||
text: The full response text
|
||||
limit: Maximum characters per chunk (default 4096)
|
||||
|
||||
Returns:
|
||||
List of text chunks, each within the limit
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
|
||||
chunks: List[str] = []
|
||||
remaining = text
|
||||
|
||||
while remaining:
|
||||
if len(remaining) <= limit:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
|
||||
# Try to find a sentence boundary within limit
|
||||
chunk = remaining[:limit]
|
||||
|
||||
# Look for sentence endings (. ! ?) followed by space or newline
|
||||
best_break = -1
|
||||
for i in range(len(chunk) - 1, max(0, len(chunk) - 500), -1):
|
||||
if chunk[i] in '.!?' and (i + 1 >= len(chunk) or chunk[i + 1] in ' \n'):
|
||||
best_break = i + 1
|
||||
break
|
||||
|
||||
# If no sentence boundary, try paragraph break
|
||||
if best_break == -1:
|
||||
newline_pos = chunk.rfind('\n\n')
|
||||
if newline_pos > limit // 2:
|
||||
best_break = newline_pos + 2
|
||||
|
||||
# If still no good break, try single newline
|
||||
if best_break == -1:
|
||||
newline_pos = chunk.rfind('\n')
|
||||
if newline_pos > limit // 2:
|
||||
best_break = newline_pos + 1
|
||||
|
||||
# Last resort: break at space
|
||||
if best_break == -1:
|
||||
space_pos = chunk.rfind(' ')
|
||||
if space_pos > limit // 2:
|
||||
best_break = space_pos + 1
|
||||
|
||||
# Ultimate fallback: hard break at limit
|
||||
if best_break == -1:
|
||||
best_break = limit
|
||||
|
||||
chunks.append(remaining[:best_break].rstrip())
|
||||
remaining = remaining[best_break:].lstrip()
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
# =============================================
|
||||
# CAPTURE MODE - Run Claude and capture output
|
||||
# =============================================
|
||||
|
||||
def _chat_id_to_uuid(chat_id: int) -> str:
|
||||
"""Generate a deterministic UUID from a Telegram chat ID."""
|
||||
namespace = uuid.UUID("a1a55000-0000-4000-8000-000000000000")
|
||||
return str(uuid.uuid5(namespace, str(chat_id)))
|
||||
|
||||
|
||||
async def _run_claude_cmd(
|
||||
args: List[str],
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
target_cwd: Optional[Path] = None
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Execute a Claude CLI command and capture output.
|
||||
|
||||
Uses subprocess_exec (no shell) to prevent shell injection attacks.
|
||||
Parses stream-json output via OutputParser.
|
||||
|
||||
Args:
|
||||
args: Command arguments as a list (e.g. [CLAUDE_BIN, '-p', prompt, ...])
|
||||
timeout: Maximum seconds to wait for response
|
||||
target_cwd: Working directory for the Claude process (defaults to DEFAULT_SESSION_PATH)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_text or error_message, session_id or None)
|
||||
"""
|
||||
session_cwd = target_cwd or DEFAULT_SESSION_PATH
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=session_cwd
|
||||
)
|
||||
|
||||
try:
|
||||
raw_stdout, raw_stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return False, f"Claude timed out after {timeout} seconds", None
|
||||
|
||||
if process.returncode != 0:
|
||||
error_text = raw_stderr.decode('utf-8', errors='replace').strip()
|
||||
return False, f"Claude error: {error_text or 'Unknown error'}", None
|
||||
|
||||
raw_output = raw_stdout.decode('utf-8', errors='replace').strip()
|
||||
|
||||
if not raw_output:
|
||||
return False, "Claude returned empty response", None
|
||||
|
||||
# Parse stream-json output
|
||||
result = OutputParser.parse_stream(raw_output)
|
||||
|
||||
if result.success:
|
||||
logger.info(
|
||||
"Parsed response: %d chars, session=%s, cost=$%.4f",
|
||||
len(result.text),
|
||||
result.session_id or "unknown",
|
||||
result.cost_usd or 0.0,
|
||||
)
|
||||
return True, result.text, result.session_id
|
||||
else:
|
||||
error = result.error_message or "Parse failed"
|
||||
logger.warning("Parse error: %s", error)
|
||||
return False, error, result.session_id
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Capture failed: {str(e)}", None
|
||||
|
||||
|
||||
async def run_claude_capture(
|
||||
message: str,
|
||||
chat_id: int = 0,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
raw_prompt: bool = False,
|
||||
target_cwd: Optional[Path] = None
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Run Claude CLI and capture its response with session persistence.
|
||||
|
||||
Strategy (when chat_id provided):
|
||||
1. Look up stored session_id → try --resume with it
|
||||
2. If no stored or resume fails → create new with random UUID
|
||||
3. If random create fails → fall back to deterministic UUID
|
||||
|
||||
Args:
|
||||
message: The message/prompt to send to Claude
|
||||
chat_id: Telegram chat ID for session persistence
|
||||
timeout: Maximum seconds to wait for response
|
||||
raw_prompt: If True, use message as-is (skip build_prompt_clean)
|
||||
target_cwd: Working directory for the Claude process (branch targeting)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_text or error_message, session_id or None)
|
||||
"""
|
||||
if raw_prompt:
|
||||
prompt = message
|
||||
else:
|
||||
prompt = build_prompt_clean(message)
|
||||
|
||||
if chat_id:
|
||||
# Strategy 1: Try stored session_id from session_store
|
||||
stored = get_session(chat_id)
|
||||
if stored and stored.get("session_id"):
|
||||
stored_id = stored["session_id"]
|
||||
resume_args = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
'--resume', stored_id,
|
||||
'--output-format', 'stream-json', '--verbose',
|
||||
'--permission-mode', 'bypassPermissions'
|
||||
]
|
||||
logger.info("Attempting resume with stored session %s for chat %s", stored_id[:8], chat_id)
|
||||
|
||||
success, response, session_id = await _run_claude_cmd(resume_args, timeout, target_cwd=target_cwd)
|
||||
if success:
|
||||
logger.info("Stored session resumed - response captured (%d chars)", len(response))
|
||||
return True, response, session_id
|
||||
|
||||
logger.info("Stored session resume failed for chat %s, creating new session", chat_id)
|
||||
|
||||
# Strategy 2: Create new session with random UUID
|
||||
random_id = str(uuid.uuid4())
|
||||
logger.info("Creating new session for chat %s with random ID %s", chat_id, random_id[:8])
|
||||
create_args = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
'--session-id', random_id,
|
||||
'--output-format', 'stream-json', '--verbose',
|
||||
'--permission-mode', 'bypassPermissions'
|
||||
]
|
||||
success, response, session_id = await _run_claude_cmd(create_args, timeout, target_cwd=target_cwd)
|
||||
if success:
|
||||
return True, response, session_id
|
||||
|
||||
# Strategy 3: Deterministic UUID as last resort
|
||||
deterministic_id = _chat_id_to_uuid(chat_id)
|
||||
logger.info("Random create failed, falling back to deterministic ID %s", deterministic_id[:8])
|
||||
fallback_args = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
'--resume', deterministic_id,
|
||||
'--output-format', 'stream-json', '--verbose',
|
||||
'--permission-mode', 'bypassPermissions'
|
||||
]
|
||||
return await _run_claude_cmd(fallback_args, timeout, target_cwd=target_cwd)
|
||||
else:
|
||||
# No chat_id - one-shot with no session ID
|
||||
one_shot_args = [
|
||||
CLAUDE_BIN, '-p', prompt,
|
||||
'--output-format', 'stream-json', '--verbose',
|
||||
'--permission-mode', 'bypassPermissions'
|
||||
]
|
||||
logger.info("Running Claude in one-shot mode (timeout: %ds)", timeout)
|
||||
return await _run_claude_cmd(one_shot_args, timeout, target_cwd=target_cwd)
|
||||
|
||||
|
||||
async def spawn_and_capture(
|
||||
sender_name: str,
|
||||
message: str,
|
||||
chat_id: int = 0,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
target_cwd: Optional[Path] = None
|
||||
) -> Tuple[bool, List[str], Optional[str]]:
|
||||
"""
|
||||
Run Claude and return chunked response for Telegram.
|
||||
|
||||
This is the main entry point for capture mode.
|
||||
|
||||
Args:
|
||||
sender_name: Telegram sender name/username (for logging)
|
||||
message: The message text from Telegram
|
||||
chat_id: Telegram chat ID (for logging)
|
||||
timeout: Maximum seconds to wait for Claude
|
||||
target_cwd: Working directory for the Claude process (branch targeting)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, list_of_response_chunks, session_id or None)
|
||||
"""
|
||||
logger.info("spawn_and_capture called by %s (chat_id: %s)", sender_name, chat_id)
|
||||
|
||||
success, response, session_id = await run_claude_capture(message, chat_id, timeout, target_cwd=target_cwd)
|
||||
|
||||
if not success:
|
||||
return False, [response], session_id
|
||||
|
||||
chunks = chunk_response(response)
|
||||
logger.info("Response split into %d chunk(s)", len(chunks))
|
||||
|
||||
return True, chunks, session_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the spawner
|
||||
print("=" * 60)
|
||||
print("TELEGRAM CLAUDE SPAWNER")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Environment check:")
|
||||
print(f" DISPLAY: {os.environ.get('DISPLAY', 'Not set')}")
|
||||
print(f" gnome-terminal: {'Available' if has_gnome_terminal() else 'Not found'}")
|
||||
print(f" tmux: {'Available' if has_tmux() else 'Not found'}")
|
||||
print()
|
||||
print(f"Session path: {SESSION_PATH}")
|
||||
print(f"tmux session name: {TMUX_SESSION_NAME}")
|
||||
print()
|
||||
print("Would spawn with:")
|
||||
if has_display() and has_gnome_terminal():
|
||||
print(" → gnome-terminal (GUI mode)")
|
||||
elif has_tmux():
|
||||
print(" → tmux (headless mode)")
|
||||
else:
|
||||
print(" → ERROR: No spawner available")
|
||||
print()
|
||||
@@ -1,370 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: telegram_standards.py - Shared Telegram Bot Standards
|
||||
# Date: 2026-02-15
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-15): Initial - shared commands, templates, and utilities for all bots
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - stdlib ONLY (importable by all bots)
|
||||
# - Provides text generation, not message delivery
|
||||
# - Single source of truth for standard bot commands
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Shared Telegram Bot Standards for AIPass
|
||||
|
||||
Central definitions for standard commands, response templates, and text
|
||||
builders used by ALL AIPass Telegram bots (bridge, assistant, test, scheduler).
|
||||
|
||||
Two bot types exist in AIPass:
|
||||
- Async bots (python-telegram-bot): Use the text builders directly and
|
||||
deliver via their own async handlers.
|
||||
- Sync bots (stdlib urllib): Use parse_command() and handle_standard_command()
|
||||
to process commands, then deliver via their own send functions.
|
||||
|
||||
This module has ZERO external dependencies (stdlib only). Every bot can import it.
|
||||
|
||||
Usage (async bot):
|
||||
from aipass.api.apps.handlers.telegram.telegram_standards import (
|
||||
build_welcome_text, build_status_text, STANDARD_COMMANDS,
|
||||
)
|
||||
|
||||
Usage (sync bot):
|
||||
from aipass.api.apps.handlers.telegram.telegram_standards import (
|
||||
parse_command, handle_standard_command, STANDARD_COMMANDS,
|
||||
)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# =============================================
|
||||
# STANDARD COMMAND REGISTRY
|
||||
# =============================================
|
||||
|
||||
STANDARD_COMMANDS: dict[str, dict[str, str]] = {
|
||||
"start": {
|
||||
"description": "Welcome message and command list",
|
||||
"menu_text": "Start / welcome message",
|
||||
},
|
||||
"help": {
|
||||
"description": "Show available commands",
|
||||
"menu_text": "Show help",
|
||||
},
|
||||
"new": {
|
||||
"description": "Kill current session and start fresh (clean Claude context)",
|
||||
"menu_text": "Fresh session",
|
||||
},
|
||||
"status": {
|
||||
"description": "Show session info (branch, uptime, session state)",
|
||||
"menu_text": "Session status",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================
|
||||
# RESPONSE TEMPLATES
|
||||
# =============================================
|
||||
|
||||
PROCESSING_MSG = "Processing..."
|
||||
|
||||
ERROR_TEMPLATE = "Something went wrong: {error}"
|
||||
|
||||
HELP_FOOTER = "\nSend any message to chat with Claude."
|
||||
|
||||
# Internal templates (used by builder functions)
|
||||
_WELCOME_HEADER = "Hello! I'm {bot_name}."
|
||||
_WELCOME_BRANCH = "Branch: @{branch_name}"
|
||||
|
||||
_STATUS_HEADER = "Session Status"
|
||||
|
||||
|
||||
# =============================================
|
||||
# TEXT BUILDERS
|
||||
# =============================================
|
||||
|
||||
def _format_command_list(
|
||||
standard_commands: dict[str, dict[str, str]],
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format a combined command list as readable text.
|
||||
|
||||
Each command appears as: /command - description
|
||||
|
||||
Args:
|
||||
standard_commands: The STANDARD_COMMANDS dict (or a subset).
|
||||
custom_commands: Optional additional commands in the same format.
|
||||
|
||||
Returns:
|
||||
Multi-line string of formatted commands.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for cmd, info in standard_commands.items():
|
||||
lines.append(f"/{cmd} - {info['description']}")
|
||||
if custom_commands:
|
||||
for cmd, info in custom_commands.items():
|
||||
lines.append(f"/{cmd} - {info['description']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_help_text(
|
||||
standard_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build a /help message combining standard and custom commands.
|
||||
|
||||
Args:
|
||||
standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS.
|
||||
custom_commands: Optional bot-specific commands in the same format.
|
||||
|
||||
Returns:
|
||||
Formatted help text string.
|
||||
"""
|
||||
if standard_commands is None:
|
||||
standard_commands = STANDARD_COMMANDS
|
||||
|
||||
parts: list[str] = [
|
||||
"Commands:",
|
||||
_format_command_list(standard_commands, custom_commands),
|
||||
HELP_FOOTER,
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_welcome_text(
|
||||
bot_name: str,
|
||||
branch_name: str,
|
||||
standard_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the /start welcome message.
|
||||
|
||||
Args:
|
||||
bot_name: Display name of the bot (e.g., "AIPass Bridge Bot").
|
||||
branch_name: The branch this bot operates on (e.g., "dev_central").
|
||||
standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS.
|
||||
custom_commands: Optional bot-specific commands in the same format.
|
||||
|
||||
Returns:
|
||||
Formatted welcome text string.
|
||||
"""
|
||||
if standard_commands is None:
|
||||
standard_commands = STANDARD_COMMANDS
|
||||
|
||||
parts: list[str] = [
|
||||
_WELCOME_HEADER.format(bot_name=bot_name),
|
||||
_WELCOME_BRANCH.format(branch_name=branch_name),
|
||||
"",
|
||||
"Commands:",
|
||||
_format_command_list(standard_commands, custom_commands),
|
||||
HELP_FOOTER,
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_status_text(
|
||||
session_name: str,
|
||||
branch_name: str,
|
||||
uptime: Optional[str] = None,
|
||||
message_count: Optional[int] = None,
|
||||
chat_id: Optional[str | int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the /status response.
|
||||
|
||||
Checks tmux session state via subprocess. Reports branch, session,
|
||||
activity status, and optional metrics.
|
||||
|
||||
Args:
|
||||
session_name: tmux session name (e.g., "telegram-assistant").
|
||||
branch_name: Branch name (e.g., "assistant").
|
||||
uptime: Optional human-readable uptime string.
|
||||
message_count: Optional count of messages processed.
|
||||
chat_id: Optional Telegram chat ID to display.
|
||||
|
||||
Returns:
|
||||
Formatted status text string.
|
||||
"""
|
||||
active = _tmux_session_exists(session_name)
|
||||
|
||||
lines: list[str] = [_STATUS_HEADER]
|
||||
if chat_id is not None:
|
||||
lines.append(f"Chat ID: {chat_id}")
|
||||
lines.append(f"Branch: @{branch_name}")
|
||||
lines.append(f"Session: {session_name}")
|
||||
lines.append(f"State: {'Active' if active else 'Inactive'}")
|
||||
if uptime:
|
||||
lines.append(f"Uptime: {uptime}")
|
||||
if message_count is not None:
|
||||
lines.append(f"Messages: {message_count}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_botfather_commands(
|
||||
standard_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
Build command list for BotFather setMyCommands API.
|
||||
|
||||
Returns the format expected by Telegram's setMyCommands endpoint:
|
||||
[{"command": "start", "description": "Start / welcome message"}, ...]
|
||||
|
||||
Args:
|
||||
standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS.
|
||||
custom_commands: Optional bot-specific commands in the same format.
|
||||
|
||||
Returns:
|
||||
List of dicts with "command" and "description" keys.
|
||||
"""
|
||||
if standard_commands is None:
|
||||
standard_commands = STANDARD_COMMANDS
|
||||
|
||||
result: list[dict[str, str]] = []
|
||||
for cmd, info in standard_commands.items():
|
||||
result.append({"command": cmd, "description": info["menu_text"]})
|
||||
if custom_commands:
|
||||
for cmd, info in custom_commands.items():
|
||||
result.append({"command": cmd, "description": info["menu_text"]})
|
||||
return result
|
||||
|
||||
|
||||
# =============================================
|
||||
# SYNC BOT UTILITIES (stdlib bots)
|
||||
# =============================================
|
||||
|
||||
def parse_command(text: str) -> Optional[tuple[str, str]]:
|
||||
"""
|
||||
Extract command name and arguments from message text.
|
||||
|
||||
Handles both '/command' and '/command@bot_username' formats.
|
||||
Returns None if the text is not a command.
|
||||
|
||||
Args:
|
||||
text: Raw message text from Telegram.
|
||||
|
||||
Returns:
|
||||
Tuple of (command_name, args_string) or None if not a command.
|
||||
command_name is lowercase without the leading slash.
|
||||
args_string is everything after the command, stripped.
|
||||
|
||||
Examples:
|
||||
parse_command("/status") -> ("status", "")
|
||||
parse_command("/new please") -> ("new", "please")
|
||||
parse_command("/help@mybot") -> ("help", "")
|
||||
parse_command("hello world") -> None
|
||||
"""
|
||||
if not text or not text.startswith("/"):
|
||||
return None
|
||||
|
||||
# Split on whitespace: first part is /command[@botname], rest is args
|
||||
parts = text.split(None, 1)
|
||||
raw_command = parts[0][1:] # Remove leading /
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Strip @bot_username suffix if present
|
||||
if "@" in raw_command:
|
||||
raw_command = raw_command.split("@", 1)[0]
|
||||
|
||||
command = raw_command.lower().strip()
|
||||
if not command:
|
||||
return None
|
||||
|
||||
return (command, args.strip())
|
||||
|
||||
|
||||
def handle_standard_command(
|
||||
command: str,
|
||||
session_name: str,
|
||||
branch_name: str,
|
||||
bot_name: str,
|
||||
custom_commands: Optional[dict[str, dict[str, str]]] = None,
|
||||
chat_id: Optional[str | int] = None,
|
||||
message_count: Optional[int] = None,
|
||||
uptime: Optional[str] = None,
|
||||
) -> Optional[str | tuple[str, str]]:
|
||||
"""
|
||||
Handle a standard command and return the response text.
|
||||
|
||||
For most commands, returns a string with the response text.
|
||||
For /new, returns a tuple ("new", instructions_text) to signal
|
||||
the caller that they need to kill and restart their tmux session.
|
||||
The caller is responsible for tmux operations and for sending
|
||||
the response text.
|
||||
|
||||
Returns None if the command is not a standard command.
|
||||
|
||||
Args:
|
||||
command: The command name (lowercase, no slash).
|
||||
session_name: tmux session name (e.g., "telegram-assistant").
|
||||
branch_name: Branch name (e.g., "assistant").
|
||||
bot_name: Display name of the bot.
|
||||
custom_commands: Optional bot-specific commands for help text.
|
||||
chat_id: Optional Telegram chat ID (for /status display).
|
||||
message_count: Optional message count (for /status display).
|
||||
uptime: Optional uptime string (for /status display).
|
||||
|
||||
Returns:
|
||||
- str: Response text for /start, /help, /status
|
||||
- tuple[str, str]: ("new", response_text) for /new command
|
||||
- None: Command is not a standard command
|
||||
"""
|
||||
if command == "start":
|
||||
return build_welcome_text(
|
||||
bot_name=bot_name,
|
||||
branch_name=branch_name,
|
||||
custom_commands=custom_commands,
|
||||
)
|
||||
|
||||
if command == "help":
|
||||
return build_help_text(custom_commands=custom_commands)
|
||||
|
||||
if command == "new":
|
||||
response_text = f"Session cleared for @{branch_name}. Next message starts fresh."
|
||||
return ("new", response_text)
|
||||
|
||||
if command == "status":
|
||||
return build_status_text(
|
||||
session_name=session_name,
|
||||
branch_name=branch_name,
|
||||
uptime=uptime,
|
||||
message_count=message_count,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTERNAL HELPERS
|
||||
# =============================================
|
||||
|
||||
def _tmux_session_exists(session_name: str) -> bool:
|
||||
"""
|
||||
Check if a tmux session exists by name.
|
||||
|
||||
Args:
|
||||
session_name: The tmux session name to check.
|
||||
|
||||
Returns:
|
||||
True if the session is running, False otherwise.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "has-session", "-t", session_name],
|
||||
capture_output=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except FileNotFoundError:
|
||||
# tmux not installed
|
||||
return False
|
||||
@@ -1,314 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: tmux_manager.py - tmux Session Manager for Telegram Bridge
|
||||
# Date: 2026-02-12
|
||||
# Version: 1.2.0
|
||||
# Category: api/handlers/telegram
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.2.0 (2026-03-01): Add AIPASS_SESSION_TYPE env var + auto /rename for Claude session identification
|
||||
# - v1.1.0 (2026-02-24): Add bot_id awareness — AIPASS_BOT_ID env var in tmux sessions
|
||||
# - v1.0.0 (2026-02-12): Initial - tmux session create/kill/send/list for persistent Claude sessions
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pure functions with proper error raising
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
tmux Session Manager for Telegram Bridge
|
||||
|
||||
Manages persistent Claude Code sessions in tmux:
|
||||
- Create named sessions (telegram-{branch_name}) running Claude Code
|
||||
- Inject messages via tmux send-keys -l (literal mode)
|
||||
- Kill/list sessions
|
||||
- Capture pane content for status display
|
||||
|
||||
Each tmux session runs `claude --permission-mode bypassPermissions` continuously.
|
||||
Messages are injected via send-keys, responses captured via Stop hook.
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
# Constants
|
||||
SESSION_PREFIX = "telegram-"
|
||||
DEFAULT_BRANCH = "dev_central"
|
||||
CLAUDE_BIN = shutil.which("claude") or "claude"
|
||||
SEND_KEYS_DELAY = 0.5 # seconds between text injection and Enter
|
||||
|
||||
|
||||
RENAME_DELAY = 3 # seconds to wait for Claude to initialize before /rename
|
||||
|
||||
|
||||
def _session_name(branch_name: str) -> str:
|
||||
"""Build tmux session name from branch name."""
|
||||
return f"{SESSION_PREFIX}{branch_name}"
|
||||
|
||||
|
||||
def _send_rename(session_name: str, branch_name: str) -> None:
|
||||
"""Send /rename to a tmux session after Claude initializes."""
|
||||
time.sleep(RENAME_DELAY)
|
||||
rename_cmd = f"/rename {branch_name.upper()}-telegram"
|
||||
subprocess.run(
|
||||
["tmux", "send-keys", "-t", session_name, rename_cmd, "Enter"],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def has_tmux() -> bool:
|
||||
"""Check if tmux is available on the system."""
|
||||
return shutil.which("tmux") is not None
|
||||
|
||||
|
||||
def session_exists(branch_name: str) -> bool:
|
||||
"""
|
||||
Check if a tmux session exists for the given branch.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name (e.g. 'dev_central')
|
||||
|
||||
Returns:
|
||||
True if session is alive
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
result = subprocess.run(
|
||||
["tmux", "has-session", "-t", name],
|
||||
capture_output=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def create_session(branch_name: str, branch_path: Path, *, bot_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Create a tmux session and launch Claude Code inside it.
|
||||
|
||||
Session is named telegram-{branch_name} and starts in branch_path.
|
||||
Claude is launched with AIPASS_SESSION_TYPE=telegram and
|
||||
--permission-mode bypassPermissions. After initialization,
|
||||
sends /rename BRANCH-telegram for the /resume picker.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name for session naming
|
||||
branch_path: Working directory for Claude Code
|
||||
bot_id: Optional bot ID — sets AIPASS_BOT_ID env var in tmux session
|
||||
|
||||
Returns:
|
||||
True if session was created successfully
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
|
||||
if session_exists(branch_name):
|
||||
print("[INFO]", "Session %s already exists", name)
|
||||
return True
|
||||
|
||||
if not branch_path.is_dir():
|
||||
print("[ERROR]", "Branch path does not exist: %s", branch_path)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create detached tmux session
|
||||
result = subprocess.run(
|
||||
[
|
||||
"tmux", "new-session",
|
||||
"-d", # Detached
|
||||
"-s", name, # Session name
|
||||
"-c", str(branch_path), # Working directory
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("[ERROR]", "Failed to create tmux session %s: %s", name, result.stderr)
|
||||
return False
|
||||
|
||||
# Set bot_id environment variable if provided
|
||||
if bot_id:
|
||||
subprocess.run(
|
||||
["tmux", "set-environment", "-t", name, "AIPASS_BOT_ID", bot_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Launch Claude Code inside the session
|
||||
# Explicit cd guarantees CWD even if shell profile drifts it
|
||||
# AIPASS_SESSION_TYPE=telegram lets drone status label this session
|
||||
claude_cmd = (
|
||||
f"cd '{branch_path}' && "
|
||||
f"AIPASS_SESSION_TYPE=telegram {CLAUDE_BIN} --permission-mode bypassPermissions"
|
||||
)
|
||||
subprocess.run(
|
||||
["tmux", "send-keys", "-t", name, claude_cmd, "Enter"],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Rename the Claude conversation for the /resume picker
|
||||
# Claude needs a few seconds to initialize before /rename works
|
||||
_send_rename(name, branch_name)
|
||||
|
||||
print("[INFO]", "Created tmux session %s at %s", name, branch_path)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print("[ERROR]", "Error creating tmux session %s: %s", name, e)
|
||||
return False
|
||||
|
||||
|
||||
async def send_message(branch_name: str, message: str) -> bool:
|
||||
"""
|
||||
Inject a message into a tmux session via send-keys.
|
||||
|
||||
Uses -l flag for literal mode (no shell interpretation).
|
||||
Sends text first, waits briefly, then sends Enter.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name identifying the session
|
||||
message: The message text to inject
|
||||
|
||||
Returns:
|
||||
True if message was sent successfully
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
|
||||
if not session_exists(branch_name):
|
||||
print("[ERROR]", "Session %s does not exist", name)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Send text literally (no shell interpretation)
|
||||
result = subprocess.run(
|
||||
["tmux", "send-keys", "-t", name, "-l", message],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("[ERROR]", "Failed to send text to %s: %s", name, result.stderr)
|
||||
return False
|
||||
|
||||
# Wait before sending Enter (prevents rapid keystroke issues)
|
||||
await asyncio.sleep(SEND_KEYS_DELAY)
|
||||
|
||||
# Send Enter to submit the message
|
||||
result = subprocess.run(
|
||||
["tmux", "send-keys", "-t", name, "Enter"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("[ERROR]", "Failed to send Enter to %s: %s", name, result.stderr)
|
||||
return False
|
||||
|
||||
print("[INFO]", "Injected message into %s (%d chars)", name, len(message))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print("[ERROR]", "Error sending to tmux session %s: %s", name, e)
|
||||
return False
|
||||
|
||||
|
||||
def kill_session(branch_name: str) -> bool:
|
||||
"""
|
||||
Kill a tmux session for the given branch.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name identifying the session
|
||||
|
||||
Returns:
|
||||
True if session was killed (or didn't exist)
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
|
||||
if not session_exists(branch_name):
|
||||
print("[INFO]", "Session %s does not exist, nothing to kill", name)
|
||||
return True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "kill-session", "-t", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print("[INFO]", "Killed tmux session %s", name)
|
||||
return True
|
||||
else:
|
||||
print("[ERROR]", "Failed to kill session %s: %s", name, result.stderr)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print("[ERROR]", "Error killing tmux session %s: %s", name, e)
|
||||
return False
|
||||
|
||||
|
||||
def list_sessions() -> List[str]:
|
||||
"""
|
||||
List all active telegram-* tmux sessions.
|
||||
|
||||
Returns:
|
||||
List of branch names with active sessions
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "list-sessions", "-F", "#{session_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
sessions = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith(SESSION_PREFIX):
|
||||
branch = line[len(SESSION_PREFIX):]
|
||||
if branch:
|
||||
sessions.append(branch)
|
||||
|
||||
return sessions
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_session_pane(branch_name: str) -> Optional[str]:
|
||||
"""
|
||||
Capture current visible pane content from a tmux session.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name identifying the session
|
||||
|
||||
Returns:
|
||||
Pane content as string, or None if session doesn't exist
|
||||
"""
|
||||
name = _session_name(branch_name)
|
||||
|
||||
if not session_exists(branch_name):
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "capture-pane", "-t", name, "-p"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
@@ -1,29 +0,0 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - Telegram Service Handler Package
|
||||
# Date: 2026-02-03
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers
|
||||
# CODE STANDARDS: Seed v1.0.0
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-03): Initial package
|
||||
# =============================================
|
||||
|
||||
"""Telegram Service Handler - systemd control operations"""
|
||||
|
||||
from aipass.api.apps.handlers.telegram_service.service import (
|
||||
start_service,
|
||||
stop_service,
|
||||
get_status,
|
||||
get_logs,
|
||||
SERVICE_NAME,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"start_service",
|
||||
"stop_service",
|
||||
"get_status",
|
||||
"get_logs",
|
||||
"SERVICE_NAME",
|
||||
]
|
||||
@@ -1,121 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: service.py - Telegram Service Handler
|
||||
# Date: 2026-02-03
|
||||
# Version: 1.0.0
|
||||
# Category: api/handlers
|
||||
# CODE STANDARDS: Seed v1.0.0
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-03): Initial handler - systemd service operations
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram Service Handler
|
||||
|
||||
Low-level systemd operations for telegram-bridge service.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file to find AIPASS_REGISTRY.json (repo root)."""
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in [current] + list(current.parents):
|
||||
if (parent / "AIPASS_REGISTRY.json").exists():
|
||||
return parent
|
||||
return Path.cwd()
|
||||
|
||||
SERVICE_NAME = "telegram-bridge"
|
||||
LOG_FILE = _find_repo_root() / "logs" / "telegram_bridge.log"
|
||||
|
||||
|
||||
def start_service() -> Tuple[bool, str]:
|
||||
"""
|
||||
Start the telegram-bridge service
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "start", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, "Service started"
|
||||
return False, result.stderr.strip() if result.stderr else "Unknown error"
|
||||
|
||||
|
||||
def stop_service() -> Tuple[bool, str]:
|
||||
"""
|
||||
Stop the telegram-bridge service
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "stop", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, "Service stopped"
|
||||
return False, result.stderr.strip() if result.stderr else "Unknown error"
|
||||
|
||||
|
||||
def get_status() -> Tuple[str, str]:
|
||||
"""
|
||||
Get telegram-bridge service status
|
||||
|
||||
Returns:
|
||||
Tuple of (status_code, details)
|
||||
status_code: 'running', 'stopped', 'not_found', 'unknown'
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "status", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
output = result.stdout if result.stdout else result.stderr
|
||||
|
||||
if "Active: active (running)" in output:
|
||||
return "running", output
|
||||
elif "Active: inactive (dead)" in output:
|
||||
return "stopped", output
|
||||
elif "could not be found" in output.lower():
|
||||
return "not_found", output
|
||||
return "unknown", output
|
||||
|
||||
|
||||
def get_logs(lines: int = 30) -> Tuple[bool, str]:
|
||||
"""
|
||||
Get recent service logs
|
||||
|
||||
Args:
|
||||
lines: Number of recent lines to return
|
||||
|
||||
Returns:
|
||||
Tuple of (success, log_content or error_message)
|
||||
"""
|
||||
if not LOG_FILE.exists():
|
||||
return False, f"No log file found at {LOG_FILE}"
|
||||
|
||||
try:
|
||||
with open(LOG_FILE, encoding="utf-8") as f:
|
||||
all_lines = f.readlines()
|
||||
recent = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
||||
|
||||
if not recent:
|
||||
return False, "Log file is empty"
|
||||
|
||||
return True, "".join(recent)
|
||||
except OSError as e:
|
||||
return False, f"Failed to read logs: {e}"
|
||||
@@ -1,415 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: telegram_bot.py - Telegram multi-bot module (replaces telegram_bridge.py + telegram_chat.py)
|
||||
# Date: 2026-02-24
|
||||
# Version: 1.0.0
|
||||
# Category: api/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-24): Initial - unified multi-bot public API module
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Module layer: thin orchestration over handler functions
|
||||
# - This is the PUBLIC API that other branches import from
|
||||
# - Replaces telegram_bridge.py and telegram_chat.py
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram Multi-Bot Module - Public API
|
||||
|
||||
Unified entry point for the AIPass multi-bot Telegram architecture.
|
||||
Re-exports all handler internals so other branches can import them
|
||||
without triggering the cross-branch handler guard.
|
||||
|
||||
Replaces:
|
||||
- telegram_bridge.py (bridge operations)
|
||||
- telegram_chat.py (direct chat + standards re-exports)
|
||||
|
||||
Usage from other branches:
|
||||
from aipass.api.apps.modules.telegram_bot import BaseBot, BranchPlugin
|
||||
from aipass.api.apps.modules.telegram_bot import create_bot, delete_bot
|
||||
from aipass.api.apps.modules.telegram_bot import list_bots, get_bot
|
||||
from aipass.api.apps.modules.telegram_bot import load_bot_config
|
||||
from aipass.api.apps.modules.telegram_bot import (
|
||||
STANDARD_COMMANDS, build_help_text, build_welcome_text,
|
||||
build_status_text, parse_command, handle_standard_command,
|
||||
)
|
||||
"""
|
||||
|
||||
# Infrastructure
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Prax logger and CLI utilities
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, success, error, warning
|
||||
|
||||
# Handler imports (operations)
|
||||
from aipass.api.apps.handlers.telegram import bot_operations
|
||||
|
||||
# =============================================
|
||||
# RE-EXPORTS FROM HANDLERS (Public API)
|
||||
# =============================================
|
||||
|
||||
from aipass.api.apps.handlers.telegram.base_bot import BaseBot
|
||||
from aipass.api.apps.handlers.telegram.branch_plugin import BranchPlugin
|
||||
from aipass.api.apps.handlers.telegram.bot_registry import (
|
||||
list_bots,
|
||||
get_bot,
|
||||
register_bot,
|
||||
get_bot_by_branch,
|
||||
)
|
||||
from aipass.api.apps.handlers.telegram.bot_factory import create_bot, delete_bot
|
||||
from aipass.api.apps.handlers.telegram.config import load_bot_config
|
||||
from aipass.api.apps.handlers.telegram.telegram_standards import (
|
||||
STANDARD_COMMANDS,
|
||||
PROCESSING_MSG,
|
||||
build_help_text,
|
||||
build_welcome_text,
|
||||
build_status_text,
|
||||
build_botfather_commands,
|
||||
parse_command,
|
||||
handle_standard_command,
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# MODULE INTROSPECTION
|
||||
# =============================================
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Show module introspection - connected handlers and capabilities"""
|
||||
console.print()
|
||||
header("Telegram Multi-Bot Module Introspection")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Purpose:[/cyan] Multi-bot Telegram architecture for AIPass")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Connected Handlers:[/cyan]")
|
||||
console.print(" - api.apps.handlers.telegram.base_bot")
|
||||
console.print(" - api.apps.handlers.telegram.branch_plugin")
|
||||
console.print(" - api.apps.handlers.telegram.bot_registry")
|
||||
console.print(" - api.apps.handlers.telegram.bot_factory")
|
||||
console.print(" - api.apps.handlers.telegram.bot_operations")
|
||||
console.print(" - api.apps.handlers.telegram.config")
|
||||
console.print(" - api.apps.handlers.telegram.telegram_standards")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Available Commands:[/cyan]")
|
||||
console.print(" - start <bot_id> - Start a specific bot (polling loop)")
|
||||
console.print(" - stop <bot_id> - Stop a bot's systemd service")
|
||||
console.print(" - status [bot_id] - Show bot status (single or all)")
|
||||
console.print(" - list - List all registered bots")
|
||||
console.print(" - create <bot_id> <token> [options] - Create new bot")
|
||||
console.print(" - delete <bot_id> - Delete a bot")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print module help with argparse"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python3 telegram_bot.py",
|
||||
description="Telegram Multi-Bot Module - AIPass multi-bot management",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
start <bot_id> - Start a bot (long-polling, blocks)
|
||||
stop <bot_id> - Stop a bot's systemd service
|
||||
status [bot_id] - Show bot status (single or all)
|
||||
list - List all registered bots
|
||||
create <bot_id> <token> [opts] - Create a new bot
|
||||
delete <bot_id> - Delete a bot
|
||||
|
||||
CREATE OPTIONS:
|
||||
--branch <name> - Associate with an AIPass branch
|
||||
--work-dir <path> - Working directory for Claude sessions
|
||||
|
||||
EXAMPLES:
|
||||
# Start a bot (blocks until Ctrl+C)
|
||||
python3 telegram_bot.py start dev_central
|
||||
|
||||
# Check all bot statuses
|
||||
python3 telegram_bot.py status
|
||||
|
||||
# Create a new branch bot
|
||||
python3 telegram_bot.py create dev_central 123:ABC --branch dev_central
|
||||
|
||||
# List all bots
|
||||
python3 telegram_bot.py list
|
||||
"""
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
start_p = subparsers.add_parser("start", help="Start a bot")
|
||||
start_p.add_argument("bot_id", help="Bot identifier")
|
||||
|
||||
stop_p = subparsers.add_parser("stop", help="Stop a bot's systemd service")
|
||||
stop_p.add_argument("bot_id", help="Bot identifier")
|
||||
|
||||
status_p = subparsers.add_parser("status", help="Show bot status")
|
||||
status_p.add_argument("bot_id", nargs="?", help="Bot identifier (optional)")
|
||||
|
||||
subparsers.add_parser("list", help="List all registered bots")
|
||||
|
||||
create_p = subparsers.add_parser("create", help="Create a new bot")
|
||||
create_p.add_argument("bot_id", help="Bot identifier")
|
||||
create_p.add_argument("token", help="Telegram bot token")
|
||||
create_p.add_argument("--branch", help="AIPass branch name")
|
||||
create_p.add_argument("--work-dir", help="Working directory")
|
||||
|
||||
delete_p = subparsers.add_parser("delete", help="Delete a bot")
|
||||
delete_p.add_argument("bot_id", help="Bot identifier")
|
||||
|
||||
console.print(parser.format_help())
|
||||
|
||||
|
||||
# =============================================
|
||||
# COMMAND HANDLING
|
||||
# =============================================
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""
|
||||
Handle module commands routed via drone.
|
||||
|
||||
Args:
|
||||
command: Command name (e.g., "telegram_bot")
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True if command was handled, False to pass through
|
||||
"""
|
||||
if not args:
|
||||
return False
|
||||
|
||||
subcommand = args[0]
|
||||
|
||||
if subcommand == "start" and len(args) >= 2:
|
||||
_cmd_start(args[1])
|
||||
return True
|
||||
|
||||
elif subcommand == "stop" and len(args) >= 2:
|
||||
_cmd_stop(args[1])
|
||||
return True
|
||||
|
||||
elif subcommand == "status":
|
||||
bot_id = args[1] if len(args) >= 2 else None
|
||||
_cmd_status(bot_id)
|
||||
return True
|
||||
|
||||
elif subcommand == "list":
|
||||
_cmd_list()
|
||||
return True
|
||||
|
||||
elif subcommand == "create" and len(args) >= 3:
|
||||
_cmd_create(args[1:])
|
||||
return True
|
||||
|
||||
elif subcommand == "delete" and len(args) >= 2:
|
||||
_cmd_delete(args[1])
|
||||
return True
|
||||
|
||||
elif subcommand == "help":
|
||||
print_help()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# THIN ORCHESTRATION (delegates to bot_operations handler)
|
||||
# =============================================
|
||||
|
||||
|
||||
def _cmd_start(bot_id: str) -> None:
|
||||
"""Orchestrate bot start: header, delegate, exit."""
|
||||
header(f"Starting Bot: {bot_id}")
|
||||
console.print()
|
||||
|
||||
config = load_bot_config(bot_id)
|
||||
if not config:
|
||||
error(f"No config found for bot '{bot_id}'")
|
||||
console.print("[dim]Expected: ~/.aipass/telegram_bots/{bot_id}.json[/dim]")
|
||||
return
|
||||
|
||||
if not config.get("bot_token"):
|
||||
error(f"No bot_token in config for '{bot_id}'")
|
||||
return
|
||||
|
||||
bot_name = config.get("bot_name", f"AIPass {bot_id} Bot")
|
||||
logger.info("Starting bot '%s' (%s)", bot_id, bot_name)
|
||||
success(f"Launching {bot_name}...")
|
||||
console.print()
|
||||
|
||||
exit_code = bot_operations.start_bot(bot_id)
|
||||
if exit_code is None:
|
||||
error(f"Bot '{bot_id}' failed to start")
|
||||
else:
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def _cmd_stop(bot_id: str) -> None:
|
||||
"""Orchestrate bot stop: header, delegate, display result."""
|
||||
header(f"Stopping Bot: {bot_id}")
|
||||
console.print()
|
||||
|
||||
ok, msg = bot_operations.stop_bot(bot_id)
|
||||
if ok:
|
||||
logger.info("Stopped bot '%s'", bot_id)
|
||||
success(msg)
|
||||
else:
|
||||
logger.warning("Failed to stop bot '%s': %s", bot_id, msg)
|
||||
error(msg)
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
def _cmd_status(bot_id: str | None = None) -> None:
|
||||
"""Orchestrate status display: header, delegate, format."""
|
||||
if bot_id:
|
||||
header(f"Bot Status: {bot_id}")
|
||||
else:
|
||||
header("All Bots Status")
|
||||
console.print()
|
||||
|
||||
bots = bot_operations.get_status(bot_id)
|
||||
if not bots:
|
||||
if bot_id:
|
||||
error(f"Bot '{bot_id}' not found in registry")
|
||||
else:
|
||||
warning("No bots registered")
|
||||
console.print("[dim]Use 'create' to add a new bot[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
for bot in bots:
|
||||
for line in bot_operations.format_bot_details(bot):
|
||||
console.print(f" [cyan]{line}[/cyan]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _cmd_list() -> None:
|
||||
"""Orchestrate bot listing: header, delegate, format."""
|
||||
header("Registered Bots")
|
||||
console.print()
|
||||
|
||||
bots = bot_operations.get_all_bots()
|
||||
if not bots:
|
||||
warning("No bots registered")
|
||||
console.print("[dim]Use 'create' to add a new bot[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
for line in bot_operations.format_bot_table(bots):
|
||||
console.print(line)
|
||||
console.print()
|
||||
|
||||
|
||||
def _cmd_create(args: list) -> None:
|
||||
"""Orchestrate bot creation: parse args, delegate, display result."""
|
||||
parsed = bot_operations.parse_create_args(args)
|
||||
if not parsed:
|
||||
error("Usage: create <bot_id> <token> [--branch <name>] [--work-dir <path>]")
|
||||
return
|
||||
|
||||
bot_id = parsed["bot_id"]
|
||||
header(f"Creating Bot: {bot_id}")
|
||||
console.print()
|
||||
|
||||
result = create_bot(
|
||||
bot_id=bot_id,
|
||||
bot_token=parsed["bot_token"],
|
||||
branch_name=parsed["branch_name"],
|
||||
work_dir=parsed["work_dir"],
|
||||
)
|
||||
|
||||
if result:
|
||||
logger.info("Bot created via module: %s", bot_id)
|
||||
success(f"Bot '{bot_id}' created successfully")
|
||||
console.print()
|
||||
details = bot_operations.format_bot_details({
|
||||
"bot_id": result["bot_id"],
|
||||
"username": result["username"],
|
||||
"branch_name": result.get("branch_name"),
|
||||
"work_dir": result["work_dir"],
|
||||
"status": "active",
|
||||
"service_name": result["service_name"],
|
||||
})
|
||||
for line in details:
|
||||
console.print(f" [cyan]{line}[/cyan]")
|
||||
else:
|
||||
logger.warning("Bot creation failed for '%s'", bot_id)
|
||||
error(f"Failed to create bot '{bot_id}'. Check logs for details.")
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
def _cmd_delete(bot_id: str) -> None:
|
||||
"""Orchestrate bot deletion: verify, delegate, display result."""
|
||||
header(f"Deleting Bot: {bot_id}")
|
||||
console.print()
|
||||
|
||||
bot = get_bot(bot_id)
|
||||
if not bot:
|
||||
error(f"Bot '{bot_id}' not found in registry")
|
||||
console.print()
|
||||
return
|
||||
|
||||
result = delete_bot(bot_id)
|
||||
|
||||
if result:
|
||||
logger.info("Bot deleted via module: %s", bot_id)
|
||||
success(f"Bot '{bot_id}' deleted successfully")
|
||||
else:
|
||||
logger.warning("Bot deletion failed for '%s'", bot_id)
|
||||
error(f"Failed to delete bot '{bot_id}'. Check logs for details.")
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
# STANDALONE EXECUTION
|
||||
# =============================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Standalone execution mode"""
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Show introspection when run without arguments
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
|
||||
# Show help for explicit help flags
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
# Execute command directly (standalone mode, not drone-routed)
|
||||
command = args[0]
|
||||
|
||||
if command == "start" and len(args) >= 2:
|
||||
_cmd_start(args[1])
|
||||
elif command == "stop" and len(args) >= 2:
|
||||
_cmd_stop(args[1])
|
||||
elif command == "status":
|
||||
bot_id = args[1] if len(args) >= 2 else None
|
||||
_cmd_status(bot_id)
|
||||
elif command == "list":
|
||||
_cmd_list()
|
||||
elif command == "create" and len(args) >= 3:
|
||||
_cmd_create(args[1:])
|
||||
elif command == "delete" and len(args) >= 2:
|
||||
_cmd_delete(args[1])
|
||||
else:
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {command}[/red]")
|
||||
console.print()
|
||||
console.print("Run [dim]python3 telegram_bot.py --help[/dim] for available commands")
|
||||
console.print()
|
||||
sys.exit(1)
|
||||
@@ -1,230 +0,0 @@
|
||||
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: telegram_service.py - Telegram Service Control Module
|
||||
# Date: 2026-02-03
|
||||
# Version: 1.0.0
|
||||
# Category: api/modules
|
||||
# CODE STANDARDS: Seed v1.0.0
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-02-03): Initial module - systemd service control for telegram bridge
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Telegram Service Control Module
|
||||
|
||||
Manages the telegram-bridge systemd user service:
|
||||
- drone @api telegram start → Start service
|
||||
- drone @api telegram stop → Stop service
|
||||
- drone @api telegram status → Check service status
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, success, error, warning
|
||||
from aipass.api.apps.handlers.telegram_service import service
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Show module introspection"""
|
||||
console.print()
|
||||
header("Telegram Service Control Introspection")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Purpose:[/cyan] Manage telegram-bridge systemd service")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Connected Handlers:[/cyan]")
|
||||
console.print(" • api.apps.handlers.telegram_service.service")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Available Commands:[/cyan]")
|
||||
console.print(" • telegram start - Start the service")
|
||||
console.print(" • telegram stop - Stop the service")
|
||||
console.print(" • telegram status - Check service status")
|
||||
console.print(" • telegram logs - View recent logs")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Service:[/cyan]")
|
||||
console.print(f" • Name: {service.SERVICE_NAME}.service")
|
||||
console.print(" • Type: systemd user service")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print module help"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="drone @api telegram",
|
||||
description="Telegram Service Control - Manage the telegram-bridge service",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
SUBCOMMANDS:
|
||||
start - Start the telegram-bridge service
|
||||
stop - Stop the telegram-bridge service
|
||||
status - Check service status
|
||||
logs - View recent service logs
|
||||
|
||||
USAGE:
|
||||
drone @api telegram start
|
||||
drone @api telegram stop
|
||||
drone @api telegram status
|
||||
drone @api telegram logs
|
||||
|
||||
EXAMPLES:
|
||||
# Start the bot
|
||||
drone @api telegram start
|
||||
|
||||
# Check if running
|
||||
drone @api telegram status
|
||||
|
||||
# Stop the bot
|
||||
drone @api telegram stop
|
||||
|
||||
# View logs
|
||||
drone @api telegram logs
|
||||
"""
|
||||
)
|
||||
console.print(parser.format_help())
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle telegram service commands
|
||||
|
||||
Args:
|
||||
command: Command name (should be 'telegram')
|
||||
args: Subcommand and arguments
|
||||
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
if command != "telegram":
|
||||
return False
|
||||
|
||||
try:
|
||||
if not args:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
|
||||
if subcommand == "start":
|
||||
_handle_start()
|
||||
elif subcommand == "stop":
|
||||
_handle_stop()
|
||||
elif subcommand == "status":
|
||||
_handle_status()
|
||||
elif subcommand == "logs":
|
||||
_handle_logs()
|
||||
elif subcommand in ["--help", "-h", "help"]:
|
||||
print_help()
|
||||
else:
|
||||
error(f"Unknown subcommand: {subcommand}")
|
||||
console.print()
|
||||
console.print("[dim]Use 'drone @api telegram --help' for usage[/dim]")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Error in telegram_service.handle_command: %s", str(e))
|
||||
raise
|
||||
|
||||
|
||||
def _handle_start() -> None:
|
||||
"""Orchestrate service start"""
|
||||
header("Telegram Bridge Service")
|
||||
console.print()
|
||||
|
||||
ok, msg = service.start_service()
|
||||
|
||||
if ok:
|
||||
success(msg)
|
||||
console.print()
|
||||
console.print("[dim]Check status: drone @api telegram status[/dim]")
|
||||
console.print("[dim]View logs: drone @api telegram logs[/dim]")
|
||||
else:
|
||||
error(f"Failed to start service: {msg}")
|
||||
|
||||
|
||||
def _handle_stop() -> None:
|
||||
"""Orchestrate service stop"""
|
||||
header("Telegram Bridge Service")
|
||||
console.print()
|
||||
|
||||
ok, msg = service.stop_service()
|
||||
|
||||
if ok:
|
||||
success(msg)
|
||||
else:
|
||||
error(f"Failed to stop service: {msg}")
|
||||
|
||||
|
||||
def _handle_status() -> None:
|
||||
"""Orchestrate status check"""
|
||||
header("Telegram Bridge Service Status")
|
||||
console.print()
|
||||
|
||||
status_code, output = service.get_status()
|
||||
|
||||
if status_code == "running":
|
||||
success("Service is running")
|
||||
elif status_code == "stopped":
|
||||
warning("Service is stopped")
|
||||
elif status_code == "not_found":
|
||||
error("Service not found - run 'systemctl --user daemon-reload'")
|
||||
else:
|
||||
warning("Unknown status")
|
||||
|
||||
console.print()
|
||||
|
||||
for line in output.split("\n"):
|
||||
line = line.strip()
|
||||
if any(k in line for k in ["Active:", "Main PID:", "Memory:", "CPU:"]):
|
||||
console.print(f" {line}")
|
||||
|
||||
console.print()
|
||||
console.print("[dim]Logs: drone @api telegram logs[/dim]")
|
||||
|
||||
|
||||
def _handle_logs() -> None:
|
||||
"""Orchestrate log retrieval"""
|
||||
header("Telegram Bridge Service Logs")
|
||||
console.print()
|
||||
|
||||
ok, content = service.get_logs(30)
|
||||
|
||||
if not ok:
|
||||
warning(content)
|
||||
return
|
||||
|
||||
console.print(f"[dim]Showing last 30 lines[/dim]")
|
||||
console.print()
|
||||
|
||||
for line in content.split("\n"):
|
||||
console.print(line.rstrip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Standalone execution mode"""
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
|
||||
if args[0] in ['--help', '-h', 'help']:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
if handle_command("telegram", args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {args[0]}[/red]")
|
||||
console.print()
|
||||
sys.exit(1)
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enabledMcpjsonServers": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-03-06T03:16:10.906535",
|
||||
"created": "2026-03-06T14:27:34.810230",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [],
|
||||
|
||||
+35
-36
@@ -1,51 +1,50 @@
|
||||
# CLI
|
||||
|
||||
**Purpose:** Terminal display formatting - headers, success, error, warning helpers
|
||||
**Module:** `aipass.cli`
|
||||
**Created:** 2026-03-05
|
||||
Display and output formatting service for AIPass modules. Provides consistent terminal output — headers, success/error/warning messages, section breaks, and operation templates — so every module looks the same without duplicating Rich formatting code.
|
||||
|
||||
---
|
||||
## Usage
|
||||
|
||||
## Overview
|
||||
```python
|
||||
from aipass.cli import header, success, error, warning, section
|
||||
|
||||
### What I Do
|
||||
header("Creating Branch", {"Name": "feature", "Type": "module"})
|
||||
success("Files created", items=12, time="2.3s")
|
||||
error("Path not found", suggestion="Check spelling")
|
||||
warning("Config missing, using defaults")
|
||||
section("Results")
|
||||
```
|
||||
|
||||
### Operation Templates
|
||||
|
||||
### How I Work
|
||||
- **Entry Point:** `apps/cli.py`
|
||||
- **Pattern:** Auto-discovers and routes to modules
|
||||
```python
|
||||
from aipass.cli import operation_start, operation_complete
|
||||
|
||||
---
|
||||
operation_start("Processing", count=10)
|
||||
# ... do work ...
|
||||
operation_complete(created=5, skipped=3, failed=0)
|
||||
```
|
||||
|
||||
### Direct Console Access
|
||||
|
||||
```python
|
||||
from aipass.cli import console
|
||||
|
||||
console.print("[bold cyan]Custom Rich output[/bold cyan]")
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CLI/
|
||||
cli/
|
||||
├── apps/
|
||||
│ ├── cli.py # Entry point
|
||||
│ ├── modules/ # Business logic
|
||||
│ ├── handlers/ # Implementation
|
||||
│ └── plugins/ # Extensions
|
||||
├── docs/
|
||||
├── tests/
|
||||
├── passport.json # Identity
|
||||
├── local.json # Session history
|
||||
├── observations.json # Collaboration patterns
|
||||
└── README.md
|
||||
│ ├── cli.py # Entry point
|
||||
│ ├── modules/
|
||||
│ │ ├── display.py # header, success, error, warning, section
|
||||
│ │ └── templates.py # operation_start, operation_complete
|
||||
│ └── handlers/
|
||||
│ └── json/ # JSON file management
|
||||
└── tests/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
*Configure after initialization*
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
|
||||
|
||||
### Provides To
|
||||
|
||||
- `apps/modules/` — Public API. Import from here.
|
||||
- `apps/handlers/` — Internal implementation. Don't import directly.
|
||||
|
||||
@@ -64,7 +64,7 @@ def _find_real_caller():
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = filepath.split("/")
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass":
|
||||
if i + 1 < len(parts):
|
||||
|
||||
@@ -15,15 +15,27 @@
|
||||
# - No Prax imports (handler tier 3)
|
||||
# =============================================
|
||||
|
||||
"""JSON Auto-Creating Handler - manages CLI JSON files with templates and auto-rotation."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
import sys
|
||||
from typing import Dict, Any, Optional
|
||||
import inspect
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Constants — package-relative paths (portable across any machine)
|
||||
CLI_ROOT = Path(__file__).resolve().parents[3] # json_handler.py -> json -> handlers -> apps -> cli
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file to find the repo root (contains pyproject.toml or AIPASS_REGISTRY.json)."""
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in [current] + list(current.parents):
|
||||
if (parent / "pyproject.toml").exists() or (parent / "AIPASS_REGISTRY.json").exists():
|
||||
return parent
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
# Constants — resolved via repo root walk-up (portable across any machine)
|
||||
_REPO_ROOT = _find_repo_root()
|
||||
CLI_ROOT = _REPO_ROOT / "src" / "aipass" / "cli"
|
||||
CLI_JSON_DIR = CLI_ROOT / "cli_json"
|
||||
JSON_TEMPLATES_DIR = CLI_ROOT / "apps" / "json_templates"
|
||||
|
||||
@@ -108,9 +120,8 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
# If corrupted, fall through to regenerate
|
||||
except Exception:
|
||||
# If unreadable, fall through to regenerate
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("JSON unreadable at %s, regenerating: %s", json_path, exc)
|
||||
|
||||
template = load_template(json_type, module_name)
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enabledMcpjsonServers": []
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
# DEVPULSE
|
||||
# DevPulse
|
||||
|
||||
**Purpose:** Orchestration hub for AIPass public repo. Manages dev notes, DPLANs, system status dashboards. The DEV_CENTRAL of AIPass.
|
||||
**Purpose:** Dev notes and status tracking for AIPass projects
|
||||
**Module:** `aipass.devpulse`
|
||||
**Created:** 2026-03-06
|
||||
**Last Updated:** 2026-03-06
|
||||
**Status:** Building
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What I Do
|
||||
- Coordinate work across all 10 AIPass modules
|
||||
- Track system health via seedgo audits and recon reports
|
||||
- Manage DPLANs and development notes
|
||||
- Test and wire modules in Docker container environment
|
||||
DevPulse tracks development notes, plans, and project status across an AIPass ecosystem. It provides a shared notation layer where both humans and agents can log issues, todos, and progress — giving visibility into what's happening without requiring meetings or status emails.
|
||||
|
||||
### How I Work
|
||||
- **Entry Point:** `apps/branch.py`
|
||||
### How It Works
|
||||
- **Entry Point:** `apps/devpulse.py`
|
||||
- **Pattern:** Auto-discovers modules in `apps/modules/` with `handle_command()` and routes commands
|
||||
|
||||
---
|
||||
@@ -24,27 +19,15 @@
|
||||
## Architecture
|
||||
|
||||
```
|
||||
DEVPULSE/
|
||||
devpulse/
|
||||
├── apps/
|
||||
│ ├── branch.py # Entry point (auto-discovery + routing)
|
||||
│ ├── modules/ # Business logic (empty, building out)
|
||||
│ ├── handlers/ # Implementation
|
||||
│ └── plugins/ # Extensions
|
||||
├── .trinity/
|
||||
│ ├── passport.json # Identity
|
||||
│ ├── local.json # Session history
|
||||
│ └── observations.json # Collaboration patterns
|
||||
├── .agent/ # System metadata
|
||||
├── .aipass/ # Branch prompt context
|
||||
├── artifacts/ # Birth certificate
|
||||
│ ├── devpulse.py # Entry point (auto-discovery + routing)
|
||||
│ ├── modules/ # Business logic
|
||||
│ ├── handlers/ # Implementation
|
||||
│ └── plugins/ # Extensions
|
||||
├── devpulse_json/ # JSON storage
|
||||
├── docs/
|
||||
│ └── sub_agent_drops/ # Recon reports from sub-agents
|
||||
├── tools/
|
||||
│ └── verify_branch.py # Template verification
|
||||
├── devpulse_json/ # Branch JSON storage
|
||||
├── tests/
|
||||
├── DASHBOARD.local.json # System status
|
||||
├── flow.local.md # Issues and todos
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -52,16 +35,33 @@ DEVPULSE/
|
||||
|
||||
## Commands
|
||||
|
||||
*No modules built yet — commands will be added as modules are created.*
|
||||
```bash
|
||||
drone @devpulse --help # Show available commands
|
||||
```
|
||||
|
||||
*Modules are being built out — commands will appear as they ship.*
|
||||
|
||||
---
|
||||
|
||||
## Python Usage
|
||||
|
||||
```python
|
||||
from aipass.devpulse.apps.devpulse import discover_modules, route_command
|
||||
|
||||
# Discover available sub-modules
|
||||
modules = discover_modules()
|
||||
|
||||
# Route a command
|
||||
route_command("status", [], modules)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
- `aipass.prax` — Logger
|
||||
- `aipass.prax` — Logging
|
||||
- `aipass.cli` — Display formatting
|
||||
- `aipass.seedgo` — Standards auditing
|
||||
|
||||
### Provides To
|
||||
- All modules — orchestration coordination, system health tracking
|
||||
- All modules — dev notes, plan tracking, project status
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""DevPulse - System monitoring and dashboards for AIPass."""
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enabledMcpjsonServers": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-03-06T03:16:10.929991",
|
||||
"created": "2026-03-06T14:27:37.777414",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [],
|
||||
|
||||
+67
-38
@@ -1,51 +1,80 @@
|
||||
# DRONE
|
||||
# Drone
|
||||
|
||||
**Purpose:** Command routing, @ resolution, module discovery and dispatch
|
||||
**Module:** `aipass.drone`
|
||||
**Created:** 2026-03-05
|
||||
Command router and symbolic addressing for AIPass. Resolves `@branch` names to paths at runtime via `AIPASS_REGISTRY.json`, routes commands to module entry points, and discovers available commands across the system.
|
||||
|
||||
---
|
||||
## CLI
|
||||
|
||||
## Overview
|
||||
```bash
|
||||
drone systems # List all registered modules and branches
|
||||
drone @seedgo verify # Route "verify" to the seedgo module
|
||||
drone @seedgo audit aipass # Route "audit aipass" to seedgo
|
||||
drone @module --help # Show help for any module
|
||||
drone --version # Show version
|
||||
```
|
||||
|
||||
### What I Do
|
||||
## Python API
|
||||
|
||||
```python
|
||||
from aipass.drone import resolve_branch, list_branches, route_command
|
||||
|
||||
### How I Work
|
||||
- **Entry Point:** `apps/drone.py`
|
||||
- **Pattern:** Auto-discovers and routes to modules
|
||||
# Resolve @name to absolute path
|
||||
path = resolve_branch("@seedgo")
|
||||
|
||||
---
|
||||
# List all registered branches
|
||||
branches = list_branches() # All branches
|
||||
active = list_branches(status="active") # Filter by status
|
||||
|
||||
# Route a command to a branch
|
||||
result = route_command("@seedgo", "verify")
|
||||
print(result.stdout) # Command output
|
||||
print(result.exit_code) # 0 on success
|
||||
```
|
||||
|
||||
### Registry Management
|
||||
|
||||
```python
|
||||
from aipass.drone import set_registry_path, get_registry_path
|
||||
|
||||
# Use a custom registry location
|
||||
set_registry_path("/path/to/AIPASS_REGISTRY.json")
|
||||
|
||||
# Or set via environment variable
|
||||
# export AIPASS_REGISTRY_PATH=/path/to/registry.json
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
from aipass.drone import resolve_branch, BranchNotFoundError, CommandExecutionError
|
||||
|
||||
try:
|
||||
path = resolve_branch("@nonexistent")
|
||||
except BranchNotFoundError:
|
||||
print("Branch not found in registry")
|
||||
|
||||
try:
|
||||
result = route_command("@seedgo", "audit", args=["aipass"], timeout=120)
|
||||
except CommandExecutionError as e:
|
||||
print(f"Command failed: {e}")
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
DRONE/
|
||||
drone/
|
||||
├── cli.py # pip entry point (drone command)
|
||||
├── drone_adapter.py # Self-routing adapter for drone @drone
|
||||
├── __init__.py # Public API exports
|
||||
├── apps/
|
||||
│ ├── drone.py # Entry point
|
||||
│ ├── modules/ # Business logic
|
||||
│ ├── handlers/ # Implementation
|
||||
│ └── plugins/ # Extensions
|
||||
├── docs/
|
||||
├── tests/
|
||||
├── passport.json # Identity
|
||||
├── local.json # Session history
|
||||
├── observations.json # Collaboration patterns
|
||||
└── README.md
|
||||
│ ├── drone.py # Core entry point
|
||||
│ ├── modules/ # Business logic
|
||||
│ │ ├── config.py # Registry path resolution
|
||||
│ │ ├── resolver.py # Branch resolution (@name -> path)
|
||||
│ │ ├── router.py # Command routing via subprocess
|
||||
│ │ ├── discovery.py # Module and command discovery
|
||||
│ │ └── module_registry.py # Internal module routing
|
||||
│ └── handlers/ # Implementation
|
||||
│ ├── executor.py # Safe subprocess execution
|
||||
│ └── exceptions.py # Exception hierarchy
|
||||
└── tests/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
*Configure after initialization*
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
|
||||
|
||||
### Provides To
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ def show_introspection() -> None:
|
||||
if branches:
|
||||
print(f"Registered Branches ({len(branches)}):")
|
||||
for name in sorted(branches):
|
||||
print(f" @{name}")
|
||||
print(f" {name}")
|
||||
|
||||
if not modules and not branches:
|
||||
print("No branches or modules registered.")
|
||||
|
||||
@@ -28,13 +28,22 @@ def execute_command(
|
||||
args: List[str],
|
||||
cwd: str,
|
||||
timeout: int = 30,
|
||||
env: dict | None = None,
|
||||
) -> CommandResult:
|
||||
"""Execute a command via subprocess with safety guards.
|
||||
|
||||
Never uses shell=True to prevent shell injection attacks.
|
||||
"""
|
||||
import os
|
||||
|
||||
full_cmd = [executable] + list(args)
|
||||
|
||||
# Merge custom env vars with current environment
|
||||
run_env = None
|
||||
if env:
|
||||
run_env = os.environ.copy()
|
||||
run_env.update(env)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
full_cmd,
|
||||
@@ -42,6 +51,7 @@ def execute_command(
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
shell=False,
|
||||
env=run_env,
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise CommandExecutionError(
|
||||
|
||||
@@ -7,6 +7,7 @@ and scanning module directories as a fallback.
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
@@ -82,7 +83,7 @@ def discover_modules(target: str) -> List[str]:
|
||||
if entry_point is not None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", str(entry_point.relative_to(branch_path)), "--help"],
|
||||
[sys.executable, str(entry_point.relative_to(branch_path)), "--help"],
|
||||
cwd=branch_path,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
@@ -121,7 +122,7 @@ def get_help(target: str, command: Optional[str] = None) -> HelpResult:
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3"] + cmd_args,
|
||||
[sys.executable] + cmd_args,
|
||||
cwd=branch_path,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
|
||||
@@ -6,6 +6,7 @@ locating the branch's apps/{name}.py entry point, and executing via subprocess.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
@@ -49,11 +50,15 @@ def route_command(
|
||||
relative_entry = str(entry_point.relative_to(branch_path))
|
||||
cmd_args = [relative_entry, command] + list(args)
|
||||
|
||||
# Pass caller's CWD so target branches can detect who invoked them
|
||||
caller_env = {"AIPASS_CALLER_CWD": str(Path.cwd())}
|
||||
|
||||
result = execute_command(
|
||||
executable="python3",
|
||||
executable=sys.executable,
|
||||
args=cmd_args,
|
||||
cwd=branch_path,
|
||||
timeout=timeout,
|
||||
env=caller_env,
|
||||
)
|
||||
|
||||
return CommandResult(
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enabledMcpjsonServers": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-03-06T03:16:10.952307",
|
||||
"created": "2026-03-06T14:27:39.378089",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [],
|
||||
|
||||
+49
-33
@@ -1,51 +1,67 @@
|
||||
# FLOW
|
||||
# Flow
|
||||
|
||||
**Purpose:** Plan lifecycle management - DPLAN/FPLAN/MPLAN creation, tracking, closure
|
||||
**Module:** `aipass.flow`
|
||||
**Created:** 2026-03-05
|
||||
Plan lifecycle management for AIPass. Creates, tracks, closes, and archives numbered work plans (FPLANs) with registry-backed state, async post-processing, and cross-branch aggregation.
|
||||
|
||||
---
|
||||
## Usage
|
||||
|
||||
## Overview
|
||||
### CLI (via drone)
|
||||
|
||||
### What I Do
|
||||
```bash
|
||||
drone @flow create . "Build authentication module" # Create plan in current dir
|
||||
drone @flow create /path/to "Migration task" master # Master plan (multi-phase)
|
||||
drone @flow close 42 # Close plan
|
||||
drone @flow close --all # Close all open plans
|
||||
drone @flow list # List plans
|
||||
drone @flow aggregate # Aggregate plans across branches
|
||||
drone @flow registry # Registry health monitor
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
### How I Work
|
||||
- **Entry Point:** `apps/flow.py`
|
||||
- **Pattern:** Auto-discovers and routes to modules
|
||||
```python
|
||||
from aipass.flow.apps.modules.create_plan import create_plan
|
||||
from aipass.flow.apps.modules.close_plan import close_plan
|
||||
from aipass.flow.apps.modules.list_plans import list_plans
|
||||
from aipass.flow.apps.handlers.registry.load_registry import load_registry
|
||||
|
||||
---
|
||||
# Load the plan registry
|
||||
registry = load_registry()
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
FLOW/
|
||||
flow/
|
||||
├── apps/
|
||||
│ ├── flow.py # Entry point
|
||||
│ ├── modules/ # Business logic
|
||||
│ ├── handlers/ # Implementation
|
||||
│ └── plugins/ # Extensions
|
||||
├── docs/
|
||||
├── tests/
|
||||
├── passport.json # Identity
|
||||
├── local.json # Session history
|
||||
├── observations.json # Collaboration patterns
|
||||
└── README.md
|
||||
│ ├── flow.py # Entry point (auto-discovers modules)
|
||||
│ ├── modules/ # Business logic
|
||||
│ │ ├── create_plan.py # Plan creation with template support
|
||||
│ │ ├── close_plan.py # Closure with async archival
|
||||
│ │ ├── list_plans.py # Plan listing and filtering
|
||||
│ │ ├── restore_plan.py # Plan recovery from backups
|
||||
│ │ ├── registry_monitor.py # Orphan detection, auto-healing
|
||||
│ │ ├── aggregate_central.py # Cross-branch plan aggregation
|
||||
│ │ └── post_close_runner.py # Background post-processing
|
||||
│ └── handlers/ # Implementation details
|
||||
│ ├── plan/ # Lifecycle, file ops, validation
|
||||
│ ├── registry/ # Load, save, auto-heal
|
||||
│ ├── template/ # Plan templates (default, master, proposal)
|
||||
│ ├── dashboard/ # Status aggregation
|
||||
│ ├── mbank/ # Memory bank archival
|
||||
│ └── summary/ # AI-generated plan summaries
|
||||
├── templates/ # Plan template files
|
||||
├── flow_json/ # Configuration and registry data
|
||||
└── tests/
|
||||
```
|
||||
|
||||
---
|
||||
## Plan Naming
|
||||
|
||||
## Commands
|
||||
Plans follow the convention `FPLAN-XXXX_topic_slug_YYYY-MM-DD.md` where XXXX is an auto-incrementing number.
|
||||
|
||||
*Configure after initialization*
|
||||
## Dependencies
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
|
||||
|
||||
### Provides To
|
||||
- `aipass.cli` — terminal formatting
|
||||
- `aipass.prax` — structured logging
|
||||
- `aipass.trigger` — error reporting (optional)
|
||||
|
||||
Last Updated: 2026-03-06
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Flow - Task planning and workflow management for AIPass."""
|
||||
@@ -43,7 +43,7 @@ def _find_real_caller():
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = filepath.split("/")
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part in ("aipass", "MEMORY_BANK", "Nexus"):
|
||||
if i + 1 < len(parts):
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enabledMcpjsonServers": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-03-06T03:16:10.975118",
|
||||
"created": "2026-03-06T14:27:42.646499",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [],
|
||||
|
||||
+65
-36
@@ -1,51 +1,80 @@
|
||||
# PRAX
|
||||
# Prax
|
||||
|
||||
**Purpose:** System logging, event tracking, and real-time monitoring infrastructure
|
||||
**Module:** `aipass.prax`
|
||||
**Created:** 2026-03-05
|
||||
System-wide logging and real-time monitoring for AIPass. Prax auto-routes log output from any module to per-module log files and provides a live monitoring console (Mission Control) that shows file changes, log events, and command execution across all branches.
|
||||
|
||||
---
|
||||
## Usage
|
||||
|
||||
## Overview
|
||||
### Logging
|
||||
|
||||
### What I Do
|
||||
```python
|
||||
from aipass.prax import logger
|
||||
|
||||
logger.info("Processing started")
|
||||
logger.warning("Disk usage high")
|
||||
logger.error("Connection failed")
|
||||
```
|
||||
|
||||
### How I Work
|
||||
- **Entry Point:** `apps/prax.py`
|
||||
- **Pattern:** Auto-discovers and routes to modules
|
||||
Logs auto-route to `system_logs/<branch>_<module>.log` based on the calling module. No configuration needed — prax detects the caller via stack introspection.
|
||||
|
||||
---
|
||||
For handlers or plugins that need to bypass the event pipeline:
|
||||
|
||||
```python
|
||||
from aipass.prax.apps.modules.logger import get_direct_logger
|
||||
|
||||
log = get_direct_logger("my_handler")
|
||||
log.info("Direct log entry")
|
||||
```
|
||||
|
||||
### Mission Control
|
||||
|
||||
Real-time monitoring console for watching system activity across all branches.
|
||||
|
||||
```bash
|
||||
drone @prax monitor
|
||||
```
|
||||
|
||||
Interactive commands inside the monitor:
|
||||
|
||||
```
|
||||
watch all # Watch all branches
|
||||
watch prax # Watch specific branch
|
||||
watch errors # Only show errors
|
||||
status # Show current filters
|
||||
quit # Exit
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `drone @prax monitor` | Launch Mission Control |
|
||||
| `drone @prax init` | Initialize logging system |
|
||||
| `drone @prax status` | Show system status |
|
||||
| `drone @prax run` | Start continuous logging mode |
|
||||
| `drone @prax shutdown` | Shutdown logging system |
|
||||
| `drone @prax discover` | Discover Python modules in ecosystem |
|
||||
| `drone @prax log-audit` | Audit log file sizes and health |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
PRAX/
|
||||
prax/
|
||||
├── apps/
|
||||
│ ├── prax.py # Entry point
|
||||
│ ├── modules/ # Business logic
|
||||
│ ├── handlers/ # Implementation
|
||||
│ └── plugins/ # Extensions
|
||||
├── docs/
|
||||
├── tests/
|
||||
├── passport.json # Identity
|
||||
├── local.json # Session history
|
||||
├── observations.json # Collaboration patterns
|
||||
└── README.md
|
||||
│ ├── prax.py # Entry point
|
||||
│ ├── modules/
|
||||
│ │ ├── logger.py # SystemLogger (public API)
|
||||
│ │ └── monitor_module.py # Mission Control
|
||||
│ └── handlers/
|
||||
│ ├── logging/ # Log setup, rotation, introspection
|
||||
│ ├── monitoring/ # Event queue, branch detection, stream output
|
||||
│ ├── discovery/ # Module scanning and filtering
|
||||
│ ├── config/ # Configuration loading
|
||||
│ └── registry/ # Module registry management
|
||||
└── tests/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
*Configure after initialization*
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
|
||||
|
||||
### Provides To
|
||||
## How It Works
|
||||
|
||||
1. **Auto-routing** — When any module calls `logger.info()`, prax inspects the call stack to identify the caller and routes the log entry to the appropriate file.
|
||||
2. **Dual output** — Each log entry goes to both a system-wide log (`system_logs/`) and a branch-local log (`{branch}/logs/`), both with rotation.
|
||||
3. **Mission Control** — A multi-threaded monitoring console that watches file changes (via inotify), log events, and agent activity across all branches simultaneously.
|
||||
|
||||
@@ -43,7 +43,7 @@ def _find_real_caller():
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = filepath.split("/")
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass":
|
||||
if i + 1 < len(parts):
|
||||
|
||||
@@ -62,7 +62,11 @@ def _find_repo_root() -> Path:
|
||||
_system_logs_dir_cache: Path | None = None
|
||||
|
||||
def get_system_logs_dir() -> Path:
|
||||
"""Lazily resolve and create system_logs directory (package-relative)."""
|
||||
"""Lazily resolve and create system_logs directory (package-relative).
|
||||
|
||||
DEPRECATED: Use get_module_logs_dir(module_name) for per-module logging.
|
||||
Kept for monitoring code that scans a central directory.
|
||||
"""
|
||||
global _system_logs_dir_cache
|
||||
if _system_logs_dir_cache is None:
|
||||
repo_root = _find_repo_root()
|
||||
@@ -70,6 +74,23 @@ def get_system_logs_dir() -> Path:
|
||||
_system_logs_dir_cache.mkdir(parents=True, exist_ok=True)
|
||||
return _system_logs_dir_cache
|
||||
|
||||
|
||||
def get_module_logs_dir(module_name: str) -> Path:
|
||||
"""Get the logs directory for a specific module.
|
||||
|
||||
Returns ECOSYSTEM_ROOT / module_name / "logs", creating it if needed.
|
||||
Each module logs to its own directory instead of a centralized system_logs/.
|
||||
|
||||
Args:
|
||||
module_name: Module name (e.g., "flow", "prax", "trigger")
|
||||
|
||||
Returns:
|
||||
Path to the module's logs directory
|
||||
"""
|
||||
logs_dir = ECOSYSTEM_ROOT / module_name / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
return logs_dir
|
||||
|
||||
# Config file
|
||||
PRAX_LOGGER_CONFIG_FILE = PRAX_JSON_DIR / "prax_logger_config.json"
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@ def _is_pid_alive(pid: int) -> bool:
|
||||
True if process exists and looks like a claude agent
|
||||
"""
|
||||
try:
|
||||
if sys.platform != "linux":
|
||||
return False
|
||||
cmdline_path = Path(f"/proc/{pid}/cmdline")
|
||||
if not cmdline_path.exists():
|
||||
return False
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user