mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 11:41:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ddbb4ee71 | ||
|
|
371ec987ac | ||
|
|
0885a9b65d | ||
|
|
fe8f52c1b4 | ||
|
|
951dfd36c8 | ||
|
|
7d0657632f | ||
|
|
ab80807b34 | ||
|
|
38888c745e | ||
|
|
d905a25833 | ||
|
|
ec0b435ab3 | ||
|
|
4833b86863 | ||
|
|
42af95d9ab | ||
|
|
0ec7cec870 |
No files matched your search
@@ -672,7 +672,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, aembedding, aembeddings, embedding, embeddings, get_dimensions
|
||||
```
|
||||
|
||||
Methods:
|
||||
@@ -736,6 +736,8 @@ Methods:
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
|
||||
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
|
||||
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">embedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
|
||||
@@ -777,6 +779,7 @@ Methods:
|
||||
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot whatsapp <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser browser-callback <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser click <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
* <code title="cli">praisonai browser navigate <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
|
||||
@@ -889,6 +892,7 @@ Methods:
|
||||
* <code title="cli">praisonai memory search <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory show <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai memory status <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
|
||||
* <code title="cli">praisonai obs obs-fallback <a href="./src/praisonai/praisonai/cli/commands/obs.py">--help</a></code>
|
||||
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
|
||||
@@ -1014,6 +1018,7 @@ Methods:
|
||||
* <code title="cli">praisonai train llm <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai train show <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai train train-callback <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui bot <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui chat <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui code <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
* <code title="cli">praisonai ui gradio <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.4.7" \
|
||||
"praisonai>=4.5.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.4.7" \
|
||||
"praisonai>=4.5.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.4.7" \
|
||||
"praisonai>=4.5.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Agent Approval — delegate tool approvals to another AI agent."""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval import AgentApproval
|
||||
|
||||
# Create a security reviewer agent
|
||||
reviewer = Agent(
|
||||
name="security-reviewer",
|
||||
instructions=(
|
||||
"You are a security reviewer. Only approve low-risk read operations. "
|
||||
"Deny anything destructive like delete, remove, or write operations. "
|
||||
"Respond with exactly one word: APPROVE or DENY"
|
||||
),
|
||||
)
|
||||
|
||||
# Create a worker agent that uses the reviewer for approvals
|
||||
worker = Agent(
|
||||
name="assistant",
|
||||
instructions="Help users with system tasks",
|
||||
tools=["execute_command"],
|
||||
approval=AgentApproval(approver_agent=reviewer),
|
||||
)
|
||||
|
||||
worker.start("List the files in the current directory")
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Discord Approval — route tool approvals to a Discord channel."""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import DiscordApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
instructions="Help users with system tasks",
|
||||
tools=["execute_command"],
|
||||
approval=DiscordApproval(channel_id="YOUR_CHANNEL_ID"), # token from DISCORD_BOT_TOKEN env
|
||||
)
|
||||
|
||||
agent.start("List the files in the current directory")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""HTTP Approval — serve a local web dashboard for tool approvals."""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import HTTPApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
instructions="Help users with system tasks",
|
||||
tools=["execute_command"],
|
||||
approval=HTTPApproval(host="127.0.0.1", port=8899),
|
||||
)
|
||||
|
||||
# When a tool needs approval, open http://127.0.0.1:8899/approve/<request_id>
|
||||
# in your browser and click Approve or Deny.
|
||||
agent.start("List the files in the current directory")
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Slack Approval — route tool approvals to a Slack channel."""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import SlackApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
instructions="Help users with system tasks",
|
||||
tools=["execute_command"],
|
||||
approval=SlackApproval(channel="#approvals"), # token from SLACK_BOT_TOKEN env
|
||||
)
|
||||
|
||||
agent.start("List the files in the current directory")
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Telegram Approval — route tool approvals to a Telegram chat."""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import TelegramApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
instructions="Help users with system tasks",
|
||||
tools=["execute_command"],
|
||||
approval=TelegramApproval(chat_id="YOUR_CHAT_ID"), # token from TELEGRAM_BOT_TOKEN env
|
||||
)
|
||||
|
||||
agent.start("List the files in the current directory")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Webhook Approval — route tool approvals to an external HTTP endpoint."""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import WebhookApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
instructions="Help users with system tasks",
|
||||
tools=["execute_command"],
|
||||
approval=WebhookApproval(
|
||||
webhook_url="https://your-app.com/api/approvals",
|
||||
headers={"Authorization": "Bearer YOUR_TOKEN"},
|
||||
),
|
||||
)
|
||||
|
||||
agent.start("List the files in the current directory")
|
||||
@@ -47,3 +47,11 @@ channels:
|
||||
routing:
|
||||
dm: "personal"
|
||||
default: "personal"
|
||||
|
||||
# WhatsApp Web mode (experimental, no tokens needed):
|
||||
# whatsapp:
|
||||
# mode: web
|
||||
# creds_dir: "~/.praisonai/whatsapp"
|
||||
# routing:
|
||||
# dm: "personal"
|
||||
# default: "personal"
|
||||
@@ -0,0 +1,13 @@
|
||||
# WhatsApp Web Mode Bot (Experimental)
|
||||
# Token-free: connect via QR code scan
|
||||
# ⚠️ Uses reverse-engineered protocol — your number may be banned
|
||||
|
||||
platform: whatsapp
|
||||
mode: web
|
||||
# creds_dir: "~/.praisonai/whatsapp" # optional, default shown
|
||||
|
||||
agent:
|
||||
name: "WhatsApp Web Assistant"
|
||||
instructions: "You are a helpful AI assistant on WhatsApp."
|
||||
llm: "gpt-4o-mini"
|
||||
memory: true
|
||||
@@ -111,6 +111,7 @@ Easy and user friendly
|
||||
| `bus/` | 48K | Event bus |
|
||||
| `streaming/` | 56K | Streaming events, callbacks |
|
||||
| `trace/` | 152K | Trace protocols, context events |
|
||||
| `scheduler/` | 48K | Schedule models, store, parser, runner |
|
||||
|
||||
### 3.2 Protocol-First Design
|
||||
|
||||
@@ -343,6 +344,56 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3 Parameter Consolidation
|
||||
|
||||
Agent parameters are consolidated into Config objects following the pattern:
|
||||
`False=disabled, True=defaults, Config=custom`
|
||||
|
||||
**Consolidated Parameters (use these):**
|
||||
|
||||
| Config Object | Replaces (Deprecated) |
|
||||
|--------------|----------------------|
|
||||
| `execution=ExecutionConfig(code_execution=True, code_mode="safe", rate_limiter=obj)` | `allow_code_execution`, `code_execution_mode`, `rate_limiter` |
|
||||
| `memory=MemoryConfig(auto_save="session_name")` | `auto_save` |
|
||||
| `autonomy=AutonomyConfig(verification_hooks=[...])` | `verification_hooks` |
|
||||
| `handoffs=[other_agent]` | `allow_delegation` |
|
||||
| `output=OutputConfig(...)` | `verbose`, `markdown`, `stream`, `metrics` |
|
||||
| `reflection=ReflectionConfig(...)` | `self_reflect`, `max_reflect`, `min_reflect` |
|
||||
| `templates=TemplateConfig(...)` | `system_template`, `prompt_template`, `response_template` |
|
||||
| `caching=CachingConfig(...)` | `cache`, `prompt_caching` |
|
||||
| `web=WebConfig(...)` | `web_search`, `web_fetch` |
|
||||
|
||||
**Deprecated standalone params still work** (backward compatible) but emit `DeprecationWarning`.
|
||||
|
||||
```python
|
||||
# ❌ Old way (deprecated)
|
||||
agent = Agent(
|
||||
name="coder",
|
||||
allow_code_execution=True,
|
||||
code_execution_mode="safe",
|
||||
auto_save="my_session",
|
||||
rate_limiter=my_limiter,
|
||||
allow_delegation=True,
|
||||
verification_hooks=[my_hook],
|
||||
)
|
||||
|
||||
# ✅ New way (consolidated)
|
||||
from praisonaiagents import Agent, ExecutionConfig, MemoryConfig
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
|
||||
agent = Agent(
|
||||
name="coder",
|
||||
handoffs=[reviewer_agent],
|
||||
execution=ExecutionConfig(
|
||||
code_execution=True,
|
||||
code_mode="safe",
|
||||
rate_limiter=my_limiter,
|
||||
),
|
||||
memory=MemoryConfig(auto_save="my_session"),
|
||||
autonomy=AutonomyConfig(verification_hooks=[my_hook]),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Extension Points
|
||||
@@ -406,7 +457,37 @@ class MyMemoryAdapter: # Implements MemoryProtocol
|
||||
...
|
||||
```
|
||||
|
||||
### 6.4 Database Adapters
|
||||
### 6.4 Schedule Tools
|
||||
|
||||
Agent-centric scheduling via standalone `@tool` functions (no Agent class bloat):
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
tools=[schedule_add, schedule_list, schedule_remove],
|
||||
)
|
||||
```
|
||||
|
||||
**Schedule expression formats:**
|
||||
- Keywords: `"hourly"`, `"daily"`, `"weekly"`
|
||||
- Intervals: `"*/30m"`, `"*/6h"`, `"*/10s"`
|
||||
- Cron: `"cron:0 7 * * *"` (requires optional `croniter`)
|
||||
- One-shot: `"at:2026-03-01T09:00:00"`
|
||||
- Relative: `"in 20 minutes"`
|
||||
|
||||
**Core components:**
|
||||
- `scheduler/models.py` — `Schedule` and `ScheduleJob` dataclasses
|
||||
- `scheduler/store.py` — `FileScheduleStore` (thread-safe JSON persistence at `~/.praisonai/schedules/jobs.json`)
|
||||
- `scheduler/parser.py` — `parse_schedule()` for human-friendly expressions
|
||||
- `scheduler/runner.py` — `ScheduleRunner` (stateless due-job checker)
|
||||
- `tools/schedule_tools.py` — `schedule_add`, `schedule_list`, `schedule_remove`
|
||||
|
||||
**Hook events:** `SCHEDULE_ADD`, `SCHEDULE_REMOVE`, `SCHEDULE_TRIGGER` (in `hooks/types.py`)
|
||||
|
||||
### 6.5 Database Adapters
|
||||
|
||||
```python
|
||||
# Base files in praisonaiagents/db/
|
||||
@@ -602,10 +683,153 @@ from praisonaiagents import Workflow, Route, Parallel, Loop
|
||||
| Event bus | `praisonaiagents/bus/bus.py` |
|
||||
| Workflow engine | `praisonaiagents/workflows/workflows.py` |
|
||||
| Policy engine | `praisonaiagents/policy/engine.py` |
|
||||
| Scheduler module | `praisonaiagents/scheduler/` |
|
||||
| Schedule tools | `praisonaiagents/tools/schedule_tools.py` |
|
||||
|
||||
---
|
||||
|
||||
## 12. Design Goals
|
||||
## 12. BotOS — Multi-Platform Bot Orchestration
|
||||
|
||||
### 12.1 Architecture
|
||||
|
||||
BotOS is the multi-platform bot orchestration layer for PraisonAI. It follows the same protocol-driven, agent-centric philosophy as the rest of the SDK.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ BotOS │
|
||||
│ Multi-platform orchestrator (runs all bots) │
|
||||
├──────────┬──────────┬──────────┬─────────────────┤
|
||||
│ Bot │ Bot │ Bot │ Bot │
|
||||
│ telegram │ discord │ slack │ custom_platform │
|
||||
├──────────┴──────────┴──────────┴─────────────────┤
|
||||
│ Agent / AgentTeam / AgentFlow │
|
||||
│ (AI brain) │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 12.2 Layer Separation
|
||||
|
||||
| Layer | Package | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `BotOSProtocol` | `praisonaiagents` (Core SDK) | Interface contract — lightweight |
|
||||
| `BotOSConfig` | `praisonaiagents` (Core SDK) | Configuration dataclass |
|
||||
| `Bot` | `praisonai` (Wrapper) | Single-platform bot wrapper |
|
||||
| `BotOS` | `praisonai` (Wrapper) | Multi-platform orchestrator |
|
||||
| `_registry` | `praisonai` (Wrapper) | Platform adapter registry |
|
||||
|
||||
### 12.3 Usage Patterns
|
||||
|
||||
```python
|
||||
# === Level 1: Single bot, 3 lines ===
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import Agent
|
||||
|
||||
bot = Bot("telegram", agent=Agent(name="assistant", instructions="Be helpful"))
|
||||
bot.run()
|
||||
|
||||
# === Level 2: Multi-platform, shared agent ===
|
||||
from praisonai.bots import BotOS
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="assistant", instructions="Be helpful")
|
||||
botos = BotOS(agent=agent, platforms=["telegram", "discord"])
|
||||
botos.run()
|
||||
|
||||
# === Level 3: AgentTeam in BotOS ===
|
||||
from praisonai.bots import BotOS, Bot
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
|
||||
researcher = Agent(name="researcher", instructions="Research topics")
|
||||
writer = Agent(name="writer", instructions="Write content")
|
||||
t1 = Task(name="research", description="Research AI", agent=researcher)
|
||||
t2 = Task(name="write", description="Write about AI", agent=writer)
|
||||
team = AgentTeam(agents=[researcher, writer], tasks=[t1, t2])
|
||||
|
||||
botos = BotOS(bots=[Bot("telegram", agent=team)])
|
||||
botos.run()
|
||||
|
||||
# === Level 4: YAML config ===
|
||||
botos = BotOS.from_config("botos.yaml")
|
||||
botos.run()
|
||||
|
||||
# === Level 5: Custom platform extension ===
|
||||
from praisonai.bots._registry import register_platform
|
||||
|
||||
class MyCustomBot:
|
||||
def __init__(self, **kwargs): ...
|
||||
async def start(self): ...
|
||||
async def stop(self): ...
|
||||
|
||||
register_platform("mychat", MyCustomBot)
|
||||
bot = Bot("mychat", agent=agent, token="my-token")
|
||||
bot.run()
|
||||
```
|
||||
|
||||
### 12.4 Token Resolution
|
||||
|
||||
Tokens are resolved in order: `explicit token=` > env var > empty string.
|
||||
|
||||
| Platform | Env Var |
|
||||
|----------|---------|
|
||||
| Telegram | `TELEGRAM_BOT_TOKEN` |
|
||||
| Discord | `DISCORD_BOT_TOKEN` |
|
||||
| Slack | `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` |
|
||||
| WhatsApp | `WHATSAPP_ACCESS_TOKEN` + `WHATSAPP_PHONE_NUMBER_ID` |
|
||||
| Custom | `{PLATFORM}_BOT_TOKEN` |
|
||||
|
||||
### 12.5 File Locations
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| BotOSProtocol | `praisonaiagents/bots/protocols.py` |
|
||||
| BotOSConfig | `praisonaiagents/bots/config.py` |
|
||||
| Bot class | `praisonai/bots/bot.py` |
|
||||
| BotOS class | `praisonai/bots/botos.py` |
|
||||
| Platform registry | `praisonai/bots/_registry.py` |
|
||||
| Unit tests | `tests/unit/test_botos_protocol.py` |
|
||||
| Integration tests | `tests/unit/test_botos_integration.py` |
|
||||
|
||||
### 12.6 Extending Platforms
|
||||
|
||||
Third-party platforms can be registered at runtime:
|
||||
|
||||
```python
|
||||
from praisonai.bots._registry import register_platform, list_platforms
|
||||
|
||||
# Register
|
||||
register_platform("line", LineBot)
|
||||
register_platform("viber", ViberBot)
|
||||
|
||||
# Verify
|
||||
print(list_platforms()) # [..., "line", "viber", ...]
|
||||
|
||||
# Use
|
||||
bot = Bot("line", agent=agent, token="my-line-token")
|
||||
```
|
||||
|
||||
**Adapter contract**: Custom adapters must implement `async start()` and `async stop()`. Optional: `send_message()`, `on_message()`, `on_command()`, `is_running` property.
|
||||
|
||||
### 12.7 YAML Config Format
|
||||
|
||||
```yaml
|
||||
# botos.yaml
|
||||
agent:
|
||||
name: assistant
|
||||
instructions: You are a helpful assistant.
|
||||
llm: gpt-4o-mini
|
||||
platforms:
|
||||
telegram:
|
||||
token: ${TELEGRAM_BOT_TOKEN}
|
||||
discord:
|
||||
token: ${DISCORD_BOT_TOKEN}
|
||||
slack:
|
||||
token: ${SLACK_BOT_TOKEN}
|
||||
app_token: ${SLACK_APP_TOKEN}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Design Goals
|
||||
|
||||
> Make PraisonAI the **best agent framework in the world**
|
||||
|
||||
|
||||
@@ -193,6 +193,10 @@ _LAZY_IMPORTS = {
|
||||
'GuardrailResult': ('praisonaiagents.guardrails', 'GuardrailResult'),
|
||||
'LLMGuardrail': ('praisonaiagents.guardrails', 'LLMGuardrail'),
|
||||
|
||||
# Approval (agent-centric approval backends)
|
||||
'AutoApproveBackend': ('praisonaiagents.approval.backends', 'AutoApproveBackend'),
|
||||
'ConsoleBackend': ('praisonaiagents.approval.backends', 'ConsoleBackend'),
|
||||
|
||||
# Flow display
|
||||
'FlowDisplay': ('praisonaiagents.flow_display', 'FlowDisplay'),
|
||||
'track_workflow': ('praisonaiagents.flow_display', 'track_workflow'),
|
||||
@@ -416,6 +420,8 @@ _LAZY_IMPORTS = {
|
||||
'BotChannel': ('praisonaiagents.bots.protocols', 'BotChannel'),
|
||||
'MessageType': ('praisonaiagents.bots.protocols', 'MessageType'),
|
||||
'BotConfig': ('praisonaiagents.bots.config', 'BotConfig'),
|
||||
'BotOSProtocol': ('praisonaiagents.bots.protocols', 'BotOSProtocol'),
|
||||
'BotOSConfig': ('praisonaiagents.bots.config', 'BotOSConfig'),
|
||||
|
||||
# Sandbox protocols and config (implementations in praisonai wrapper)
|
||||
'SandboxProtocol': ('praisonaiagents.sandbox.protocols', 'SandboxProtocol'),
|
||||
@@ -646,6 +652,9 @@ __all__ = [
|
||||
'tool',
|
||||
'Tools',
|
||||
|
||||
# Approval (agent-centric approval backends)
|
||||
'AutoApproveBackend',
|
||||
|
||||
# Embedding API - simplified imports
|
||||
# Usage: from praisonaiagents import embedding, EmbeddingResult
|
||||
'embedding',
|
||||
|
||||
@@ -411,13 +411,13 @@ class Agent:
|
||||
api_key: Optional[str] = None, # Kept separate (connection/auth)
|
||||
# Tools
|
||||
tools: Optional[List[Any]] = None,
|
||||
allow_delegation: bool = False,
|
||||
allow_code_execution: Optional[bool] = False,
|
||||
code_execution_mode: Literal["safe", "unsafe"] = "safe",
|
||||
allow_delegation: bool = False, # Deprecated: use handoffs= instead
|
||||
allow_code_execution: Optional[bool] = False, # Deprecated: use execution=ExecutionConfig(code_execution=True)
|
||||
code_execution_mode: Literal["safe", "unsafe"] = "safe", # Deprecated: use execution=ExecutionConfig(code_mode="safe")
|
||||
handoffs: Optional[List[Union['Agent', 'Handoff']]] = None,
|
||||
# Session management
|
||||
auto_save: Optional[str] = None,
|
||||
rate_limiter: Optional[Any] = None,
|
||||
# Session management (deprecated standalone params - use config objects)
|
||||
auto_save: Optional[str] = None, # Deprecated: use memory=MemoryConfig(auto_save="name")
|
||||
rate_limiter: Optional[Any] = None, # Deprecated: use execution=ExecutionConfig(rate_limiter=obj)
|
||||
# ============================================================
|
||||
# CONSOLIDATED FEATURE PARAMS (agent-centric API)
|
||||
# Each follows: False=disabled, True=defaults, Config=custom
|
||||
@@ -430,13 +430,14 @@ class Agent:
|
||||
web: Optional[Union[bool, Any]] = None, # Union[bool, WebConfig]
|
||||
context: Optional[Union[bool, Any]] = None, # Union[bool, ManagerConfig, ContextManager] - None=smart default
|
||||
autonomy: Optional[Union[bool, Dict[str, Any], Any]] = None, # Union[bool, dict, AutonomyConfig]
|
||||
verification_hooks: Optional[List[Any]] = None, # List of VerificationHook instances
|
||||
verification_hooks: Optional[List[Any]] = None, # Deprecated: use autonomy=AutonomyConfig(verification_hooks=[...])
|
||||
output: Optional[Union[str, Any]] = None, # Union[str preset, OutputConfig]
|
||||
execution: Optional[Union[str, Any]] = None, # Union[str preset, ExecutionConfig]
|
||||
templates: Optional[Any] = None, # TemplateConfig
|
||||
caching: Optional[Union[bool, Any]] = None, # Union[bool, CachingConfig]
|
||||
hooks: Optional[Union[List[Any], Any]] = None, # Union[list, HooksConfig]
|
||||
skills: Optional[Union[List[str], Any]] = None, # Union[list, SkillsConfig]
|
||||
approval: Optional[Union[bool, Any]] = None, # Union[bool, ApprovalProtocol backend]
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -452,12 +453,12 @@ class Agent:
|
||||
base_url: Custom LLM endpoint URL (e.g., for Ollama). Kept separate for auth.
|
||||
api_key: API key for LLM provider. Kept separate for auth.
|
||||
tools: List of tools, functions, callables, or MCP instances.
|
||||
allow_delegation: Allow task delegation to other agents. Defaults to False.
|
||||
allow_code_execution: Enable code execution during tasks. Defaults to False.
|
||||
code_execution_mode: "safe" (restricted) or "unsafe" (full access). Defaults to "safe".
|
||||
allow_delegation: **Deprecated** — use ``handoffs=`` instead.
|
||||
allow_code_execution: **Deprecated** — use ``execution=ExecutionConfig(code_execution=True)``.
|
||||
code_execution_mode: **Deprecated** — use ``execution=ExecutionConfig(code_mode="safe")``.
|
||||
handoffs: List of Agent or Handoff objects for agent-to-agent collaboration.
|
||||
auto_save: Session name for automatic session saving.
|
||||
rate_limiter: Rate limiter instance for API call throttling.
|
||||
auto_save: **Deprecated** — use ``memory=MemoryConfig(auto_save="name")``.
|
||||
rate_limiter: **Deprecated** — use ``execution=ExecutionConfig(rate_limiter=obj)``.
|
||||
memory: Memory system configuration. Accepts:
|
||||
- bool: True enables defaults, False disables
|
||||
- MemoryConfig: Custom configuration
|
||||
@@ -486,7 +487,8 @@ class Agent:
|
||||
- bool: True enables with defaults
|
||||
- Dict: Configuration dict
|
||||
- AutonomyConfig: Custom configuration
|
||||
verification_hooks: List of VerificationHook instances for output verification.
|
||||
verification_hooks: **Deprecated** — use ``autonomy=AutonomyConfig(verification_hooks=[...])``.
|
||||
Still works for backward compatibility.
|
||||
output: Output configuration. Accepts:
|
||||
- str: Preset name ("silent", "actions", "verbose", "json", "stream")
|
||||
- OutputConfig: Custom configuration
|
||||
@@ -520,6 +522,11 @@ class Agent:
|
||||
- system_template, prompt_template, response_template → templates=
|
||||
- cache, prompt_caching → caching=
|
||||
- web_search, web_fetch → web=
|
||||
- allow_delegation → handoffs=
|
||||
- allow_code_execution, code_execution_mode → execution=
|
||||
- auto_save → memory=MemoryConfig(auto_save=)
|
||||
- rate_limiter → execution=ExecutionConfig(rate_limiter=)
|
||||
- verification_hooks → autonomy=AutonomyConfig(verification_hooks=)
|
||||
"""
|
||||
# Add check at start if memory is requested
|
||||
if memory is not None:
|
||||
@@ -584,6 +591,48 @@ class Agent:
|
||||
# AutonomyConfig is in agent/autonomy.py - use dict for config defaults
|
||||
autonomy = apply_config_defaults("autonomy", autonomy, None)
|
||||
|
||||
# ============================================================
|
||||
# DEPRECATION WARNINGS for params consolidated into configs
|
||||
# Old params still work but emit warnings pointing to new API
|
||||
# ============================================================
|
||||
import warnings as _warnings
|
||||
|
||||
if allow_delegation:
|
||||
_warnings.warn(
|
||||
"Parameter 'allow_delegation' is deprecated. Use 'handoffs=[other_agent]' instead.",
|
||||
DeprecationWarning, stacklevel=2,
|
||||
)
|
||||
if allow_code_execution:
|
||||
_warnings.warn(
|
||||
"Parameter 'allow_code_execution' is deprecated. "
|
||||
"Use 'execution=ExecutionConfig(code_execution=True)' instead.",
|
||||
DeprecationWarning, stacklevel=2,
|
||||
)
|
||||
if code_execution_mode != "safe":
|
||||
_warnings.warn(
|
||||
"Parameter 'code_execution_mode' is deprecated. "
|
||||
"Use 'execution=ExecutionConfig(code_mode=\"unsafe\")' instead.",
|
||||
DeprecationWarning, stacklevel=2,
|
||||
)
|
||||
if auto_save is not None:
|
||||
_warnings.warn(
|
||||
"Parameter 'auto_save' is deprecated. "
|
||||
"Use 'memory=MemoryConfig(auto_save=\"name\")' instead.",
|
||||
DeprecationWarning, stacklevel=2,
|
||||
)
|
||||
if rate_limiter is not None:
|
||||
_warnings.warn(
|
||||
"Parameter 'rate_limiter' is deprecated. "
|
||||
"Use 'execution=ExecutionConfig(rate_limiter=obj)' instead.",
|
||||
DeprecationWarning, stacklevel=2,
|
||||
)
|
||||
if verification_hooks is not None:
|
||||
_warnings.warn(
|
||||
"Parameter 'verification_hooks' is deprecated. "
|
||||
"Use 'autonomy=AutonomyConfig(verification_hooks=[...])' instead.",
|
||||
DeprecationWarning, stacklevel=2,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# CONSOLIDATED PARAMS EXTRACTION (agent-centric API)
|
||||
# Uses unified resolver: Instance > Config > Array > String > Bool > Default
|
||||
@@ -730,6 +779,13 @@ class Agent:
|
||||
max_rpm = _exec_config.max_rpm
|
||||
max_execution_time = _exec_config.max_execution_time
|
||||
max_retry_limit = _exec_config.max_retry_limit
|
||||
# Extract consolidated fields (config takes precedence over deprecated standalone params)
|
||||
if _exec_config.rate_limiter is not None:
|
||||
rate_limiter = _exec_config.rate_limiter
|
||||
if _exec_config.code_execution:
|
||||
allow_code_execution = True
|
||||
if _exec_config.code_mode != "safe":
|
||||
code_execution_mode = _exec_config.code_mode
|
||||
else:
|
||||
max_iter, max_rpm, max_execution_time, max_retry_limit = 20, None, None, 2
|
||||
|
||||
@@ -879,6 +935,9 @@ class Agent:
|
||||
db = _memory_config.db
|
||||
auto_memory = _memory_config.auto_memory
|
||||
claude_memory = _memory_config.claude_memory
|
||||
# Extract auto_save from MemoryConfig (takes precedence over standalone param)
|
||||
if _memory_config.auto_save is not None:
|
||||
auto_save = _memory_config.auto_save
|
||||
# Convert to internal format
|
||||
backend = _memory_config.backend
|
||||
if hasattr(backend, 'value'):
|
||||
@@ -911,11 +970,24 @@ class Agent:
|
||||
if _memory_config.history:
|
||||
_history_enabled = True
|
||||
_history_limit = _memory_config.history_limit
|
||||
# Use explicit session_id from MemoryConfig if provided
|
||||
if _memory_config.session_id:
|
||||
_history_session_id = _memory_config.session_id
|
||||
|
||||
# Use auto_save session if no explicit session and history is enabled
|
||||
if _history_enabled and _history_session_id is None and auto_save:
|
||||
_history_session_id = auto_save
|
||||
|
||||
# Auto-generate session_id when history=True but no session_id set
|
||||
# This ensures _init_session_store() can create the store
|
||||
if _history_enabled and session_id is None and _history_session_id is not None:
|
||||
session_id = _history_session_id
|
||||
elif _history_enabled and session_id is None and _history_session_id is None:
|
||||
import hashlib as _hl
|
||||
_agent_hash = _hl.md5(name.encode()).hexdigest()[:8]
|
||||
session_id = f"history_{_agent_hash}"
|
||||
_history_session_id = session_id
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve KNOWLEDGE param - FAST PATH
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1357,6 +1429,16 @@ Your Goal: {self.goal}
|
||||
self._guardrail_fn = None
|
||||
self._setup_guardrail()
|
||||
|
||||
# Initialize approval backend (agent-centric approval)
|
||||
# True = AutoApproveBackend, False/None = registry fallback, object = custom backend
|
||||
if approval is True:
|
||||
from ..approval.backends import AutoApproveBackend
|
||||
self._approval_backend = AutoApproveBackend()
|
||||
elif approval is False or approval is None:
|
||||
self._approval_backend = None
|
||||
else:
|
||||
self._approval_backend = approval
|
||||
|
||||
# Cache for system prompts and formatted tools with lazy thread-safe lock
|
||||
self._system_prompt_cache = {}
|
||||
self._formatted_tools_cache = {}
|
||||
@@ -1835,6 +1917,9 @@ Summary:"""
|
||||
"auto_escalate": autonomy.auto_escalate,
|
||||
}
|
||||
config = autonomy
|
||||
# Extract verification_hooks from AutonomyConfig if provided
|
||||
if autonomy.verification_hooks and not verification_hooks:
|
||||
self._verification_hooks = autonomy.verification_hooks
|
||||
else:
|
||||
self.autonomy_enabled = False
|
||||
self.autonomy_config = {}
|
||||
@@ -2746,7 +2831,7 @@ Summary:"""
|
||||
user_id=mem_user_id,
|
||||
verbose=1 if getattr(self, 'verbose', False) else 0
|
||||
)
|
||||
elif isinstance(memory, str) and memory in ("sqlite", "chromadb", "mem0", "mongodb"):
|
||||
elif isinstance(memory, str) and memory in ("sqlite", "chromadb", "mem0", "mongodb", "redis", "postgres"):
|
||||
# Use full Memory class with specific provider
|
||||
try:
|
||||
from ..memory.memory import Memory
|
||||
@@ -2784,6 +2869,11 @@ Summary:"""
|
||||
logging.warning("Full Memory class requires additional dependencies. Falling back to FileMemory.")
|
||||
from ..memory.file_memory import FileMemory
|
||||
self._memory_instance = FileMemory(user_id=mem_user_id)
|
||||
elif isinstance(memory, str):
|
||||
# Unknown string backend - fall back to FileMemory with warning
|
||||
logging.warning(f"Unknown memory backend '{memory}'. Falling back to FileMemory.")
|
||||
from ..memory.file_memory import FileMemory
|
||||
self._memory_instance = FileMemory(user_id=mem_user_id)
|
||||
else:
|
||||
# Assume it's already a memory instance
|
||||
self._memory_instance = memory
|
||||
@@ -3974,6 +4064,12 @@ Your Goal: {self.goal}"""
|
||||
execution_time_ms=(time.time() - start_time) * 1000
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.AFTER_AGENT, after_agent_input)
|
||||
|
||||
# Auto-memory extraction (opt-in via MemoryConfig(auto_memory=True))
|
||||
if response:
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._process_auto_memory(prompt_str, str(response))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -4041,39 +4137,46 @@ Your Goal: {self.goal}"""
|
||||
def _execute_tool_impl(self, function_name, arguments):
|
||||
"""Internal tool execution implementation."""
|
||||
|
||||
# Check if approval is required for this tool
|
||||
from ..approval import is_approval_required, console_approval_callback, get_risk_level, mark_approved, get_approval_callback, is_env_auto_approve, is_yaml_approved
|
||||
if is_approval_required(function_name):
|
||||
# Skip approval if auto-approve env var is set or tool is YAML-approved
|
||||
if is_env_auto_approve() or is_yaml_approved(function_name):
|
||||
logging.debug(f"Tool {function_name} auto-approved (env={is_env_auto_approve()}, yaml={is_yaml_approved(function_name)})")
|
||||
mark_approved(function_name)
|
||||
# Check if approval is required for this tool (protocol-driven)
|
||||
# Priority: self._approval_backend (agent param) > registry (global/per-agent)
|
||||
from ..approval import get_approval_registry
|
||||
try:
|
||||
backend = getattr(self, '_approval_backend', None)
|
||||
if backend is not None:
|
||||
# Agent-level approval backend — bypass registry, call directly
|
||||
from ..approval.protocols import ApprovalRequest
|
||||
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
|
||||
if function_name in DEFAULT_DANGEROUS_TOOLS:
|
||||
request = ApprovalRequest(
|
||||
tool_name=function_name,
|
||||
arguments=arguments,
|
||||
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
|
||||
agent_name=getattr(self, 'name', None),
|
||||
)
|
||||
if hasattr(backend, 'request_approval_sync'):
|
||||
decision = backend.request_approval_sync(request)
|
||||
else:
|
||||
import asyncio
|
||||
decision = asyncio.run(backend.request_approval(request))
|
||||
else:
|
||||
from ..approval.protocols import ApprovalDecision
|
||||
decision = ApprovalDecision(approved=True, reason="No approval required")
|
||||
else:
|
||||
risk_level = get_risk_level(function_name)
|
||||
logging.debug(f"Tool {function_name} requires approval (risk level: {risk_level})")
|
||||
|
||||
# Use global approval callback or default console callback
|
||||
callback = get_approval_callback() or console_approval_callback
|
||||
|
||||
try:
|
||||
decision = callback(function_name, arguments, risk_level)
|
||||
if not decision.approved:
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
|
||||
# Mark as approved in context to prevent double approval in decorator
|
||||
mark_approved(function_name)
|
||||
|
||||
# Use modified arguments if provided
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error during approval process: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg, "approval_error": True}
|
||||
# Fallback to global registry
|
||||
decision = get_approval_registry().approve_sync(
|
||||
getattr(self, 'name', None), function_name, arguments,
|
||||
)
|
||||
if not decision.approved:
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
except Exception as e:
|
||||
error_msg = f"Error during approval process: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg, "approval_error": True}
|
||||
|
||||
# Special handling for MCP tools
|
||||
# Check if tools is an MCP instance with the requested function name
|
||||
@@ -6416,6 +6519,36 @@ Write the complete compiled report:"""
|
||||
"""
|
||||
pass
|
||||
|
||||
def _process_auto_memory(self, user_message: str, assistant_response: str):
|
||||
"""Process auto-memory extraction after agent response.
|
||||
|
||||
Called after each agent response when auto_memory=True in MemoryConfig.
|
||||
Uses AutoMemory to extract and store memorable content (names, preferences,
|
||||
facts) from the conversation. No-op when auto_memory is disabled.
|
||||
|
||||
Args:
|
||||
user_message: The user's input message
|
||||
assistant_response: The agent's response
|
||||
"""
|
||||
if not self._auto_memory or not self._memory_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
from ..memory.auto_memory import AutoMemory
|
||||
# Lazy-create AutoMemory wrapper on first use
|
||||
if not hasattr(self, '_auto_memory_instance') or self._auto_memory_instance is None:
|
||||
self._auto_memory_instance = AutoMemory(
|
||||
self._memory_instance,
|
||||
enabled=True,
|
||||
verbose=1 if getattr(self, 'verbose', False) else 0
|
||||
)
|
||||
self._auto_memory_instance.process_interaction(
|
||||
user_message=str(user_message),
|
||||
assistant_response=str(assistant_response),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Auto-memory extraction failed: {e}")
|
||||
|
||||
def _auto_save_session(self):
|
||||
"""Auto-save session if auto_save is enabled."""
|
||||
if not self.auto_save or not self._memory_instance:
|
||||
@@ -6765,24 +6898,40 @@ Write the complete compiled report:"""
|
||||
try:
|
||||
logging.info(f"Executing async tool: {function_name} with arguments: {arguments}")
|
||||
|
||||
# Check if approval is required for this tool
|
||||
from ..approval import is_approval_required, request_approval, is_env_auto_approve, is_yaml_approved, mark_approved
|
||||
if is_approval_required(function_name):
|
||||
# Skip approval if auto-approve env var is set or tool is YAML-approved
|
||||
if is_env_auto_approve() or is_yaml_approved(function_name):
|
||||
logging.debug(f"Tool {function_name} auto-approved (env={is_env_auto_approve()}, yaml={is_yaml_approved(function_name)})")
|
||||
mark_approved(function_name)
|
||||
# Check if approval is required for this tool (protocol-driven)
|
||||
# Priority: self._approval_backend (agent param) > registry (global/per-agent)
|
||||
from ..approval import get_approval_registry
|
||||
try:
|
||||
backend = getattr(self, '_approval_backend', None)
|
||||
if backend is not None:
|
||||
from ..approval.protocols import ApprovalRequest
|
||||
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
|
||||
if function_name in DEFAULT_DANGEROUS_TOOLS:
|
||||
request = ApprovalRequest(
|
||||
tool_name=function_name,
|
||||
arguments=arguments,
|
||||
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
|
||||
agent_name=getattr(self, 'name', None),
|
||||
)
|
||||
decision = await backend.request_approval(request)
|
||||
else:
|
||||
from ..approval.protocols import ApprovalDecision
|
||||
decision = ApprovalDecision(approved=True, reason="No approval required")
|
||||
else:
|
||||
decision = await request_approval(function_name, arguments)
|
||||
if not decision.approved:
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
|
||||
# Use modified arguments if provided
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
decision = await get_approval_registry().approve_async(
|
||||
getattr(self, 'name', None), function_name, arguments,
|
||||
)
|
||||
if not decision.approved:
|
||||
error_msg = f"Tool execution denied: {decision.reason}"
|
||||
logging.warning(error_msg)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
except Exception as e:
|
||||
error_msg = f"Error during approval process: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg, "approval_error": True}
|
||||
|
||||
# Try to find the function in the agent's tools list first
|
||||
func = None
|
||||
|
||||
@@ -55,6 +55,7 @@ class AutonomyConfig:
|
||||
observe: Whether to emit observability events
|
||||
completion_promise: Optional string that signals completion when wrapped in <promise>TEXT</promise>
|
||||
clear_context: Whether to clear chat history between iterations
|
||||
verification_hooks: List of VerificationHook instances for output verification
|
||||
"""
|
||||
enabled: bool = True
|
||||
max_iterations: int = 20
|
||||
@@ -63,6 +64,7 @@ class AutonomyConfig:
|
||||
observe: bool = False
|
||||
completion_promise: Optional[str] = None
|
||||
clear_context: bool = False
|
||||
verification_hooks: Optional[List[Any]] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "AutonomyConfig":
|
||||
@@ -75,6 +77,7 @@ class AutonomyConfig:
|
||||
observe=data.get("observe", False),
|
||||
completion_promise=data.get("completion_promise"),
|
||||
clear_context=data.get("clear_context", False),
|
||||
verification_hooks=data.get("verification_hooks"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -462,17 +462,23 @@ DO NOT use strings for tasks. Each task MUST be a complete object with all four
|
||||
# Use consolidated knowledge if provided, else fall back to legacy knowledge_sources
|
||||
effective_knowledge = self._knowledge if self._knowledge is not None else self.knowledge_sources
|
||||
|
||||
# Build execution config merging legacy code_execution params
|
||||
_effective_execution = self._execution
|
||||
if _effective_execution is None and (self.allow_code_execution or self.code_execution_mode != "safe"):
|
||||
from ..config.feature_configs import ExecutionConfig
|
||||
_effective_execution = ExecutionConfig(
|
||||
code_execution=bool(self.allow_code_execution),
|
||||
code_mode=self.code_execution_mode,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name=agent_config.name,
|
||||
role=agent_config.role,
|
||||
goal=agent_config.goal,
|
||||
backstory=agent_config.backstory,
|
||||
tools=agent_tools, # Use assigned tools
|
||||
allow_code_execution=self.allow_code_execution,
|
||||
memory=self.memory,
|
||||
llm=self.llm, # Consolidated LLM param
|
||||
code_execution_mode=self.code_execution_mode,
|
||||
allow_delegation=self.allow_delegation,
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
# Consolidated params (new SDK contract)
|
||||
@@ -480,7 +486,7 @@ DO NOT use strings for tasks. Each task MUST be a complete object with all four
|
||||
reflection=self._reflection, # Overrides self_reflect/max_reflect/min_reflect/reflect_llm
|
||||
caching=self._caching, # Overrides cache
|
||||
knowledge=effective_knowledge, # Overrides knowledge_sources/embedder_config
|
||||
execution=self._execution, # Overrides max_iter
|
||||
execution=_effective_execution, # Overrides max_iter + code execution
|
||||
guardrails=self._guardrails,
|
||||
web=self._web,
|
||||
hooks=self._hooks,
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
"""
|
||||
Human Approval Framework for PraisonAI Agents
|
||||
|
||||
This module provides a minimal human-in-the-loop approval system for dangerous tool operations.
|
||||
It extends the existing callback system to require human approval before executing high-risk tools.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Set, Optional, Callable, Any, Literal, List
|
||||
from functools import wraps
|
||||
import contextvars
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Lazy import for rich components
|
||||
_rich_console = None
|
||||
_rich_panel = None
|
||||
_rich_confirm = None
|
||||
|
||||
def _get_rich_console():
|
||||
"""Lazy import rich Console."""
|
||||
global _rich_console
|
||||
if _rich_console is None:
|
||||
from rich.console import Console
|
||||
_rich_console = Console
|
||||
return _rich_console
|
||||
|
||||
def _get_rich_panel():
|
||||
"""Lazy import rich Panel."""
|
||||
global _rich_panel
|
||||
if _rich_panel is None:
|
||||
from rich.panel import Panel
|
||||
_rich_panel = Panel
|
||||
return _rich_panel
|
||||
|
||||
def _get_rich_confirm():
|
||||
"""Lazy import rich Confirm."""
|
||||
global _rich_confirm
|
||||
if _rich_confirm is None:
|
||||
from rich.prompt import Confirm
|
||||
_rich_confirm = Confirm
|
||||
return _rich_confirm
|
||||
|
||||
# Global registries for approval requirements
|
||||
APPROVAL_REQUIRED_TOOLS: Set[str] = set()
|
||||
TOOL_RISK_LEVELS: Dict[str, str] = {}
|
||||
|
||||
# Risk levels
|
||||
RiskLevel = Literal["critical", "high", "medium", "low"]
|
||||
|
||||
# Global approval callback
|
||||
approval_callback: Optional[Callable] = None
|
||||
|
||||
# Context variable to track if we're in an approved execution context
|
||||
_approved_context: ContextVar[Set[str]] = ContextVar('approved_context', default=set())
|
||||
|
||||
# Context variable for YAML-defined auto-approved tools (from agents.yaml approve field)
|
||||
_yaml_approved_tools: ContextVar[Set[str]] = ContextVar('yaml_approved_tools', default=set())
|
||||
|
||||
# Global permission allowlist
|
||||
_permission_allowlist: Optional["PermissionAllowlist"] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolPermission:
|
||||
"""Permission entry for a tool."""
|
||||
tool_name: str
|
||||
allowed_paths: List[str] = field(default_factory=list)
|
||||
session_only: bool = False # If True, permission expires with session
|
||||
|
||||
|
||||
class PermissionAllowlist:
|
||||
"""
|
||||
Persistent permission allowlist for tools.
|
||||
|
||||
Allows pre-approving tools and paths to skip interactive approval.
|
||||
Can be saved/loaded from JSON for persistence across sessions.
|
||||
|
||||
Usage:
|
||||
allowlist = PermissionAllowlist()
|
||||
allowlist.add_tool("read_file")
|
||||
allowlist.add_tool("write_file", paths=["./src", "./tests"])
|
||||
|
||||
if allowlist.is_allowed("read_file"):
|
||||
# Skip approval prompt
|
||||
pass
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, ToolPermission] = {}
|
||||
self._session_tools: Set[str] = set()
|
||||
|
||||
def add_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
paths: Optional[List[str]] = None,
|
||||
session_only: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Add a tool to the allowlist.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to allow
|
||||
paths: Optional list of allowed paths (empty = all paths)
|
||||
session_only: If True, permission expires with session
|
||||
"""
|
||||
self._tools[tool_name] = ToolPermission(
|
||||
tool_name=tool_name,
|
||||
allowed_paths=paths or [],
|
||||
session_only=session_only
|
||||
)
|
||||
if session_only:
|
||||
self._session_tools.add(tool_name)
|
||||
|
||||
def remove_tool(self, tool_name: str) -> bool:
|
||||
"""Remove a tool from the allowlist."""
|
||||
if tool_name in self._tools:
|
||||
del self._tools[tool_name]
|
||||
self._session_tools.discard(tool_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_allowed(self, tool_name: str, path: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if a tool is allowed.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
path: Optional path to check against allowed paths
|
||||
|
||||
Returns:
|
||||
True if tool is allowed (optionally for the given path)
|
||||
"""
|
||||
if tool_name not in self._tools:
|
||||
return False
|
||||
|
||||
permission = self._tools[tool_name]
|
||||
|
||||
# If no paths specified, tool is allowed for all paths
|
||||
if not permission.allowed_paths:
|
||||
return True
|
||||
|
||||
# If path not provided, check if tool has any permissions
|
||||
if path is None:
|
||||
return True
|
||||
|
||||
# Check if path matches any allowed path
|
||||
for allowed_path in permission.allowed_paths:
|
||||
# Normalize paths for comparison
|
||||
norm_allowed = os.path.normpath(allowed_path)
|
||||
norm_path = os.path.normpath(path)
|
||||
|
||||
# Check if path starts with allowed path
|
||||
if norm_path.startswith(norm_allowed):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Check if allowlist is empty."""
|
||||
return len(self._tools) == 0
|
||||
|
||||
def list_tools(self) -> List[str]:
|
||||
"""List all allowed tools."""
|
||||
return list(self._tools.keys())
|
||||
|
||||
def clear_session_permissions(self) -> None:
|
||||
"""Clear session-only permissions."""
|
||||
for tool_name in list(self._session_tools):
|
||||
if tool_name in self._tools:
|
||||
del self._tools[tool_name]
|
||||
self._session_tools.clear()
|
||||
|
||||
def save(self, filepath: str) -> None:
|
||||
"""Save allowlist to JSON file."""
|
||||
data = {
|
||||
"tools": {
|
||||
name: {
|
||||
"allowed_paths": perm.allowed_paths,
|
||||
"session_only": perm.session_only
|
||||
}
|
||||
for name, perm in self._tools.items()
|
||||
if not perm.session_only # Don't persist session-only permissions
|
||||
}
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, filepath: str) -> "PermissionAllowlist":
|
||||
"""Load allowlist from JSON file."""
|
||||
allowlist = cls()
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return allowlist
|
||||
|
||||
with open(filepath, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
for tool_name, tool_data in data.get("tools", {}).items():
|
||||
allowlist.add_tool(
|
||||
tool_name,
|
||||
paths=tool_data.get("allowed_paths", []),
|
||||
session_only=tool_data.get("session_only", False)
|
||||
)
|
||||
|
||||
return allowlist
|
||||
|
||||
|
||||
def get_permission_allowlist() -> PermissionAllowlist:
|
||||
"""Get the global permission allowlist."""
|
||||
global _permission_allowlist
|
||||
if _permission_allowlist is None:
|
||||
_permission_allowlist = PermissionAllowlist()
|
||||
return _permission_allowlist
|
||||
|
||||
|
||||
def set_permission_allowlist(allowlist: PermissionAllowlist) -> None:
|
||||
"""Set the global permission allowlist."""
|
||||
global _permission_allowlist
|
||||
_permission_allowlist = allowlist
|
||||
|
||||
|
||||
class ApprovalDecision:
|
||||
"""Result of an approval request"""
|
||||
def __init__(self, approved: bool, modified_args: Optional[Dict[str, Any]] = None, reason: str = ""):
|
||||
self.approved = approved
|
||||
self.modified_args = modified_args or {}
|
||||
self.reason = reason
|
||||
|
||||
def set_approval_callback(callback_fn: Callable):
|
||||
"""Set a custom approval callback function.
|
||||
|
||||
The callback should accept (function_name, arguments, risk_level) and return ApprovalDecision.
|
||||
"""
|
||||
global approval_callback
|
||||
approval_callback = callback_fn
|
||||
|
||||
def get_approval_callback() -> Optional[Callable]:
|
||||
"""Get the current approval callback function.
|
||||
|
||||
Returns the custom callback if set, otherwise None.
|
||||
This should be used instead of directly accessing approval_callback
|
||||
to ensure the latest callback is always used.
|
||||
"""
|
||||
return approval_callback
|
||||
|
||||
def mark_approved(tool_name: str):
|
||||
"""Mark a tool as approved in the current context."""
|
||||
approved = _approved_context.get(set())
|
||||
approved.add(tool_name)
|
||||
_approved_context.set(approved)
|
||||
|
||||
def is_already_approved(tool_name: str) -> bool:
|
||||
"""Check if a tool is already approved in the current context."""
|
||||
approved = _approved_context.get(set())
|
||||
return tool_name in approved
|
||||
|
||||
|
||||
def is_yaml_approved(tool_name: str) -> bool:
|
||||
"""Check if a tool is auto-approved via YAML approve field."""
|
||||
try:
|
||||
yaml_approved = _yaml_approved_tools.get()
|
||||
return tool_name in yaml_approved
|
||||
except LookupError:
|
||||
return False
|
||||
|
||||
|
||||
def is_env_auto_approve() -> bool:
|
||||
"""Check if PRAISONAI_AUTO_APPROVE environment variable is set."""
|
||||
return os.environ.get("PRAISONAI_AUTO_APPROVE", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def set_yaml_approved_tools(tools: List[str]) -> contextvars.Token:
|
||||
"""
|
||||
Set the list of YAML-approved tools for the current context.
|
||||
|
||||
This is called by the workflow runner when parsing agents.yaml with approve field.
|
||||
|
||||
Args:
|
||||
tools: List of tool names to auto-approve
|
||||
|
||||
Returns:
|
||||
Token that can be used to reset the context
|
||||
"""
|
||||
return _yaml_approved_tools.set(set(tools))
|
||||
|
||||
|
||||
def reset_yaml_approved_tools(token: contextvars.Token) -> None:
|
||||
"""Reset YAML-approved tools to previous state."""
|
||||
_yaml_approved_tools.reset(token)
|
||||
|
||||
|
||||
def clear_approval_context():
|
||||
"""Clear the approval context."""
|
||||
_approved_context.set(set())
|
||||
|
||||
def require_approval(risk_level: RiskLevel = "high"):
|
||||
"""Decorator to mark a tool as requiring human approval.
|
||||
|
||||
Args:
|
||||
risk_level: The risk level of the tool ("critical", "high", "medium", "low")
|
||||
"""
|
||||
def decorator(func):
|
||||
tool_name = getattr(func, '__name__', str(func))
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Skip approval if already approved in current context
|
||||
if is_already_approved(tool_name):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# Skip approval if tool is auto-approved via YAML approve field (primary)
|
||||
if is_yaml_approved(tool_name):
|
||||
mark_approved(tool_name)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# Skip approval if PRAISONAI_AUTO_APPROVE env var is set (secondary)
|
||||
if is_env_auto_approve():
|
||||
mark_approved(tool_name)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# Request approval before executing the function
|
||||
try:
|
||||
# Try to check if we're in an async context
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# We're in an async context, but this is a sync function
|
||||
# Fall back to sync approval to avoid loop conflicts
|
||||
raise RuntimeError("Use sync fallback in async context")
|
||||
except RuntimeError:
|
||||
# Either no running loop or we want sync fallback
|
||||
# Use asyncio.run for clean async execution
|
||||
decision = asyncio.run(request_approval(tool_name, kwargs))
|
||||
except Exception as e:
|
||||
# Fallback to sync approval if async fails
|
||||
logging.warning(f"Async approval failed, using sync fallback: {e}")
|
||||
callback = get_approval_callback() or console_approval_callback
|
||||
decision = callback(tool_name, kwargs, risk_level)
|
||||
|
||||
if not decision.approved:
|
||||
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
|
||||
|
||||
# Mark as approved and merge modified args
|
||||
mark_approved(tool_name)
|
||||
kwargs.update(decision.modified_args)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
# Skip approval if already approved in current context
|
||||
if is_already_approved(tool_name):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Skip approval if tool is auto-approved via YAML approve field (primary)
|
||||
if is_yaml_approved(tool_name):
|
||||
mark_approved(tool_name)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Skip approval if PRAISONAI_AUTO_APPROVE env var is set (secondary)
|
||||
if is_env_auto_approve():
|
||||
mark_approved(tool_name)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Request approval before executing the function
|
||||
decision = await request_approval(tool_name, kwargs)
|
||||
if not decision.approved:
|
||||
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
|
||||
|
||||
# Mark as approved and merge modified args
|
||||
mark_approved(tool_name)
|
||||
kwargs.update(decision.modified_args)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Return the appropriate wrapper based on function type
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
def console_approval_callback(function_name: str, arguments: Dict[str, Any], risk_level: str) -> ApprovalDecision:
|
||||
"""Default console-based approval callback.
|
||||
|
||||
Displays tool information and prompts user for approval via console.
|
||||
"""
|
||||
Console = _get_rich_console()
|
||||
Panel = _get_rich_panel()
|
||||
Confirm = _get_rich_confirm()
|
||||
|
||||
console = Console()
|
||||
|
||||
# Create risk level styling
|
||||
risk_colors = {
|
||||
"critical": "bold red",
|
||||
"high": "red",
|
||||
"medium": "yellow",
|
||||
"low": "blue"
|
||||
}
|
||||
risk_color = risk_colors.get(risk_level, "white")
|
||||
|
||||
# Display tool information
|
||||
tool_info = f"[bold]Function:[/] {function_name}\n"
|
||||
tool_info += f"[bold]Risk Level:[/] [{risk_color}]{risk_level.upper()}[/{risk_color}]\n"
|
||||
tool_info += f"[bold]Arguments:[/]\n"
|
||||
|
||||
for key, value in arguments.items():
|
||||
# Truncate long values for display
|
||||
str_value = str(value)
|
||||
if len(str_value) > 100:
|
||||
str_value = str_value[:97] + "..."
|
||||
tool_info += f" {key}: {str_value}\n"
|
||||
|
||||
console.print(Panel(
|
||||
tool_info.strip(),
|
||||
title="🔒 Tool Approval Required",
|
||||
border_style=risk_color,
|
||||
title_align="left"
|
||||
))
|
||||
|
||||
# Get user approval
|
||||
try:
|
||||
approved = Confirm.ask(
|
||||
f"[{risk_color}]Do you want to execute this {risk_level} risk tool?[/{risk_color}]",
|
||||
default=False
|
||||
)
|
||||
|
||||
if approved:
|
||||
console.print("[green]✅ Tool execution approved[/green]")
|
||||
return ApprovalDecision(approved=True, reason="User approved")
|
||||
else:
|
||||
console.print("[red]❌ Tool execution denied[/red]")
|
||||
return ApprovalDecision(approved=False, reason="User denied")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[red]❌ Tool execution cancelled by user[/red]")
|
||||
return ApprovalDecision(approved=False, reason="User cancelled")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error during approval: {e}[/red]")
|
||||
return ApprovalDecision(approved=False, reason=f"Approval error: {e}")
|
||||
|
||||
async def request_approval(function_name: str, arguments: Dict[str, Any]) -> ApprovalDecision:
|
||||
"""Request approval for a tool execution.
|
||||
|
||||
Args:
|
||||
function_name: Name of the function to execute
|
||||
arguments: Arguments to pass to the function
|
||||
|
||||
Returns:
|
||||
ApprovalDecision with approval status and any modifications
|
||||
"""
|
||||
# Check if approval is required
|
||||
if function_name not in APPROVAL_REQUIRED_TOOLS:
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
risk_level = TOOL_RISK_LEVELS.get(function_name, "medium")
|
||||
|
||||
# Use custom callback if set, otherwise use console callback
|
||||
callback = get_approval_callback() or console_approval_callback
|
||||
|
||||
try:
|
||||
# Handle async callbacks
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
decision = await callback(function_name, arguments, risk_level)
|
||||
else:
|
||||
# Run sync callback in executor to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
decision = await loop.run_in_executor(None, callback, function_name, arguments, risk_level)
|
||||
|
||||
return decision
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in approval callback: {e}")
|
||||
return ApprovalDecision(approved=False, reason=f"Approval callback error: {e}")
|
||||
|
||||
# Default dangerous tools - can be configured at runtime
|
||||
DEFAULT_DANGEROUS_TOOLS = {
|
||||
# Critical risk tools
|
||||
"execute_command": "critical",
|
||||
"kill_process": "critical",
|
||||
"execute_code": "critical",
|
||||
|
||||
# High risk tools
|
||||
"write_file": "high",
|
||||
"delete_file": "high",
|
||||
"move_file": "high",
|
||||
"copy_file": "high",
|
||||
"execute_query": "high",
|
||||
|
||||
# Medium risk tools
|
||||
"evaluate": "medium",
|
||||
"crawl": "medium",
|
||||
"scrape_page": "medium",
|
||||
}
|
||||
|
||||
def configure_default_approvals():
|
||||
"""Configure default dangerous tools to require approval."""
|
||||
for tool_name, risk_level in DEFAULT_DANGEROUS_TOOLS.items():
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
|
||||
def add_approval_requirement(tool_name: str, risk_level: RiskLevel = "high"):
|
||||
"""Dynamically add approval requirement for a tool."""
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
|
||||
def remove_approval_requirement(tool_name: str):
|
||||
"""Remove approval requirement for a tool."""
|
||||
APPROVAL_REQUIRED_TOOLS.discard(tool_name)
|
||||
TOOL_RISK_LEVELS.pop(tool_name, None)
|
||||
|
||||
def is_approval_required(tool_name: str) -> bool:
|
||||
"""Check if a tool requires approval."""
|
||||
return tool_name in APPROVAL_REQUIRED_TOOLS
|
||||
|
||||
def get_risk_level(tool_name: str) -> Optional[str]:
|
||||
"""Get the risk level of a tool."""
|
||||
return TOOL_RISK_LEVELS.get(tool_name)
|
||||
|
||||
# Initialize with defaults
|
||||
configure_default_approvals()
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Extensible Approval Protocol for PraisonAI Agents.
|
||||
|
||||
This package provides a protocol-driven, per-agent approval system for
|
||||
tool executions. It is fully backward-compatible with the old
|
||||
``approval.py`` module — all existing imports continue to work.
|
||||
|
||||
Quick start (no Agent class changes needed)::
|
||||
|
||||
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
|
||||
|
||||
# Global auto-approve for all agents
|
||||
get_approval_registry().set_backend(AutoApproveBackend())
|
||||
|
||||
# Per-agent backend
|
||||
get_approval_registry().set_backend(AutoApproveBackend(), agent_name="bot")
|
||||
|
||||
Custom backend::
|
||||
|
||||
from praisonaiagents.approval import ApprovalProtocol, ApprovalRequest, ApprovalDecision
|
||||
|
||||
class WebhookBackend:
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
# POST to external service, wait for response
|
||||
return ApprovalDecision(approved=True)
|
||||
|
||||
get_approval_registry().set_backend(WebhookBackend())
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
from typing import Callable, Dict, List, Literal, Optional, Set
|
||||
|
||||
# ── New protocol-driven exports ──────────────────────────────────────────────
|
||||
|
||||
from .protocols import ApprovalProtocol, ApprovalRequest # noqa: F401
|
||||
from .protocols import ApprovalDecision # noqa: F401
|
||||
from .backends import AutoApproveBackend, ConsoleBackend, CallbackBackend, AgentApproval # noqa: F401
|
||||
from .registry import ApprovalRegistry, DEFAULT_DANGEROUS_TOOLS # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Singleton registry ───────────────────────────────────────────────────────
|
||||
|
||||
_registry: Optional[ApprovalRegistry] = None
|
||||
|
||||
|
||||
def get_approval_registry() -> ApprovalRegistry:
|
||||
"""Return the global singleton :class:`ApprovalRegistry`."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = ApprovalRegistry()
|
||||
return _registry
|
||||
|
||||
|
||||
# ── Backward-compatible API (delegates to registry) ─────────────────────────
|
||||
|
||||
# These globals are kept for code that imports them directly.
|
||||
APPROVAL_REQUIRED_TOOLS: Set[str] = get_approval_registry()._required_tools
|
||||
TOOL_RISK_LEVELS: Dict[str, str] = get_approval_registry()._risk_levels
|
||||
|
||||
# Legacy global callback holder — set_approval_callback wraps it into a backend
|
||||
approval_callback: Optional[Callable] = None
|
||||
|
||||
|
||||
def set_approval_callback(callback_fn: Optional[Callable]) -> None:
|
||||
"""Set a custom approval callback (legacy API).
|
||||
|
||||
The callback is wrapped into a :class:`CallbackBackend` and set as the
|
||||
global backend on the registry.
|
||||
"""
|
||||
global approval_callback
|
||||
approval_callback = callback_fn
|
||||
reg = get_approval_registry()
|
||||
if callback_fn is not None:
|
||||
reg.set_backend(CallbackBackend(callback_fn))
|
||||
else:
|
||||
reg.remove_backend()
|
||||
|
||||
|
||||
def get_approval_callback() -> Optional[Callable]:
|
||||
"""Get the current approval callback function (legacy API)."""
|
||||
return approval_callback
|
||||
|
||||
|
||||
def mark_approved(tool_name: str) -> None:
|
||||
get_approval_registry().mark_approved(tool_name)
|
||||
|
||||
|
||||
def is_already_approved(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_already_approved(tool_name)
|
||||
|
||||
|
||||
def is_yaml_approved(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_yaml_approved(tool_name)
|
||||
|
||||
|
||||
def is_env_auto_approve() -> bool:
|
||||
return ApprovalRegistry.is_env_auto_approve()
|
||||
|
||||
|
||||
def set_yaml_approved_tools(tools: List[str]) -> contextvars.Token:
|
||||
return get_approval_registry().set_yaml_approved_tools(tools)
|
||||
|
||||
|
||||
def reset_yaml_approved_tools(token: contextvars.Token) -> None:
|
||||
get_approval_registry().reset_yaml_approved_tools(token)
|
||||
|
||||
|
||||
def clear_approval_context() -> None:
|
||||
get_approval_registry().clear_approved()
|
||||
|
||||
|
||||
def configure_default_approvals() -> None:
|
||||
"""Re-configure default dangerous tools (idempotent)."""
|
||||
reg = get_approval_registry()
|
||||
for tool_name, risk_level in DEFAULT_DANGEROUS_TOOLS.items():
|
||||
reg.add_requirement(tool_name, risk_level)
|
||||
|
||||
|
||||
def add_approval_requirement(tool_name: str, risk_level: str = "high") -> None:
|
||||
get_approval_registry().add_requirement(tool_name, risk_level)
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
|
||||
|
||||
def remove_approval_requirement(tool_name: str) -> None:
|
||||
get_approval_registry().remove_requirement(tool_name)
|
||||
APPROVAL_REQUIRED_TOOLS.discard(tool_name)
|
||||
TOOL_RISK_LEVELS.pop(tool_name, None)
|
||||
|
||||
|
||||
def is_approval_required(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_required(tool_name)
|
||||
|
||||
|
||||
def get_risk_level(tool_name: str) -> Optional[str]:
|
||||
return get_approval_registry().get_risk_level(tool_name)
|
||||
|
||||
|
||||
# ── Legacy console callback (kept for direct callers) ───────────────────────
|
||||
|
||||
def console_approval_callback(function_name: str, arguments: Dict, risk_level: str) -> ApprovalDecision:
|
||||
"""Default console-based approval callback (legacy API)."""
|
||||
backend = ConsoleBackend()
|
||||
request = ApprovalRequest(
|
||||
tool_name=function_name,
|
||||
arguments=arguments,
|
||||
risk_level=risk_level,
|
||||
)
|
||||
return backend.request_approval_sync(request)
|
||||
|
||||
|
||||
# ── Legacy async request_approval ────────────────────────────────────────────
|
||||
|
||||
async def request_approval(function_name: str, arguments: Dict) -> ApprovalDecision:
|
||||
"""Request approval for a tool execution (legacy async API)."""
|
||||
return await get_approval_registry().approve_async(None, function_name, arguments)
|
||||
|
||||
|
||||
# ── require_approval decorator (unchanged semantics) ─────────────────────────
|
||||
|
||||
RiskLevel = Literal["critical", "high", "medium", "low"]
|
||||
|
||||
|
||||
def require_approval(risk_level: RiskLevel = "high"):
|
||||
"""Decorator to mark a tool as requiring human approval."""
|
||||
def decorator(func):
|
||||
tool_name = getattr(func, '__name__', str(func))
|
||||
reg = get_approval_registry()
|
||||
reg.add_requirement(tool_name, risk_level)
|
||||
APPROVAL_REQUIRED_TOOLS.add(tool_name)
|
||||
TOOL_RISK_LEVELS[tool_name] = risk_level
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if is_already_approved(tool_name):
|
||||
return func(*args, **kwargs)
|
||||
if is_yaml_approved(tool_name):
|
||||
mark_approved(tool_name)
|
||||
return func(*args, **kwargs)
|
||||
if is_env_auto_approve():
|
||||
mark_approved(tool_name)
|
||||
return func(*args, **kwargs)
|
||||
try:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
raise RuntimeError("Use sync fallback in async context")
|
||||
except RuntimeError:
|
||||
decision = asyncio.run(request_approval(tool_name, kwargs))
|
||||
except Exception as e:
|
||||
logging.warning(f"Async approval failed, using sync fallback: {e}")
|
||||
decision = console_approval_callback(tool_name, kwargs, risk_level)
|
||||
if not decision.approved:
|
||||
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
|
||||
mark_approved(tool_name)
|
||||
kwargs.update(decision.modified_args)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
if is_already_approved(tool_name):
|
||||
return await func(*args, **kwargs)
|
||||
if is_yaml_approved(tool_name):
|
||||
mark_approved(tool_name)
|
||||
return await func(*args, **kwargs)
|
||||
if is_env_auto_approve():
|
||||
mark_approved(tool_name)
|
||||
return await func(*args, **kwargs)
|
||||
decision = await request_approval(tool_name, kwargs)
|
||||
if not decision.approved:
|
||||
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
|
||||
mark_approved(tool_name)
|
||||
kwargs.update(decision.modified_args)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ── PermissionAllowlist (kept as-is) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolPermission:
|
||||
"""Permission entry for a tool."""
|
||||
tool_name: str
|
||||
allowed_paths: List[str] = field(default_factory=list)
|
||||
session_only: bool = False
|
||||
|
||||
|
||||
class PermissionAllowlist:
|
||||
"""Persistent permission allowlist for tools."""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, ToolPermission] = {}
|
||||
self._session_tools: Set[str] = set()
|
||||
|
||||
def add_tool(self, tool_name: str, paths: Optional[List[str]] = None, session_only: bool = False) -> None:
|
||||
self._tools[tool_name] = ToolPermission(tool_name=tool_name, allowed_paths=paths or [], session_only=session_only)
|
||||
if session_only:
|
||||
self._session_tools.add(tool_name)
|
||||
|
||||
def remove_tool(self, tool_name: str) -> bool:
|
||||
if tool_name in self._tools:
|
||||
del self._tools[tool_name]
|
||||
self._session_tools.discard(tool_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_allowed(self, tool_name: str, path: Optional[str] = None) -> bool:
|
||||
if tool_name not in self._tools:
|
||||
return False
|
||||
permission = self._tools[tool_name]
|
||||
if not permission.allowed_paths:
|
||||
return True
|
||||
if path is None:
|
||||
return True
|
||||
for allowed_path in permission.allowed_paths:
|
||||
norm_allowed = os.path.normpath(allowed_path)
|
||||
norm_path = os.path.normpath(path)
|
||||
if norm_path.startswith(norm_allowed):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return len(self._tools) == 0
|
||||
|
||||
def list_tools(self) -> List[str]:
|
||||
return list(self._tools.keys())
|
||||
|
||||
def clear_session_permissions(self) -> None:
|
||||
for tool_name in list(self._session_tools):
|
||||
if tool_name in self._tools:
|
||||
del self._tools[tool_name]
|
||||
self._session_tools.clear()
|
||||
|
||||
def save(self, filepath: str) -> None:
|
||||
data = {
|
||||
"tools": {
|
||||
name: {"allowed_paths": perm.allowed_paths, "session_only": perm.session_only}
|
||||
for name, perm in self._tools.items()
|
||||
if not perm.session_only
|
||||
}
|
||||
}
|
||||
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, filepath: str) -> "PermissionAllowlist":
|
||||
allowlist = cls()
|
||||
if not os.path.exists(filepath):
|
||||
return allowlist
|
||||
with open(filepath, "r") as f:
|
||||
data = json.load(f)
|
||||
for tool_name, tool_data in data.get("tools", {}).items():
|
||||
allowlist.add_tool(tool_name, paths=tool_data.get("allowed_paths", []), session_only=tool_data.get("session_only", False))
|
||||
return allowlist
|
||||
|
||||
|
||||
_permission_allowlist: Optional[PermissionAllowlist] = None
|
||||
|
||||
|
||||
def get_permission_allowlist() -> PermissionAllowlist:
|
||||
global _permission_allowlist
|
||||
if _permission_allowlist is None:
|
||||
_permission_allowlist = PermissionAllowlist()
|
||||
return _permission_allowlist
|
||||
|
||||
|
||||
def set_permission_allowlist(allowlist: PermissionAllowlist) -> None:
|
||||
global _permission_allowlist
|
||||
_permission_allowlist = allowlist
|
||||
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Built-in approval backends for PraisonAI Agents.
|
||||
|
||||
Provides lightweight backends that ship with the core SDK:
|
||||
|
||||
- **AutoApproveBackend** — always approves (bots, trusted envs).
|
||||
- **ConsoleBackend** — interactive Rich terminal prompt (CLI default).
|
||||
- **AgentApproval** — delegates approval decision to another AI agent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .protocols import ApprovalDecision, ApprovalRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy Rich imports (same pattern as old approval.py)
|
||||
_rich_console = None
|
||||
_rich_panel = None
|
||||
_rich_confirm = None
|
||||
|
||||
|
||||
def _get_rich_console():
|
||||
global _rich_console
|
||||
if _rich_console is None:
|
||||
from rich.console import Console
|
||||
_rich_console = Console
|
||||
return _rich_console
|
||||
|
||||
|
||||
def _get_rich_panel():
|
||||
global _rich_panel
|
||||
if _rich_panel is None:
|
||||
from rich.panel import Panel
|
||||
_rich_panel = Panel
|
||||
return _rich_panel
|
||||
|
||||
|
||||
def _get_rich_confirm():
|
||||
global _rich_confirm
|
||||
if _rich_confirm is None:
|
||||
from rich.prompt import Confirm
|
||||
_rich_confirm = Confirm
|
||||
return _rich_confirm
|
||||
|
||||
|
||||
class AutoApproveBackend:
|
||||
"""Always approves. Use for bots or trusted unattended environments."""
|
||||
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=True, reason="auto-approved", approver="system")
|
||||
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=True, reason="auto-approved", approver="system")
|
||||
|
||||
|
||||
class ConsoleBackend:
|
||||
"""Interactive Rich terminal prompt. Default for CLI usage."""
|
||||
|
||||
def _prompt_user(self, request: ApprovalRequest) -> bool:
|
||||
"""Show Rich panel and ask yes/no. Returns True if approved."""
|
||||
Console = _get_rich_console()
|
||||
Panel = _get_rich_panel()
|
||||
Confirm = _get_rich_confirm()
|
||||
|
||||
console = Console()
|
||||
|
||||
risk_colors = {
|
||||
"critical": "bold red",
|
||||
"high": "red",
|
||||
"medium": "yellow",
|
||||
"low": "blue",
|
||||
}
|
||||
risk_color = risk_colors.get(request.risk_level, "white")
|
||||
|
||||
tool_info = f"[bold]Function:[/] {request.tool_name}\n"
|
||||
tool_info += f"[bold]Risk Level:[/] [{risk_color}]{request.risk_level.upper()}[/{risk_color}]\n"
|
||||
if request.agent_name:
|
||||
tool_info += f"[bold]Agent:[/] {request.agent_name}\n"
|
||||
tool_info += "[bold]Arguments:[/]\n"
|
||||
for key, value in request.arguments.items():
|
||||
str_value = str(value)
|
||||
if len(str_value) > 100:
|
||||
str_value = str_value[:97] + "..."
|
||||
tool_info += f" {key}: {str_value}\n"
|
||||
|
||||
console.print(Panel(
|
||||
tool_info.strip(),
|
||||
title="🔒 Tool Approval Required",
|
||||
border_style=risk_color,
|
||||
title_align="left",
|
||||
))
|
||||
|
||||
try:
|
||||
return Confirm.ask(
|
||||
f"[{risk_color}]Do you want to execute this {request.risk_level} risk tool?[/{risk_color}]",
|
||||
default=False,
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return False
|
||||
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Synchronous approval via Rich console prompt."""
|
||||
try:
|
||||
approved = self._prompt_user(request)
|
||||
if approved:
|
||||
return ApprovalDecision(approved=True, reason="User approved", approver="console")
|
||||
return ApprovalDecision(approved=False, reason="User denied", approver="console")
|
||||
except Exception as e:
|
||||
logger.error("Console approval error: %s", e)
|
||||
return ApprovalDecision(approved=False, reason=f"Approval error: {e}")
|
||||
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Async wrapper — runs the sync prompt in an executor."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self.request_approval_sync, request)
|
||||
|
||||
|
||||
class AgentApproval:
|
||||
"""Delegates approval decisions to another AI agent.
|
||||
|
||||
The approver agent receives a structured prompt describing the tool call
|
||||
and responds with ``APPROVE`` or ``DENY``. This enables autonomous
|
||||
multi-agent approval workflows without human intervention.
|
||||
|
||||
Lives in the core SDK because it only depends on the Agent class which
|
||||
is already in core — no external dependencies.
|
||||
|
||||
Args:
|
||||
approver_agent: An Agent instance that will evaluate approval requests.
|
||||
If ``None``, a default approver agent is created with sensible
|
||||
instructions.
|
||||
llm: LLM model to use for the default approver agent (default ``gpt-4o-mini``).
|
||||
|
||||
Example::
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval import AgentApproval
|
||||
|
||||
approver = Agent(
|
||||
name="security-reviewer",
|
||||
instructions="Only approve low-risk read operations. Deny anything destructive.",
|
||||
)
|
||||
worker = Agent(
|
||||
name="worker",
|
||||
tools=[execute_command],
|
||||
approval=AgentApproval(approver_agent=approver),
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
approver_agent: Any = None,
|
||||
llm: str = "gpt-4o-mini",
|
||||
):
|
||||
self._approver_agent = approver_agent
|
||||
self._llm = llm
|
||||
|
||||
def __repr__(self) -> str:
|
||||
name = getattr(self._approver_agent, "name", None) or "default"
|
||||
return f"AgentApproval(approver={name!r})"
|
||||
|
||||
def _get_approver(self) -> Any:
|
||||
"""Lazily create or return the approver agent."""
|
||||
if self._approver_agent is not None:
|
||||
return self._approver_agent
|
||||
|
||||
# Lazy import to avoid circular dependency at module level
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
|
||||
self._approver_agent = Agent(
|
||||
name="approval-reviewer",
|
||||
instructions=(
|
||||
"You are a security reviewer for tool execution requests. "
|
||||
"Evaluate each request and respond with exactly one word: "
|
||||
"APPROVE or DENY. Consider the tool name, arguments, and risk level. "
|
||||
"Deny anything that looks destructive, dangerous, or unauthorized. "
|
||||
"Approve safe read-only operations."
|
||||
),
|
||||
llm=self._llm,
|
||||
)
|
||||
return self._approver_agent
|
||||
|
||||
def _build_prompt(self, request: ApprovalRequest) -> str:
|
||||
"""Build the evaluation prompt for the approver agent."""
|
||||
args_str = "\n".join(
|
||||
f" {k}: {v}" for k, v in request.arguments.items()
|
||||
) or " (none)"
|
||||
|
||||
return (
|
||||
f"Tool Approval Request:\n"
|
||||
f" Tool: {request.tool_name}\n"
|
||||
f" Risk Level: {request.risk_level.upper()}\n"
|
||||
f" Agent: {request.agent_name or 'unknown'}\n"
|
||||
f" Arguments:\n{args_str}\n\n"
|
||||
f"Respond with exactly one word: APPROVE or DENY"
|
||||
)
|
||||
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Ask the approver agent to evaluate the request."""
|
||||
try:
|
||||
approver = self._get_approver()
|
||||
prompt = self._build_prompt(request)
|
||||
|
||||
# Use the agent's chat method
|
||||
if hasattr(approver, "achat"):
|
||||
response = await approver.achat(prompt)
|
||||
elif hasattr(approver, "chat"):
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, approver.chat, prompt)
|
||||
else:
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason="Approver agent has no chat method",
|
||||
)
|
||||
|
||||
response_text = str(response).strip().upper()
|
||||
approved = "APPROVE" in response_text and "DENY" not in response_text
|
||||
|
||||
return ApprovalDecision(
|
||||
approved=approved,
|
||||
reason=f"Agent {'approved' if approved else 'denied'}: {str(response).strip()[:200]}",
|
||||
approver=getattr(approver, "name", "agent"),
|
||||
metadata={"platform": "agent", "response": str(response).strip()[:500]},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AgentApproval error: {e}")
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Agent approval error: {e}",
|
||||
)
|
||||
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Synchronous wrapper."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, self.request_approval(request))
|
||||
return future.result(timeout=60)
|
||||
else:
|
||||
return asyncio.run(self.request_approval(request))
|
||||
|
||||
|
||||
class CallbackBackend:
|
||||
"""Wraps a legacy ``(function_name, arguments, risk_level) -> ApprovalDecision`` callback
|
||||
into an :class:`ApprovalProtocol`-compatible backend.
|
||||
|
||||
Used internally by :func:`set_approval_callback` for backward compatibility.
|
||||
"""
|
||||
|
||||
def __init__(self, callback):
|
||||
self._callback = callback
|
||||
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
result = self._callback(request.tool_name, request.arguments, request.risk_level)
|
||||
if isinstance(result, ApprovalDecision):
|
||||
return result
|
||||
# Legacy callbacks may return dict-like or bool
|
||||
if isinstance(result, bool):
|
||||
return ApprovalDecision(approved=result)
|
||||
return ApprovalDecision(approved=bool(result))
|
||||
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
if asyncio.iscoroutinefunction(self._callback):
|
||||
result = await self._callback(request.tool_name, request.arguments, request.risk_level)
|
||||
else:
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None, self._callback, request.tool_name, request.arguments, request.risk_level,
|
||||
)
|
||||
if isinstance(result, ApprovalDecision):
|
||||
return result
|
||||
if isinstance(result, bool):
|
||||
return ApprovalDecision(approved=result)
|
||||
return ApprovalDecision(approved=bool(result))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Approval protocol definitions for PraisonAI Agents.
|
||||
|
||||
Defines the structural contract for approval backends via typing.Protocol.
|
||||
Any class implementing ``request_approval`` satisfies the protocol without
|
||||
explicit inheritance (structural subtyping).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalRequest:
|
||||
"""Immutable request for tool-execution approval.
|
||||
|
||||
Attributes:
|
||||
tool_name: Name of the tool requesting approval.
|
||||
arguments: Arguments the tool will be called with.
|
||||
risk_level: Risk classification (critical / high / medium / low).
|
||||
agent_name: Name of the agent that triggered the call (optional).
|
||||
session_id: Session identifier for tracking (optional).
|
||||
context: Arbitrary context dict for backend-specific data.
|
||||
"""
|
||||
|
||||
tool_name: str
|
||||
arguments: Dict[str, Any]
|
||||
risk_level: str
|
||||
agent_name: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalDecision:
|
||||
"""Result of an approval request.
|
||||
|
||||
Attributes:
|
||||
approved: Whether execution is allowed.
|
||||
reason: Human-readable reason for the decision.
|
||||
modified_args: Optional replacement arguments (empty = use originals).
|
||||
approver: Who/what approved (user, system, webhook, …).
|
||||
metadata: Backend-specific metadata (timestamps, IPs, …).
|
||||
"""
|
||||
|
||||
approved: bool
|
||||
reason: str = ""
|
||||
modified_args: Dict[str, Any] = field(default_factory=dict)
|
||||
approver: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ApprovalProtocol(Protocol):
|
||||
"""Protocol for tool-execution approval backends.
|
||||
|
||||
Implement ``request_approval`` to create custom approval channels:
|
||||
- Console prompts
|
||||
- Webhook / HTTP callbacks
|
||||
- Messaging platform replies (Slack, WhatsApp, Telegram)
|
||||
- External UI dashboards
|
||||
- Auto-approve policies
|
||||
|
||||
Example::
|
||||
|
||||
class MyBackend:
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
# … your logic …
|
||||
return ApprovalDecision(approved=True)
|
||||
"""
|
||||
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
"""Request approval for a tool execution.
|
||||
|
||||
Args:
|
||||
request: The approval request with tool info and context.
|
||||
|
||||
Returns:
|
||||
ApprovalDecision indicating whether execution is approved.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Approval registry for PraisonAI Agents.
|
||||
|
||||
Centralises approval state that was previously scattered across module-level
|
||||
globals. Supports **per-agent** backends so different agents can use
|
||||
different approval channels (console, webhook, Slack, …) without touching
|
||||
the ``Agent`` class.
|
||||
|
||||
Usage (no Agent param needed)::
|
||||
|
||||
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
|
||||
|
||||
registry = get_approval_registry()
|
||||
registry.set_backend(AutoApproveBackend(), agent_name="bot-agent")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from .protocols import ApprovalDecision, ApprovalRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default dangerous tools — same as old approval.py
|
||||
DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
|
||||
"execute_command": "critical",
|
||||
"kill_process": "critical",
|
||||
"execute_code": "critical",
|
||||
"write_file": "high",
|
||||
"delete_file": "high",
|
||||
"move_file": "high",
|
||||
"copy_file": "high",
|
||||
"execute_query": "high",
|
||||
"evaluate": "medium",
|
||||
"crawl": "medium",
|
||||
"scrape_page": "medium",
|
||||
}
|
||||
|
||||
|
||||
class ApprovalRegistry:
|
||||
"""Per-agent approval configuration.
|
||||
|
||||
Replaces the old global mutable sets/dicts while keeping the same
|
||||
semantics. The singleton instance is obtained via
|
||||
:func:`get_approval_registry`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Backends
|
||||
self._global_backend = None # type: ignore[assignment]
|
||||
self._agent_backends: Dict[str, object] = {}
|
||||
|
||||
# Tool requirements (mirrors old APPROVAL_REQUIRED_TOOLS / TOOL_RISK_LEVELS)
|
||||
self._required_tools: Set[str] = set()
|
||||
self._risk_levels: Dict[str, str] = {}
|
||||
|
||||
# Context variables (per-coroutine / per-thread)
|
||||
self._approved_context: contextvars.ContextVar[Set[str]] = contextvars.ContextVar(
|
||||
"approved_context", default=set()
|
||||
)
|
||||
self._yaml_approved_tools: contextvars.ContextVar[Set[str]] = contextvars.ContextVar(
|
||||
"yaml_approved_tools", default=set()
|
||||
)
|
||||
|
||||
# Timeout for async approval calls
|
||||
self.timeout: float = 300.0
|
||||
|
||||
# Initialise with dangerous-tool defaults
|
||||
for tool_name, risk in DEFAULT_DANGEROUS_TOOLS.items():
|
||||
self._required_tools.add(tool_name)
|
||||
self._risk_levels[tool_name] = risk
|
||||
|
||||
# ── Backend management ───────────────────────────────────────────────
|
||||
|
||||
def set_backend(self, backend: object, agent_name: Optional[str] = None) -> None:
|
||||
"""Set the approval backend globally or for a specific agent.
|
||||
|
||||
Args:
|
||||
backend: Any object satisfying :class:`ApprovalProtocol`.
|
||||
agent_name: If provided, apply only to this agent.
|
||||
"""
|
||||
if agent_name:
|
||||
self._agent_backends[agent_name] = backend
|
||||
else:
|
||||
self._global_backend = backend
|
||||
|
||||
def remove_backend(self, agent_name: Optional[str] = None) -> None:
|
||||
"""Remove a previously set backend."""
|
||||
if agent_name:
|
||||
self._agent_backends.pop(agent_name, None)
|
||||
else:
|
||||
self._global_backend = None
|
||||
|
||||
def get_backend(self, agent_name: Optional[str] = None) -> object:
|
||||
"""Resolve the backend for *agent_name* (falls back to global, then console)."""
|
||||
if agent_name and agent_name in self._agent_backends:
|
||||
return self._agent_backends[agent_name]
|
||||
if self._global_backend is not None:
|
||||
return self._global_backend
|
||||
# Lazy import to avoid circular / heavy import at module level
|
||||
from .backends import ConsoleBackend
|
||||
return ConsoleBackend()
|
||||
|
||||
# ── Tool requirement management ──────────────────────────────────────
|
||||
|
||||
def add_requirement(self, tool_name: str, risk_level: str = "high") -> None:
|
||||
self._required_tools.add(tool_name)
|
||||
self._risk_levels[tool_name] = risk_level
|
||||
|
||||
def remove_requirement(self, tool_name: str) -> None:
|
||||
self._required_tools.discard(tool_name)
|
||||
self._risk_levels.pop(tool_name, None)
|
||||
|
||||
def is_required(self, tool_name: str) -> bool:
|
||||
return tool_name in self._required_tools
|
||||
|
||||
def get_risk_level(self, tool_name: str) -> Optional[str]:
|
||||
return self._risk_levels.get(tool_name)
|
||||
|
||||
# ── Context helpers ──────────────────────────────────────────────────
|
||||
|
||||
def mark_approved(self, tool_name: str) -> None:
|
||||
approved = self._approved_context.get(set())
|
||||
approved.add(tool_name)
|
||||
self._approved_context.set(approved)
|
||||
|
||||
def is_already_approved(self, tool_name: str) -> bool:
|
||||
return tool_name in self._approved_context.get(set())
|
||||
|
||||
def clear_approved(self) -> None:
|
||||
self._approved_context.set(set())
|
||||
|
||||
def set_yaml_approved_tools(self, tools: List[str]) -> contextvars.Token:
|
||||
return self._yaml_approved_tools.set(set(tools))
|
||||
|
||||
def reset_yaml_approved_tools(self, token: contextvars.Token) -> None:
|
||||
self._yaml_approved_tools.reset(token)
|
||||
|
||||
def is_yaml_approved(self, tool_name: str) -> bool:
|
||||
try:
|
||||
return tool_name in self._yaml_approved_tools.get()
|
||||
except LookupError:
|
||||
return False
|
||||
|
||||
# ── Env-var check ────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def is_env_auto_approve() -> bool:
|
||||
return os.environ.get("PRAISONAI_AUTO_APPROVE", "").lower() in ("true", "1", "yes")
|
||||
|
||||
# ── Approval entry points ────────────────────────────────────────────
|
||||
|
||||
def approve_sync(
|
||||
self,
|
||||
agent_name: Optional[str],
|
||||
tool_name: str,
|
||||
arguments: Dict,
|
||||
) -> ApprovalDecision:
|
||||
"""Synchronous approval — used by ``Agent._execute_tool_impl``."""
|
||||
# Fast-path: not required
|
||||
if not self.is_required(tool_name):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
# Already approved in this context
|
||||
if self.is_already_approved(tool_name):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
# Env auto-approve
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
|
||||
|
||||
# YAML auto-approve
|
||||
if self.is_yaml_approved(tool_name):
|
||||
self.mark_approved(tool_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
|
||||
|
||||
# Delegate to backend
|
||||
backend = self.get_backend(agent_name)
|
||||
request = ApprovalRequest(
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
risk_level=self._risk_levels.get(tool_name, "medium"),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
# Prefer sync method if available
|
||||
if hasattr(backend, "request_approval_sync"):
|
||||
decision = backend.request_approval_sync(request)
|
||||
else:
|
||||
# Fallback: run async method
|
||||
try:
|
||||
decision = asyncio.run(backend.request_approval(request))
|
||||
except RuntimeError:
|
||||
# Already in an event loop — fall back to thread
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, backend.request_approval(request))
|
||||
decision = future.result(timeout=self.timeout)
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name)
|
||||
return decision
|
||||
|
||||
async def approve_async(
|
||||
self,
|
||||
agent_name: Optional[str],
|
||||
tool_name: str,
|
||||
arguments: Dict,
|
||||
) -> ApprovalDecision:
|
||||
"""Asynchronous approval — used by async tool execution path."""
|
||||
# Fast-path: not required
|
||||
if not self.is_required(tool_name):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
if self.is_already_approved(tool_name):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
|
||||
|
||||
if self.is_yaml_approved(tool_name):
|
||||
self.mark_approved(tool_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
|
||||
|
||||
backend = self.get_backend(agent_name)
|
||||
request = ApprovalRequest(
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
risk_level=self._risk_levels.get(tool_name, "medium"),
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
try:
|
||||
decision = await asyncio.wait_for(
|
||||
backend.request_approval(request),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
decision = ApprovalDecision(approved=False, reason="Approval timed out")
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name)
|
||||
return decision
|
||||
@@ -1,13 +1,28 @@
|
||||
"""
|
||||
Bot Protocols for PraisonAI Agents.
|
||||
Bot Protocols and Configuration for PraisonAI Agents.
|
||||
|
||||
Provides protocols and base classes for building messaging bot implementations
|
||||
(Telegram, Discord, Slack, etc.) that connect agents to messaging platforms.
|
||||
|
||||
This module contains only protocols and lightweight utilities.
|
||||
Heavy implementations live in the praisonai wrapper package.
|
||||
Defines the interfaces and configuration for messaging bot implementations.
|
||||
All implementations live in the praisonai wrapper package.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .protocols import (
|
||||
BotOSProtocol, # noqa: F401
|
||||
BotProtocol,
|
||||
BotMessageProtocol,
|
||||
BotUserProtocol,
|
||||
BotChannelProtocol,
|
||||
BotMessage,
|
||||
BotUser,
|
||||
BotChannel,
|
||||
MessageType,
|
||||
ChatCommandInfo,
|
||||
ChatCommandProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
)
|
||||
from .protocols import (
|
||||
BotProtocol,
|
||||
BotMessageProtocol,
|
||||
@@ -19,8 +34,11 @@ from .protocols import (
|
||||
MessageType,
|
||||
ChatCommandInfo,
|
||||
ChatCommandProtocol,
|
||||
BotOSProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
)
|
||||
from .config import BotConfig
|
||||
from .config import BotConfig, BotOSConfig
|
||||
|
||||
__all__ = [
|
||||
"BotProtocol",
|
||||
@@ -34,4 +52,8 @@ __all__ = [
|
||||
"MessageType",
|
||||
"ChatCommandInfo",
|
||||
"ChatCommandProtocol",
|
||||
"BotOSProtocol",
|
||||
"BotOSConfig",
|
||||
"ProbeResult",
|
||||
"HealthResult",
|
||||
]
|
||||
@@ -43,12 +43,29 @@ class BotConfig:
|
||||
reply_in_thread: bool = False # Default to inline, set True for thread replies
|
||||
thread_threshold: int = 500 # Auto-thread responses longer than this (0 = disabled)
|
||||
|
||||
# Group message policy
|
||||
group_policy: str = "mention_only" # respond_all, mention_only, command_only
|
||||
|
||||
# Default tools enabled for all bots
|
||||
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web"])
|
||||
default_tools: List[str] = field(default_factory=lambda: ["execute_command", "search_web", "schedule_add", "schedule_list", "schedule_remove"])
|
||||
|
||||
# Auto-approve tool calls (useful for trusted environments)
|
||||
auto_approve_tools: bool = False # If True, skip confirmation for tool execution
|
||||
|
||||
# Inbound message debounce (ms). Coalesces rapid messages from same user.
|
||||
# 0 = disabled (default). Recommended: 1000-2000 for chat bots.
|
||||
debounce_ms: int = 0
|
||||
|
||||
# Ack emoji reaction on inbound messages. Empty string = disabled.
|
||||
# When set, bot reacts with this emoji on receive (e.g. "⏳") and
|
||||
# replaces it with done_emoji on completion (e.g. "✅").
|
||||
ack_emoji: str = ""
|
||||
done_emoji: str = "✅"
|
||||
|
||||
# Session TTL in seconds. 0 = disabled (sessions never expire).
|
||||
# When set, stale sessions older than this are auto-reaped.
|
||||
session_ttl: int = 0
|
||||
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -70,6 +87,10 @@ class BotConfig:
|
||||
"thread_threshold": self.thread_threshold,
|
||||
"default_tools": self.default_tools,
|
||||
"auto_approve_tools": self.auto_approve_tools,
|
||||
"debounce_ms": self.debounce_ms,
|
||||
"ack_emoji": self.ack_emoji,
|
||||
"done_emoji": self.done_emoji,
|
||||
"session_ttl": self.session_ttl,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@@ -89,3 +110,30 @@ class BotConfig:
|
||||
if not self.allowed_channels:
|
||||
return True
|
||||
return channel_id in self.allowed_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotOSConfig:
|
||||
"""Configuration for BotOS — multi-platform bot orchestrator.
|
||||
|
||||
Attributes:
|
||||
name: Human-readable name for this BotOS instance.
|
||||
platforms: Per-platform config dicts keyed by platform name.
|
||||
Example: ``{"telegram": {"token": "..."}, "discord": {"token": "..."}}``
|
||||
"""
|
||||
|
||||
name: str = "PraisonAI BotOS"
|
||||
platforms: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dict (hides tokens)."""
|
||||
sanitized_platforms = {}
|
||||
for plat, cfg in self.platforms.items():
|
||||
sanitized = dict(cfg)
|
||||
if "token" in sanitized:
|
||||
sanitized["token"] = "***"
|
||||
sanitized_platforms[plat] = sanitized
|
||||
return {
|
||||
"name": self.name,
|
||||
"platforms": sanitized_platforms,
|
||||
}
|
||||
@@ -286,6 +286,76 @@ class BotChannelProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProbeResult:
|
||||
"""Result of a channel connectivity probe.
|
||||
|
||||
Attributes:
|
||||
ok: Whether the probe succeeded
|
||||
platform: Platform name
|
||||
elapsed_ms: Time taken in milliseconds
|
||||
bot_username: Bot's username (if available)
|
||||
error: Error message (if probe failed)
|
||||
details: Additional platform-specific details
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
platform: str = ""
|
||||
elapsed_ms: float = 0.0
|
||||
bot_username: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"platform": self.platform,
|
||||
"elapsed_ms": self.elapsed_ms,
|
||||
"bot_username": self.bot_username,
|
||||
"error": self.error,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthResult:
|
||||
"""Detailed health status of a bot.
|
||||
|
||||
Attributes:
|
||||
ok: Overall health status
|
||||
platform: Platform name
|
||||
is_running: Whether the bot is actively running
|
||||
uptime_seconds: Seconds since bot started (None if not running)
|
||||
probe: Latest probe result (None if not probed)
|
||||
sessions: Number of active sessions
|
||||
error: Error message (if unhealthy)
|
||||
details: Additional platform-specific health details
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
platform: str = ""
|
||||
is_running: bool = False
|
||||
uptime_seconds: Optional[float] = None
|
||||
probe: Optional[ProbeResult] = None
|
||||
sessions: int = 0
|
||||
error: Optional[str] = None
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"platform": self.platform,
|
||||
"is_running": self.is_running,
|
||||
"uptime_seconds": self.uptime_seconds,
|
||||
"probe": self.probe.to_dict() if self.probe else None,
|
||||
"sessions": self.sessions,
|
||||
"error": self.error,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BotProtocol(Protocol):
|
||||
"""Protocol for messaging bot implementations.
|
||||
@@ -451,6 +521,25 @@ class BotProtocol(Protocol):
|
||||
Channel information or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
# Health & diagnostics
|
||||
async def probe(self) -> ProbeResult:
|
||||
"""Test channel connectivity without starting the bot.
|
||||
|
||||
Verifies the bot token is valid and the platform API is reachable.
|
||||
|
||||
Returns:
|
||||
ProbeResult with connectivity status and bot info.
|
||||
"""
|
||||
...
|
||||
|
||||
async def health(self) -> HealthResult:
|
||||
"""Get detailed health status of the running bot.
|
||||
|
||||
Returns:
|
||||
HealthResult with running state, uptime, probe, and session count.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -522,3 +611,77 @@ class ChatCommandProtocol(Protocol):
|
||||
List of ChatCommandInfo for all registered commands
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# BotOS Protocol — multi-platform bot orchestration
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BotOSProtocol(Protocol):
|
||||
"""Protocol for BotOS — multi-platform bot orchestrator.
|
||||
|
||||
BotOS manages multiple Bot instances across different messaging
|
||||
platforms (Telegram, Discord, Slack, WhatsApp, etc.) with a single
|
||||
unified lifecycle.
|
||||
|
||||
Hierarchy::
|
||||
|
||||
BotOS (multi-platform orchestrator)
|
||||
└── Bot (single platform)
|
||||
└── Agent / AgentTeam / AgentFlow (AI brain)
|
||||
|
||||
Implementations live in the ``praisonai`` wrapper package.
|
||||
"""
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Whether the orchestrator is currently running."""
|
||||
...
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start all registered bots concurrently."""
|
||||
...
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Gracefully stop all running bots."""
|
||||
...
|
||||
|
||||
def add_bot(self, bot: Any) -> None:
|
||||
"""Register a Bot instance for orchestration.
|
||||
|
||||
Args:
|
||||
bot: A Bot instance (satisfies BotProtocol).
|
||||
"""
|
||||
...
|
||||
|
||||
def list_bots(self) -> List[str]:
|
||||
"""List platform names of all registered bots.
|
||||
|
||||
Returns:
|
||||
List of platform name strings.
|
||||
"""
|
||||
...
|
||||
|
||||
def remove_bot(self, platform: str) -> bool:
|
||||
"""Remove a registered bot by platform name.
|
||||
|
||||
Args:
|
||||
platform: Platform identifier to remove.
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_bot(self, platform: str) -> Optional[Any]:
|
||||
"""Get a registered bot by platform name.
|
||||
|
||||
Args:
|
||||
platform: Platform identifier (e.g. "telegram").
|
||||
|
||||
Returns:
|
||||
The Bot instance, or None if not found.
|
||||
"""
|
||||
...
|
||||
@@ -172,6 +172,10 @@ class MemoryConfig:
|
||||
history: bool = False
|
||||
history_limit: int = 10
|
||||
|
||||
# Auto-save session name (consolidated from standalone auto_save param)
|
||||
# When set, automatically saves session to memory with this name
|
||||
auto_save: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
learn_dict = None
|
||||
@@ -193,6 +197,7 @@ class MemoryConfig:
|
||||
"learn": learn_dict,
|
||||
"history": self.history,
|
||||
"history_limit": self.history_limit,
|
||||
"auto_save": self.auto_save,
|
||||
}
|
||||
|
||||
|
||||
@@ -609,7 +614,8 @@ class ExecutionConfig:
|
||||
"""
|
||||
Configuration for agent execution limits.
|
||||
|
||||
Consolidates: max_iter, max_rpm, max_execution_time, max_retry_limit
|
||||
Consolidates: max_iter, max_rpm, max_execution_time, max_retry_limit,
|
||||
allow_code_execution, code_execution_mode, rate_limiter
|
||||
|
||||
Usage:
|
||||
# Simple preset
|
||||
@@ -622,6 +628,13 @@ class ExecutionConfig:
|
||||
max_execution_time=300,
|
||||
max_retry_limit=5,
|
||||
))
|
||||
|
||||
# With code execution and rate limiter
|
||||
Agent(execution=ExecutionConfig(
|
||||
code_execution=True,
|
||||
code_mode="safe",
|
||||
rate_limiter=my_limiter,
|
||||
))
|
||||
"""
|
||||
# Iteration limits
|
||||
max_iter: int = 20
|
||||
@@ -635,6 +648,13 @@ class ExecutionConfig:
|
||||
# Retry settings
|
||||
max_retry_limit: int = 2
|
||||
|
||||
# Code execution (consolidated from allow_code_execution + code_execution_mode)
|
||||
code_execution: bool = False
|
||||
code_mode: str = "safe" # "safe" or "unsafe"
|
||||
|
||||
# Rate limiter instance (consolidated from standalone rate_limiter param)
|
||||
rate_limiter: Optional[Any] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
@@ -642,6 +662,8 @@ class ExecutionConfig:
|
||||
"max_rpm": self.max_rpm,
|
||||
"max_execution_time": self.max_execution_time,
|
||||
"max_retry_limit": self.max_retry_limit,
|
||||
"code_execution": self.code_execution,
|
||||
"code_mode": self.code_mode,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ class EventType(str, Enum):
|
||||
MESSAGE_ACK = "message_ack"
|
||||
TYPING = "typing"
|
||||
|
||||
# Streaming events (relayed from agent's StreamEventEmitter)
|
||||
TOKEN_STREAM = "token_stream"
|
||||
TOOL_CALL_STREAM = "tool_call_stream"
|
||||
STREAM_END = "stream_end"
|
||||
|
||||
# System events
|
||||
HEALTH = "health"
|
||||
ERROR = "error"
|
||||
|
||||
@@ -71,6 +71,10 @@ __all__ = [
|
||||
"SessionEndInput",
|
||||
"OnErrorInput",
|
||||
"OnRetryInput",
|
||||
# Message lifecycle event inputs
|
||||
"MessageReceivedInput",
|
||||
"MessageSendingInput",
|
||||
"MessageSentInput",
|
||||
# Middleware types
|
||||
"InvocationContext",
|
||||
"ModelRequest",
|
||||
@@ -130,6 +134,9 @@ _LAZY_GROUPS = {
|
||||
'SessionEndInput': ('praisonaiagents.hooks.events', 'SessionEndInput'),
|
||||
'OnErrorInput': ('praisonaiagents.hooks.events', 'OnErrorInput'),
|
||||
'OnRetryInput': ('praisonaiagents.hooks.events', 'OnRetryInput'),
|
||||
'MessageReceivedInput': ('praisonaiagents.hooks.events', 'MessageReceivedInput'),
|
||||
'MessageSendingInput': ('praisonaiagents.hooks.events', 'MessageSendingInput'),
|
||||
'MessageSentInput': ('praisonaiagents.hooks.events', 'MessageSentInput'),
|
||||
},
|
||||
'middleware_types': {
|
||||
'InvocationContext': ('praisonaiagents.hooks.middleware', 'InvocationContext'),
|
||||
|
||||
@@ -193,3 +193,68 @@ class OnRetryInput(HookInput):
|
||||
"operation": self.operation
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageReceivedInput(HookInput):
|
||||
"""Input for MESSAGE_RECEIVED hooks (bot received an incoming message)."""
|
||||
platform: str = ""
|
||||
content: str = ""
|
||||
sender_id: str = ""
|
||||
channel_id: str = ""
|
||||
channel_type: str = ""
|
||||
message_id: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"platform": self.platform,
|
||||
"content": self.content[:500] if self.content else "",
|
||||
"sender_id": self.sender_id,
|
||||
"channel_id": self.channel_id,
|
||||
"channel_type": self.channel_type,
|
||||
"message_id": self.message_id,
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageSendingInput(HookInput):
|
||||
"""Input for MESSAGE_SENDING hooks (bot about to send a message).
|
||||
|
||||
Hooks can modify content or cancel the send by returning
|
||||
HookResult with decision=DENY.
|
||||
"""
|
||||
platform: str = ""
|
||||
content: str = ""
|
||||
channel_id: str = ""
|
||||
reply_to: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"platform": self.platform,
|
||||
"content": self.content[:500] if self.content else "",
|
||||
"channel_id": self.channel_id,
|
||||
"reply_to": self.reply_to,
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageSentInput(HookInput):
|
||||
"""Input for MESSAGE_SENT hooks (bot successfully sent a message)."""
|
||||
platform: str = ""
|
||||
content: str = ""
|
||||
channel_id: str = ""
|
||||
message_id: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"platform": self.platform,
|
||||
"content": self.content[:500] if self.content else "",
|
||||
"channel_id": self.channel_id,
|
||||
"message_id": self.message_id,
|
||||
})
|
||||
return base
|
||||
@@ -70,7 +70,7 @@ class HookRunner:
|
||||
default_timeout: Default timeout for hooks
|
||||
cwd: Working directory for command hooks
|
||||
"""
|
||||
self._registry = registry or HookRegistry()
|
||||
self._registry = registry if registry is not None else HookRegistry()
|
||||
self._default_timeout = default_timeout
|
||||
self._cwd = cwd or os.getcwd()
|
||||
|
||||
@@ -142,12 +142,19 @@ class HookRunner:
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we're in an async context, create a task
|
||||
future = asyncio.ensure_future(self.execute(event, input_data, target))
|
||||
return loop.run_until_complete(future)
|
||||
except RuntimeError:
|
||||
# No event loop running, create one
|
||||
return asyncio.run(self.execute(event, input_data, target))
|
||||
loop = None
|
||||
|
||||
if loop is not None and loop.is_running():
|
||||
# Already inside a running async loop — cannot use run_until_complete.
|
||||
# Schedule as fire-and-forget task to avoid leaking unawaited coroutines.
|
||||
# Callers that need results in async context should use
|
||||
# ``await runner.execute(...)`` directly.
|
||||
loop.create_task(self.execute(event, input_data, target))
|
||||
return []
|
||||
|
||||
# No running loop — safe to create one
|
||||
return asyncio.run(self.execute(event, input_data, target))
|
||||
|
||||
async def _execute_parallel(
|
||||
self,
|
||||
|
||||
@@ -63,6 +63,11 @@ class HookEvent(str, Enum):
|
||||
ON_CONFIG = "on_config"
|
||||
ON_AUTH = "on_auth"
|
||||
|
||||
# Schedule lifecycle
|
||||
SCHEDULE_ADD = "schedule_add"
|
||||
SCHEDULE_REMOVE = "schedule_remove"
|
||||
SCHEDULE_TRIGGER = "schedule_trigger"
|
||||
|
||||
# Claude Code parity events
|
||||
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
|
||||
NOTIFICATION = "notification" # When notification is sent
|
||||
|
||||
@@ -45,6 +45,7 @@ from .protocols import (
|
||||
DeletableMemoryProtocol,
|
||||
AsyncDeletableMemoryProtocol,
|
||||
EntityMemoryProtocol,
|
||||
AgentMemoryProtocol,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -275,6 +275,47 @@ class EntityMemoryProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AgentMemoryProtocol(Protocol):
|
||||
"""
|
||||
Protocol for memory implementations used by the Agent class.
|
||||
|
||||
This defines the interface that Agent expects from its _memory_instance.
|
||||
Both FileMemory and Memory satisfy this protocol, enabling consistent
|
||||
usage across backends.
|
||||
|
||||
Example:
|
||||
```python
|
||||
class CustomMemory:
|
||||
def get_context(self, query=None):
|
||||
return "remembered facts here"
|
||||
|
||||
def save_session(self, name, conversation_history=None, metadata=None):
|
||||
pass # persist session
|
||||
|
||||
agent = Agent(memory=CustomMemory())
|
||||
```
|
||||
"""
|
||||
|
||||
def get_context(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Get memory context for injection into system prompt."""
|
||||
...
|
||||
|
||||
def save_session(
|
||||
self,
|
||||
name: str,
|
||||
conversation_history: Optional[List[Dict[str, Any]]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""Save a conversation session to memory."""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
'MemoryProtocol',
|
||||
'ResettableMemoryProtocol',
|
||||
@@ -282,5 +323,6 @@ __all__ = [
|
||||
'AsyncDeletableMemoryProtocol',
|
||||
'AsyncMemoryProtocol',
|
||||
'EntityMemoryProtocol',
|
||||
'AgentMemoryProtocol',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Scheduler module for PraisonAI Agents.
|
||||
|
||||
Provides agent-centric scheduling via tools — agents can add, list,
|
||||
and remove scheduled jobs without any changes to the Agent class.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
tools=[schedule_add, schedule_list, schedule_remove],
|
||||
)
|
||||
agent.start("Remind me to check email every morning at 7am")
|
||||
|
||||
Default storage: ~/.praisonai/schedules/jobs.json
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import Schedule, ScheduleJob
|
||||
from .store import FileScheduleStore
|
||||
from .parser import parse_schedule
|
||||
from .runner import ScheduleRunner
|
||||
|
||||
_module_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load scheduler components."""
|
||||
if name in _module_cache:
|
||||
return _module_cache[name]
|
||||
|
||||
if name in ("Schedule", "ScheduleJob"):
|
||||
from .models import Schedule, ScheduleJob
|
||||
_module_cache["Schedule"] = Schedule
|
||||
_module_cache["ScheduleJob"] = ScheduleJob
|
||||
return _module_cache[name]
|
||||
|
||||
if name == "FileScheduleStore":
|
||||
from .store import FileScheduleStore
|
||||
_module_cache[name] = FileScheduleStore
|
||||
return FileScheduleStore
|
||||
|
||||
if name == "parse_schedule":
|
||||
from .parser import parse_schedule
|
||||
_module_cache[name] = parse_schedule
|
||||
return parse_schedule
|
||||
|
||||
if name == "ScheduleRunner":
|
||||
from .runner import ScheduleRunner
|
||||
_module_cache[name] = ScheduleRunner
|
||||
return ScheduleRunner
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Schedule",
|
||||
"ScheduleJob",
|
||||
"FileScheduleStore",
|
||||
"parse_schedule",
|
||||
"ScheduleRunner",
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Schedule models for PraisonAI Agents.
|
||||
|
||||
Lightweight dataclasses — no heavy dependencies.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Schedule:
|
||||
"""When to run a scheduled job.
|
||||
|
||||
Supports three kinds:
|
||||
- ``every``: recurring interval in seconds
|
||||
- ``cron``: 5-field cron expression (requires optional ``croniter``)
|
||||
- ``at``: one-shot ISO 8601 timestamp
|
||||
"""
|
||||
|
||||
kind: Literal["every", "cron", "at"] = "every"
|
||||
every_seconds: Optional[int] = None
|
||||
cron_expr: Optional[str] = None
|
||||
at: Optional[str] = None
|
||||
tz: Optional[str] = None
|
||||
|
||||
# ── serialisation ────────────────────────────────────────────────
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d: Dict[str, Any] = {"kind": self.kind}
|
||||
if self.every_seconds is not None:
|
||||
d["every_seconds"] = self.every_seconds
|
||||
if self.cron_expr is not None:
|
||||
d["cron_expr"] = self.cron_expr
|
||||
if self.at is not None:
|
||||
d["at"] = self.at
|
||||
if self.tz is not None:
|
||||
d["tz"] = self.tz
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "Schedule":
|
||||
return cls(
|
||||
kind=d.get("kind", "every"),
|
||||
every_seconds=d.get("every_seconds"),
|
||||
cron_expr=d.get("cron_expr"),
|
||||
at=d.get("at"),
|
||||
tz=d.get("tz"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleJob:
|
||||
"""A persisted scheduled job.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier (auto-generated).
|
||||
name: Human-readable name.
|
||||
schedule: When to run.
|
||||
message: Prompt / payload to deliver when triggered.
|
||||
agent_id: Optional owning agent.
|
||||
session_target: ``"main"`` injects into running session;
|
||||
``"isolated"`` creates a fresh agent turn.
|
||||
enabled: Toggle without deleting.
|
||||
delete_after_run: Auto-remove after first execution (one-shot).
|
||||
created_at: Epoch timestamp.
|
||||
last_run_at: Epoch timestamp of most recent execution.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
schedule: Schedule = field(default_factory=Schedule)
|
||||
message: str = ""
|
||||
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
||||
agent_id: Optional[str] = None
|
||||
session_target: Literal["main", "isolated"] = "isolated"
|
||||
enabled: bool = True
|
||||
delete_after_run: bool = False
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_run_at: Optional[float] = None
|
||||
|
||||
# ── serialisation ────────────────────────────────────────────────
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"schedule": self.schedule.to_dict(),
|
||||
"message": self.message,
|
||||
"agent_id": self.agent_id,
|
||||
"session_target": self.session_target,
|
||||
"enabled": self.enabled,
|
||||
"delete_after_run": self.delete_after_run,
|
||||
"created_at": self.created_at,
|
||||
"last_run_at": self.last_run_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "ScheduleJob":
|
||||
sched_data = d.get("schedule", {})
|
||||
return cls(
|
||||
id=d.get("id", uuid.uuid4().hex[:12]),
|
||||
name=d.get("name", ""),
|
||||
schedule=Schedule.from_dict(sched_data) if isinstance(sched_data, dict) else Schedule(),
|
||||
message=d.get("message", ""),
|
||||
agent_id=d.get("agent_id"),
|
||||
session_target=d.get("session_target", "isolated"),
|
||||
enabled=d.get("enabled", True),
|
||||
delete_after_run=d.get("delete_after_run", False),
|
||||
created_at=d.get("created_at", time.time()),
|
||||
last_run_at=d.get("last_run_at"),
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Schedule expression parser for PraisonAI Agents.
|
||||
|
||||
Parses human-friendly schedule strings into ``Schedule`` objects.
|
||||
No external dependencies for interval parsing; ``croniter`` is optional
|
||||
and only imported when a cron expression is used.
|
||||
|
||||
Supported formats:
|
||||
"hourly" → every 3600s
|
||||
"daily" → every 86400s
|
||||
"*/30m" → every 1800s
|
||||
"*/6h" → every 21600s
|
||||
"*/10s" → every 10s
|
||||
"3600" → every 3600s (raw seconds)
|
||||
"cron:0 7 * * *" → cron expression
|
||||
"at:2026-03-01T09:00:00" → one-shot ISO timestamp
|
||||
"in 20 minutes" → one-shot ~20min from now
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .models import Schedule
|
||||
|
||||
_KEYWORD_MAP = {
|
||||
"hourly": 3600,
|
||||
"daily": 86400,
|
||||
"weekly": 604800,
|
||||
}
|
||||
|
||||
_INTERVAL_RE = re.compile(
|
||||
r"^\*?/?\*?(\d+)\s*(s|sec|second|seconds|m|min|minute|minutes|h|hr|hour|hours)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_RELATIVE_RE = re.compile(
|
||||
r"^in\s+(\d+)\s*(s|sec|second|seconds|m|min|minute|minutes|h|hr|hour|hours)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_UNIT_MULTIPLIER = {
|
||||
"s": 1, "sec": 1, "second": 1, "seconds": 1,
|
||||
"m": 60, "min": 60, "minute": 60, "minutes": 60,
|
||||
"h": 3600, "hr": 3600, "hour": 3600, "hours": 3600,
|
||||
}
|
||||
|
||||
|
||||
def parse_schedule(expr: str) -> Schedule:
|
||||
"""Parse a schedule expression string into a ``Schedule`` object.
|
||||
|
||||
Raises ``ValueError`` for unrecognised expressions.
|
||||
"""
|
||||
if not expr or not expr.strip():
|
||||
raise ValueError("Schedule expression cannot be empty")
|
||||
|
||||
expr = expr.strip()
|
||||
|
||||
# Keyword shortcuts
|
||||
lower = expr.lower()
|
||||
if lower in _KEYWORD_MAP:
|
||||
return Schedule(kind="every", every_seconds=_KEYWORD_MAP[lower])
|
||||
|
||||
# Cron prefix
|
||||
if lower.startswith("cron:"):
|
||||
cron_expr = expr[5:].strip()
|
||||
if not cron_expr:
|
||||
raise ValueError("Empty cron expression after 'cron:' prefix")
|
||||
return Schedule(kind="cron", cron_expr=cron_expr)
|
||||
|
||||
# At prefix (ISO timestamp)
|
||||
if lower.startswith("at:"):
|
||||
at_str = expr[3:].strip()
|
||||
if not at_str:
|
||||
raise ValueError("Empty timestamp after 'at:' prefix")
|
||||
return Schedule(kind="at", at=at_str)
|
||||
|
||||
# Interval pattern: */30m, */6h, */10s
|
||||
m = _INTERVAL_RE.match(expr)
|
||||
if m:
|
||||
value = int(m.group(1))
|
||||
unit = m.group(2).lower()
|
||||
seconds = value * _UNIT_MULTIPLIER[unit]
|
||||
return Schedule(kind="every", every_seconds=seconds)
|
||||
|
||||
# Relative pattern: "in 20 minutes"
|
||||
m = _RELATIVE_RE.match(expr)
|
||||
if m:
|
||||
value = int(m.group(1))
|
||||
unit = m.group(2).lower()
|
||||
delta_seconds = value * _UNIT_MULTIPLIER[unit]
|
||||
target = datetime.now(timezone.utc) + timedelta(seconds=delta_seconds)
|
||||
return Schedule(kind="at", at=target.isoformat())
|
||||
|
||||
# Raw numeric seconds
|
||||
try:
|
||||
seconds = int(expr)
|
||||
if seconds > 0:
|
||||
return Schedule(kind="every", every_seconds=seconds)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise ValueError(
|
||||
f"Unrecognised schedule expression: {expr!r}. "
|
||||
"Use 'hourly', 'daily', '*/30m', 'cron:EXPR', 'at:ISO', or 'in N minutes'."
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Schedule runner for PraisonAI Agents.
|
||||
|
||||
Checks the store for due jobs and returns them. Execution of the job
|
||||
payload is the caller's responsibility (keeping the runner lightweight).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from .models import ScheduleJob
|
||||
from .store import FileScheduleStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScheduleRunner:
|
||||
"""Stateless helper that inspects a store and returns due jobs.
|
||||
|
||||
The runner does NOT own a loop or thread — callers decide how
|
||||
and when to call ``get_due_jobs()``.
|
||||
"""
|
||||
|
||||
def __init__(self, store: FileScheduleStore):
|
||||
self._store = store
|
||||
|
||||
# ── public ───────────────────────────────────────────────────────
|
||||
|
||||
def get_due_jobs(self) -> List[ScheduleJob]:
|
||||
"""Return all enabled jobs that are due for execution *now*."""
|
||||
now = time.time()
|
||||
due: List[ScheduleJob] = []
|
||||
for job in self._store.list():
|
||||
if not job.enabled:
|
||||
continue
|
||||
if self._is_due(job, now):
|
||||
due.append(job)
|
||||
return due
|
||||
|
||||
def mark_run(self, job: ScheduleJob) -> None:
|
||||
"""Update ``last_run_at`` and optionally delete one-shot jobs."""
|
||||
job.last_run_at = time.time()
|
||||
if job.delete_after_run:
|
||||
self._store.remove(job.id)
|
||||
else:
|
||||
self._store.update(job)
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────
|
||||
|
||||
def _is_due(self, job: ScheduleJob, now: float) -> bool:
|
||||
sched = job.schedule
|
||||
|
||||
if sched.kind == "every":
|
||||
if sched.every_seconds is None:
|
||||
return False
|
||||
if job.last_run_at is None:
|
||||
return True # Never run → due immediately
|
||||
return (now - job.last_run_at) >= sched.every_seconds
|
||||
|
||||
if sched.kind == "at":
|
||||
if sched.at is None:
|
||||
return False
|
||||
if job.last_run_at is not None:
|
||||
return False # Already ran
|
||||
try:
|
||||
target = datetime.fromisoformat(sched.at)
|
||||
if target.tzinfo is None:
|
||||
target = target.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) >= target
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Invalid 'at' timestamp for job %s: %s", job.id, sched.at)
|
||||
return False
|
||||
|
||||
if sched.kind == "cron":
|
||||
return self._cron_is_due(job, now)
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _cron_is_due(job: ScheduleJob, now: float) -> bool:
|
||||
"""Check cron schedule. Requires optional ``croniter``."""
|
||||
if job.schedule.cron_expr is None:
|
||||
return False
|
||||
try:
|
||||
from croniter import croniter # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"croniter not installed — cron schedules are unavailable. "
|
||||
"Install with: pip install croniter"
|
||||
)
|
||||
return False
|
||||
|
||||
base_time = job.last_run_at or job.created_at
|
||||
cron = croniter(job.schedule.cron_expr, base_time)
|
||||
next_run = cron.get_next(float)
|
||||
return now >= next_run
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
File-based schedule store for PraisonAI Agents.
|
||||
|
||||
Persists scheduled jobs to ``~/.praisonai/schedules/jobs.json``.
|
||||
Thread-safe, no external dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .models import ScheduleJob
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_DIR = os.path.join(os.path.expanduser("~"), ".praisonai", "schedules")
|
||||
_JOBS_FILE = "jobs.json"
|
||||
|
||||
|
||||
class FileScheduleStore:
|
||||
"""CRUD store backed by a single JSON file.
|
||||
|
||||
Thread-safe for multi-agent scenarios.
|
||||
"""
|
||||
|
||||
def __init__(self, store_dir: Optional[str] = None):
|
||||
self._dir = store_dir or _DEFAULT_DIR
|
||||
self._path = os.path.join(self._dir, _JOBS_FILE)
|
||||
self._lock = threading.RLock()
|
||||
self._jobs: Dict[str, ScheduleJob] = {}
|
||||
self._load()
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────
|
||||
|
||||
def add(self, job: ScheduleJob) -> None:
|
||||
"""Add a job. Raises ``ValueError`` if id already exists."""
|
||||
with self._lock:
|
||||
if job.id in self._jobs:
|
||||
raise ValueError(f"Job '{job.id}' already exists")
|
||||
self._jobs[job.id] = job
|
||||
self._save()
|
||||
|
||||
def get(self, job_id: str) -> Optional[ScheduleJob]:
|
||||
with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[ScheduleJob]:
|
||||
with self._lock:
|
||||
for job in self._jobs.values():
|
||||
if job.name == name:
|
||||
return job
|
||||
return None
|
||||
|
||||
def list(self, agent_id: Optional[str] = None) -> List[ScheduleJob]:
|
||||
with self._lock:
|
||||
jobs = list(self._jobs.values())
|
||||
if agent_id is not None:
|
||||
jobs = [j for j in jobs if j.agent_id == agent_id]
|
||||
return jobs
|
||||
|
||||
def update(self, job: ScheduleJob) -> None:
|
||||
with self._lock:
|
||||
self._jobs[job.id] = job
|
||||
self._save()
|
||||
|
||||
def remove(self, job_id: str) -> bool:
|
||||
with self._lock:
|
||||
if job_id in self._jobs:
|
||||
del self._jobs[job_id]
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_by_name(self, name: str) -> bool:
|
||||
with self._lock:
|
||||
for jid, job in list(self._jobs.items()):
|
||||
if job.name == name:
|
||||
del self._jobs[jid]
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> None:
|
||||
if not os.path.exists(self._path):
|
||||
return
|
||||
try:
|
||||
with open(self._path, "r") as f:
|
||||
data = json.load(f)
|
||||
for d in data:
|
||||
job = ScheduleJob.from_dict(d)
|
||||
self._jobs[job.id] = job
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load schedule store from %s: %s", self._path, e)
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
os.makedirs(self._dir, exist_ok=True)
|
||||
data = [j.to_dict() for j in self._jobs.values()]
|
||||
tmp = self._path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, self._path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save schedule store to %s: %s", self._path, e)
|
||||
@@ -29,6 +29,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .store import DefaultSessionStore, SessionMessage, SessionData
|
||||
from .protocols import SessionStoreProtocol
|
||||
from .hierarchy import HierarchicalSessionStore, SessionSnapshot, ExtendedSessionData
|
||||
|
||||
# Lazy loading for zero import overhead
|
||||
_module_cache = {}
|
||||
@@ -64,6 +66,31 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = get_default_session_store
|
||||
return get_default_session_store
|
||||
|
||||
if name == "SessionStoreProtocol":
|
||||
from .protocols import SessionStoreProtocol
|
||||
_module_cache[name] = SessionStoreProtocol
|
||||
return SessionStoreProtocol
|
||||
|
||||
if name == "HierarchicalSessionStore":
|
||||
from .hierarchy import HierarchicalSessionStore
|
||||
_module_cache[name] = HierarchicalSessionStore
|
||||
return HierarchicalSessionStore
|
||||
|
||||
if name == "get_hierarchical_session_store":
|
||||
from .hierarchy import get_hierarchical_session_store
|
||||
_module_cache[name] = get_hierarchical_session_store
|
||||
return get_hierarchical_session_store
|
||||
|
||||
if name == "SessionSnapshot":
|
||||
from .hierarchy import SessionSnapshot
|
||||
_module_cache[name] = SessionSnapshot
|
||||
return SessionSnapshot
|
||||
|
||||
if name == "ExtendedSessionData":
|
||||
from .hierarchy import ExtendedSessionData
|
||||
_module_cache[name] = ExtendedSessionData
|
||||
return ExtendedSessionData
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -73,4 +100,9 @@ __all__ = [
|
||||
"SessionMessage",
|
||||
"SessionData",
|
||||
"get_default_session_store",
|
||||
"SessionStoreProtocol",
|
||||
"HierarchicalSessionStore",
|
||||
"get_hierarchical_session_store",
|
||||
"SessionSnapshot",
|
||||
"ExtendedSessionData",
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Session Store Protocol for PraisonAI Agents.
|
||||
|
||||
Defines the minimal interface contract for session persistence backends.
|
||||
Any class implementing these methods can be used as a session store.
|
||||
|
||||
Built-in implementations:
|
||||
- DefaultSessionStore (JSON file-based)
|
||||
- HierarchicalSessionStore (extends DefaultSessionStore with fork/snapshot)
|
||||
|
||||
Custom implementations (e.g. Redis, MongoDB, PostgreSQL) can implement
|
||||
this protocol for seamless swapping.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.session.protocols import SessionStoreProtocol
|
||||
|
||||
class RedisSessionStore:
|
||||
def add_message(self, session_id, role, content, metadata=None):
|
||||
... # Store in Redis
|
||||
|
||||
def get_chat_history(self, session_id, max_messages=None):
|
||||
... # Retrieve from Redis
|
||||
|
||||
# ... other methods
|
||||
|
||||
# Type-check at runtime
|
||||
store: SessionStoreProtocol = RedisSessionStore()
|
||||
assert isinstance(store, SessionStoreProtocol)
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionStoreProtocol(Protocol):
|
||||
"""Protocol for session persistence backends.
|
||||
|
||||
Defines the minimal interface for storing and retrieving
|
||||
agent conversation sessions. Implementations must provide
|
||||
these five core methods.
|
||||
|
||||
Built-in implementations:
|
||||
- DefaultSessionStore (JSON files with atomic writes)
|
||||
- HierarchicalSessionStore (fork, snapshot, revert)
|
||||
|
||||
Example custom implementation::
|
||||
|
||||
class MyStore:
|
||||
def add_message(self, session_id, role, content, metadata=None):
|
||||
...
|
||||
def get_chat_history(self, session_id, max_messages=None):
|
||||
return [{"role": "user", "content": "Hi"}]
|
||||
def clear_session(self, session_id):
|
||||
return True
|
||||
def delete_session(self, session_id):
|
||||
return True
|
||||
def session_exists(self, session_id):
|
||||
return False
|
||||
|
||||
store: SessionStoreProtocol = MyStore()
|
||||
assert isinstance(store, SessionStoreProtocol) # True
|
||||
"""
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Add a message to a session.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier.
|
||||
role: Message role ("user", "assistant", "system").
|
||||
content: Message content text.
|
||||
metadata: Optional metadata dict.
|
||||
|
||||
Returns:
|
||||
True if saved successfully.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_chat_history(
|
||||
self,
|
||||
session_id: str,
|
||||
max_messages: Optional[int] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Get chat history in LLM-compatible format.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier.
|
||||
max_messages: Maximum messages to return (most recent).
|
||||
|
||||
Returns:
|
||||
List of {"role": "...", "content": "..."} dicts.
|
||||
"""
|
||||
...
|
||||
|
||||
def clear_session(self, session_id: str) -> bool:
|
||||
"""Clear all messages from a session (keep session metadata).
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier.
|
||||
|
||||
Returns:
|
||||
True if cleared successfully.
|
||||
"""
|
||||
...
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session completely (data + metadata).
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier.
|
||||
|
||||
Returns:
|
||||
True if deleted successfully.
|
||||
"""
|
||||
...
|
||||
|
||||
def session_exists(self, session_id: str) -> bool:
|
||||
"""Check if a session exists.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier.
|
||||
|
||||
Returns:
|
||||
True if the session exists.
|
||||
"""
|
||||
...
|
||||
@@ -149,6 +149,12 @@ TOOL_MAPPINGS = {
|
||||
'create_skill_tools': ('.skill_tools', None),
|
||||
'SkillTools': ('.skill_tools', 'SkillTools'),
|
||||
'skill_tools': ('.skill_tools', None),
|
||||
|
||||
# Schedule Tools (agent-centric scheduling)
|
||||
'schedule_add': ('.schedule_tools', None),
|
||||
'schedule_list': ('.schedule_tools', None),
|
||||
'schedule_remove': ('.schedule_tools', None),
|
||||
'schedule_tools': ('.schedule_tools', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
@@ -181,10 +187,11 @@ def __getattr__(name: str) -> Any:
|
||||
'crawl4ai_sync', 'crawl4ai_extract_sync',
|
||||
'search_web', 'web_search', 'get_available_providers',
|
||||
'web_crawl', 'crawl_web', 'get_available_crawl_providers',
|
||||
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools'
|
||||
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools',
|
||||
'schedule_add', 'schedule_list', 'schedule_remove'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools']:
|
||||
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools']:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Schedule tools for PraisonAI Agents.
|
||||
|
||||
Agent-centric scheduling — agents call these tools to add, list, and
|
||||
remove scheduled jobs. No changes to the Agent class required.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
instructions="You can set reminders and schedules for the user.",
|
||||
tools=[schedule_add, schedule_list, schedule_remove],
|
||||
)
|
||||
agent.start("Remind me to check email every morning at 7am")
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── lazy singleton store ─────────────────────────────────────────────────────
|
||||
|
||||
_store_instance = None
|
||||
|
||||
|
||||
def _get_store():
|
||||
"""Return (or create) the global ``FileScheduleStore``."""
|
||||
global _store_instance
|
||||
if _store_instance is None:
|
||||
from ..scheduler.store import FileScheduleStore
|
||||
_store_instance = FileScheduleStore()
|
||||
return _store_instance
|
||||
|
||||
|
||||
# ── tools ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def schedule_add(
|
||||
name: str,
|
||||
schedule: str,
|
||||
message: str = "",
|
||||
) -> str:
|
||||
"""Add a new scheduled job.
|
||||
|
||||
Args:
|
||||
name: Human-readable name for this schedule (e.g. "morning-email-check").
|
||||
schedule: When to run. Accepted formats:
|
||||
- "hourly", "daily"
|
||||
- "*/30m", "*/6h", "*/10s" (interval)
|
||||
- "cron:0 7 * * *" (cron expression)
|
||||
- "at:2026-03-01T09:00:00" (one-shot ISO timestamp)
|
||||
- "in 20 minutes" (relative one-shot)
|
||||
message: The prompt or reminder text to deliver when triggered.
|
||||
|
||||
Returns:
|
||||
Confirmation string with the job id.
|
||||
"""
|
||||
try:
|
||||
from ..scheduler.parser import parse_schedule
|
||||
from ..scheduler.models import ScheduleJob
|
||||
|
||||
sched = parse_schedule(schedule)
|
||||
job = ScheduleJob(name=name, schedule=sched, message=message)
|
||||
|
||||
store = _get_store()
|
||||
|
||||
# Prevent duplicate names
|
||||
existing = store.get_by_name(name)
|
||||
if existing:
|
||||
return f"A schedule named '{name}' already exists (id: {existing.id}). Remove it first or choose a different name."
|
||||
|
||||
store.add(job)
|
||||
return f"Schedule '{name}' added (id: {job.id}, {schedule})."
|
||||
except ValueError as e:
|
||||
return f"Error adding schedule: {e}"
|
||||
except Exception as e:
|
||||
logger.error("schedule_add failed: %s", e, exc_info=True)
|
||||
return f"Error adding schedule: {e}"
|
||||
|
||||
|
||||
def schedule_list() -> str:
|
||||
"""List all scheduled jobs.
|
||||
|
||||
Returns:
|
||||
A formatted string listing every job with its id, name, schedule,
|
||||
status, and message. Returns a friendly message when empty.
|
||||
"""
|
||||
try:
|
||||
store = _get_store()
|
||||
jobs = store.list()
|
||||
if not jobs:
|
||||
return "No schedules found. Use schedule_add to create one."
|
||||
|
||||
lines = [f"Found {len(jobs)} schedule(s):\n"]
|
||||
for j in jobs:
|
||||
status = "enabled" if j.enabled else "disabled"
|
||||
sched = j.schedule
|
||||
if sched.kind == "every" and sched.every_seconds:
|
||||
sched_str = _human_interval(sched.every_seconds)
|
||||
elif sched.kind == "cron":
|
||||
sched_str = f"cron: {sched.cron_expr}"
|
||||
elif sched.kind == "at":
|
||||
sched_str = f"at: {sched.at}"
|
||||
else:
|
||||
sched_str = str(sched.kind)
|
||||
lines.append(
|
||||
f" - {j.name} (id: {j.id}) [{status}] — {sched_str}"
|
||||
+ (f" — \"{j.message}\"" if j.message else "")
|
||||
)
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.error("schedule_list failed: %s", e, exc_info=True)
|
||||
return f"Error listing schedules: {e}"
|
||||
|
||||
|
||||
def schedule_remove(name: str) -> str:
|
||||
"""Remove a scheduled job by name.
|
||||
|
||||
Args:
|
||||
name: The name of the schedule to remove.
|
||||
|
||||
Returns:
|
||||
Confirmation or not-found message.
|
||||
"""
|
||||
try:
|
||||
store = _get_store()
|
||||
removed = store.remove_by_name(name)
|
||||
if removed:
|
||||
return f"Schedule '{name}' removed."
|
||||
return f"Schedule '{name}' not found."
|
||||
except Exception as e:
|
||||
logger.error("schedule_remove failed: %s", e, exc_info=True)
|
||||
return f"Error removing schedule: {e}"
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _human_interval(seconds: int) -> str:
|
||||
"""Convert seconds to a human-friendly string."""
|
||||
if seconds >= 86400 and seconds % 86400 == 0:
|
||||
d = seconds // 86400
|
||||
return f"every {d} day(s)"
|
||||
if seconds >= 3600 and seconds % 3600 == 0:
|
||||
h = seconds // 3600
|
||||
return f"every {h} hour(s)"
|
||||
if seconds >= 60 and seconds % 60 == 0:
|
||||
m = seconds // 60
|
||||
return f"every {m} minute(s)"
|
||||
return f"every {seconds} second(s)"
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.4.6"
|
||||
version = "1.5.2"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
"""
|
||||
REAL integration tests for WhatsApp Web mode.
|
||||
|
||||
These tests use:
|
||||
- Real subprocess CLI calls (not mocked)
|
||||
- Real neonize imports (not mocked)
|
||||
- Real WhatsAppBot construction and lifecycle
|
||||
- Real adapter instantiation with neonize
|
||||
- Real gateway config parsing
|
||||
|
||||
NO mocks for neonize or CLI — tests verify actual behavior.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
PRAISONAI_SRC = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "..", "praisonai"
|
||||
)
|
||||
PYTHON = sys.executable
|
||||
|
||||
|
||||
def run_cli(*args, timeout=15, env_extra=None):
|
||||
"""Run a praisonai CLI command as subprocess, return (returncode, stdout, stderr)."""
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = PRAISONAI_SRC + os.pathsep + env.get("PYTHONPATH", "")
|
||||
# Ensure there's at least a dummy key so agent creation doesn't fail
|
||||
env.setdefault("OPENAI_API_KEY", "sk-test-integration-dummy")
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
cmd = [PYTHON, "-m", "praisonai", "bot", "whatsapp"] + list(args)
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
cwd=PRAISONAI_SRC,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def run_cli_background(*args, env_extra=None):
|
||||
"""Start a praisonai CLI command as background process, return Popen."""
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = PRAISONAI_SRC + os.pathsep + env.get("PYTHONPATH", "")
|
||||
env.setdefault("OPENAI_API_KEY", "sk-test-integration-dummy")
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
cmd = [PYTHON, "-m", "praisonai", "bot", "whatsapp"] + list(args)
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=PRAISONAI_SRC,
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 1. CLI HELP AND FLAG TESTS (real subprocess)
|
||||
# =====================================================================
|
||||
|
||||
class TestCLIHelp:
|
||||
"""Verify CLI flags exist and help text is correct via real subprocess."""
|
||||
|
||||
def test_help_shows_mode_flag(self):
|
||||
"""--help output includes --mode flag."""
|
||||
rc, stdout, stderr = run_cli("--help")
|
||||
assert rc == 0
|
||||
assert "--mode" in stdout
|
||||
assert "cloud" in stdout.lower() or "web" in stdout.lower()
|
||||
|
||||
def test_help_shows_creds_dir_flag(self):
|
||||
"""--help output includes --creds-dir flag."""
|
||||
rc, stdout, stderr = run_cli("--help")
|
||||
assert rc == 0
|
||||
assert "--creds-dir" in stdout
|
||||
|
||||
def test_help_shows_experimental_in_description(self):
|
||||
"""--help mentions experimental/QR for web mode."""
|
||||
rc, stdout, stderr = run_cli("--help")
|
||||
assert rc == 0
|
||||
output = stdout.lower()
|
||||
assert "experimental" in output or "qr" in output
|
||||
|
||||
def test_help_shows_cloud_mode_flags(self):
|
||||
"""--help still shows cloud mode flags (--token, --phone-id, etc)."""
|
||||
rc, stdout, stderr = run_cli("--help")
|
||||
assert rc == 0
|
||||
assert "--token" in stdout
|
||||
assert "--phone-id" in stdout
|
||||
assert "--verify-token" in stdout
|
||||
assert "--port" in stdout
|
||||
|
||||
def test_mode_default_is_cloud(self):
|
||||
"""--mode default value is 'cloud' in help."""
|
||||
rc, stdout, stderr = run_cli("--help")
|
||||
assert rc == 0
|
||||
# Find the line with --mode and check default
|
||||
assert "default: cloud" in stdout.lower()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 2. CLI WEB MODE LAUNCH (real subprocess with neonize)
|
||||
# =====================================================================
|
||||
|
||||
class TestCLIWebModeLaunch:
|
||||
"""Verify `praisonai bot whatsapp --mode web` actually starts."""
|
||||
|
||||
def test_web_mode_starts_without_crash(self):
|
||||
"""Web mode starts, prints experimental warning, doesn't crash immediately."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
proc = run_cli_background("--mode", "web", "--creds-dir", tmpdir)
|
||||
try:
|
||||
# Give it a few seconds to start
|
||||
time.sleep(5)
|
||||
# It should still be running (waiting for QR/connection)
|
||||
assert proc.poll() is None, (
|
||||
f"Process exited prematurely with code {proc.returncode}"
|
||||
)
|
||||
finally:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
def test_web_mode_experimental_warning(self):
|
||||
"""Web mode prints experimental warning on stderr/stdout."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
proc = run_cli_background("--mode", "web", "--creds-dir", tmpdir)
|
||||
try:
|
||||
# Give it time to print the warning
|
||||
time.sleep(4)
|
||||
proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stdout, stderr = proc.communicate()
|
||||
combined = stdout + stderr
|
||||
# The warning is printed by the CLI before start.
|
||||
# Once the event loop fix landed, neonize actually connects
|
||||
# to WhatsApp servers, so we may also see websocket messages.
|
||||
assert (
|
||||
"experimental" in combined.lower()
|
||||
or "EXPERIMENTAL" in combined
|
||||
or "web mode" in combined.lower()
|
||||
or "Starting WhatsApp" in combined
|
||||
or "websocket" in combined.lower()
|
||||
or "whatsapp" in combined.lower()
|
||||
), f"Expected WhatsApp output, got: {combined[:500]}"
|
||||
except Exception:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
raise
|
||||
|
||||
def test_web_mode_custom_creds_dir_creates_directory(self):
|
||||
"""Web mode creates the creds directory if it doesn't exist."""
|
||||
tmpdir = os.path.join(tempfile.mkdtemp(), "custom_wa_creds")
|
||||
assert not os.path.exists(tmpdir)
|
||||
|
||||
proc = run_cli_background("--mode", "web", "--creds-dir", tmpdir)
|
||||
try:
|
||||
time.sleep(4)
|
||||
# The creds dir should have been created
|
||||
assert os.path.isdir(tmpdir), f"Creds dir was not created: {tmpdir}"
|
||||
finally:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 3. CLI CLOUD MODE BACKWARD COMPATIBILITY (real subprocess)
|
||||
# =====================================================================
|
||||
|
||||
class TestCLICloudModeCompat:
|
||||
"""Verify cloud mode still works correctly (no regressions)."""
|
||||
|
||||
def test_cloud_mode_requires_token(self):
|
||||
"""Cloud mode (default) without token should error."""
|
||||
# Remove any env tokens
|
||||
rc, stdout, stderr = run_cli(
|
||||
env_extra={"WHATSAPP_ACCESS_TOKEN": "", "WHATSAPP_PHONE_NUMBER_ID": ""}
|
||||
)
|
||||
combined = stdout + stderr
|
||||
# Should mention missing token
|
||||
assert rc != 0 or "token" in combined.lower() or "missing" in combined.lower() or "required" in combined.lower()
|
||||
|
||||
def test_cloud_mode_explicit_flag(self):
|
||||
"""--mode cloud works explicitly."""
|
||||
rc, stdout, stderr = run_cli(
|
||||
"--mode", "cloud",
|
||||
env_extra={"WHATSAPP_ACCESS_TOKEN": "", "WHATSAPP_PHONE_NUMBER_ID": ""}
|
||||
)
|
||||
combined = stdout + stderr
|
||||
# Should ask for token, not crash
|
||||
assert "EXPERIMENTAL" not in combined # no web mode warning
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 4. REAL NEONIZE IMPORT TESTS (not mocked)
|
||||
# =====================================================================
|
||||
|
||||
class TestRealNeonizeImport:
|
||||
"""Test real neonize module imports and API compatibility."""
|
||||
|
||||
def test_neonize_installed(self):
|
||||
"""neonize is importable."""
|
||||
import neonize
|
||||
assert neonize is not None
|
||||
|
||||
def test_neonize_async_client_importable(self):
|
||||
"""NewAClient is importable from neonize.aioze.client."""
|
||||
from neonize.aioze.client import NewAClient
|
||||
assert NewAClient is not None
|
||||
|
||||
def test_neonize_events_importable(self):
|
||||
"""Event types are importable."""
|
||||
from neonize.aioze.events import ConnectedEv, MessageEv, Event
|
||||
assert ConnectedEv is not None
|
||||
assert MessageEv is not None
|
||||
assert Event is not None
|
||||
|
||||
def test_neonize_build_jid_importable(self):
|
||||
"""build_jid utility is importable."""
|
||||
from neonize.utils import build_jid
|
||||
assert build_jid is not None
|
||||
|
||||
def test_neonize_newaclient_signature(self):
|
||||
"""NewAClient.__init__ accepts (name) and NOT database kwarg."""
|
||||
import inspect
|
||||
from neonize.aioze.client import NewAClient
|
||||
sig = inspect.signature(NewAClient.__init__)
|
||||
params = list(sig.parameters.keys())
|
||||
assert "name" in params
|
||||
assert "database" not in params, (
|
||||
"neonize API changed — 'database' is not a valid kwarg"
|
||||
)
|
||||
|
||||
def test_neonize_newaclient_creates_with_path(self):
|
||||
"""NewAClient can be created with a file path as name."""
|
||||
from neonize.aioze.client import NewAClient
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
db_path = os.path.join(tmpdir, "test_client.db")
|
||||
client = NewAClient(db_path)
|
||||
assert client is not None
|
||||
assert client.name == db_path
|
||||
|
||||
def test_neonize_event_system_exists(self):
|
||||
"""NewAClient has an event system that is callable."""
|
||||
from neonize.aioze.client import NewAClient
|
||||
from neonize.aioze.events import Event
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
db_path = os.path.join(tmpdir, "test_events.db")
|
||||
client = NewAClient(db_path)
|
||||
assert hasattr(client, "event")
|
||||
assert isinstance(client.event, Event)
|
||||
assert callable(client.event)
|
||||
|
||||
def test_neonize_event_registration(self):
|
||||
"""Can register a ConnectedEv handler on the client event system."""
|
||||
from neonize.aioze.client import NewAClient
|
||||
from neonize.aioze.events import ConnectedEv
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
db_path = os.path.join(tmpdir, "test_reg.db")
|
||||
client = NewAClient(db_path)
|
||||
|
||||
handler_called = False
|
||||
|
||||
@client.event(ConnectedEv)
|
||||
async def on_connected(c, ev):
|
||||
nonlocal handler_called
|
||||
handler_called = True
|
||||
|
||||
# Verify handler was registered (code 3 = ConnectedEv)
|
||||
assert 3 in client.event.list_func
|
||||
|
||||
def test_neonize_qr_event_registration(self):
|
||||
"""Can register a QR handler via client.event.qr."""
|
||||
from neonize.aioze.client import NewAClient
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
db_path = os.path.join(tmpdir, "test_qr.db")
|
||||
client = NewAClient(db_path)
|
||||
|
||||
@client.event.qr
|
||||
async def on_qr(c, data):
|
||||
pass
|
||||
|
||||
# Verify the QR handler was set
|
||||
assert client.event._qr is not None
|
||||
|
||||
def test_neonize_message_event_registration(self):
|
||||
"""Can register a MessageEv handler."""
|
||||
from neonize.aioze.client import NewAClient
|
||||
from neonize.aioze.events import MessageEv
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
db_path = os.path.join(tmpdir, "test_msg.db")
|
||||
client = NewAClient(db_path)
|
||||
|
||||
@client.event(MessageEv)
|
||||
async def on_message(c, ev):
|
||||
pass
|
||||
|
||||
# code 17 = MessageEv
|
||||
assert 17 in client.event.list_func
|
||||
|
||||
def test_build_jid_creates_jid(self):
|
||||
"""build_jid creates a proper JID protobuf."""
|
||||
from neonize.utils import build_jid
|
||||
jid = build_jid("1234567890", "s.whatsapp.net")
|
||||
assert jid is not None
|
||||
assert hasattr(jid, "User")
|
||||
assert jid.User == "1234567890"
|
||||
|
||||
def test_build_jid_group(self):
|
||||
"""build_jid works for group JIDs."""
|
||||
from neonize.utils import build_jid
|
||||
jid = build_jid("120363012345678901", "g.us")
|
||||
assert jid is not None
|
||||
assert jid.User == "120363012345678901"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 5. REAL ADAPTER TESTS (real neonize, no mocks)
|
||||
# =====================================================================
|
||||
|
||||
class TestRealAdapter:
|
||||
"""Test WhatsAppWebAdapter with real neonize (no mocks)."""
|
||||
|
||||
def test_adapter_import(self):
|
||||
"""Adapter imports correctly."""
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
assert WhatsAppWebAdapter is not None
|
||||
|
||||
def test_adapter_construction(self):
|
||||
"""Adapter constructs with default params."""
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
|
||||
assert adapter.creds_dir == tmpdir
|
||||
assert adapter.is_connected is False
|
||||
assert adapter.self_jid is None
|
||||
|
||||
def test_adapter_db_path_creation(self):
|
||||
"""Adapter creates the creds dir and returns correct db path."""
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
tmpdir = os.path.join(tempfile.mkdtemp(), "nested", "creds")
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
|
||||
db_path = adapter._get_db_path()
|
||||
assert db_path.endswith(".db")
|
||||
assert os.path.isdir(tmpdir)
|
||||
|
||||
def test_adapter_has_saved_session_false(self):
|
||||
"""No saved session in fresh temp dir."""
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
|
||||
assert adapter.has_saved_session() is False
|
||||
|
||||
def test_adapter_normalize_jid_phone(self):
|
||||
"""JID normalization for plain phone numbers."""
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
assert WhatsAppWebAdapter._normalize_jid("+1555123") == "[email protected]"
|
||||
assert WhatsAppWebAdapter._normalize_jid("1234567890") == "[email protected]"
|
||||
|
||||
def test_adapter_normalize_jid_already_jid(self):
|
||||
"""JID normalization passes through existing JIDs."""
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
assert WhatsAppWebAdapter._normalize_jid("[email protected]") == "[email protected]"
|
||||
assert WhatsAppWebAdapter._normalize_jid("[email protected]") == "[email protected]"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_connect_creates_neonize_client(self):
|
||||
"""connect() creates a real neonize NewAClient and registers handlers."""
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
|
||||
|
||||
# We can't complete the full connect (it blocks on WhatsApp servers),
|
||||
# but we can verify the client creation and handler registration
|
||||
# by monkey-patching connect() to just do setup without the network call.
|
||||
from neonize.aioze.client import NewAClient
|
||||
|
||||
original_connect = NewAClient.connect
|
||||
|
||||
connect_was_called = False
|
||||
|
||||
async def patched_connect(self_client):
|
||||
nonlocal connect_was_called
|
||||
connect_was_called = True
|
||||
# Don't actually connect to WhatsApp servers
|
||||
raise asyncio.CancelledError("test: skip real connection")
|
||||
|
||||
NewAClient.connect = patched_connect
|
||||
try:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await adapter.connect()
|
||||
assert connect_was_called
|
||||
assert adapter._client is not None
|
||||
# Verify event handlers were registered on the REAL neonize client
|
||||
assert 3 in adapter._client.event.list_func # ConnectedEv
|
||||
assert 17 in adapter._client.event.list_func # MessageEv
|
||||
finally:
|
||||
NewAClient.connect = original_connect
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_connect_bridges_event_loop(self):
|
||||
"""connect() patches neonize's event_global_loop with the running loop.
|
||||
|
||||
Root cause fix: neonize creates event_global_loop = asyncio.new_event_loop()
|
||||
at import time but never starts it. Go-thread callbacks (QR, messages)
|
||||
are posted to that dead loop. Our adapter must replace it with the
|
||||
currently running loop so callbacks actually execute.
|
||||
"""
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
from neonize.aioze.client import NewAClient
|
||||
import neonize.aioze.events as nz_events
|
||||
import neonize.aioze.client as nz_client
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
|
||||
|
||||
running_loop = asyncio.get_running_loop()
|
||||
|
||||
# Before connect: neonize's loop is NOT our running loop
|
||||
original_nz_loop = nz_events.event_global_loop
|
||||
|
||||
original_connect = NewAClient.connect
|
||||
async def patched_connect(self_client):
|
||||
raise ConnectionError("test: skip real connection")
|
||||
|
||||
NewAClient.connect = patched_connect
|
||||
try:
|
||||
with pytest.raises(ConnectionError):
|
||||
await adapter.connect()
|
||||
|
||||
# After connect: neonize's event loops must be bridged to ours
|
||||
assert nz_events.event_global_loop is running_loop
|
||||
assert nz_client.event_global_loop is running_loop
|
||||
assert adapter._client.loop is running_loop
|
||||
finally:
|
||||
NewAClient.connect = original_connect
|
||||
# Restore original loop to avoid test pollution
|
||||
nz_events.event_global_loop = original_nz_loop
|
||||
nz_client.event_global_loop = original_nz_loop
|
||||
|
||||
def test_adapter_logout_cleans_db(self):
|
||||
"""logout() removes the DB file."""
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
|
||||
# Create a fake DB file
|
||||
db_path = adapter._get_db_path()
|
||||
with open(db_path, "w") as f:
|
||||
f.write("fake db")
|
||||
assert adapter.has_saved_session() is True
|
||||
|
||||
asyncio.run(adapter.logout())
|
||||
assert adapter.has_saved_session() is False
|
||||
assert not os.path.exists(db_path)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 6. REAL WHATSAPP BOT DUAL-MODE TESTS (real imports)
|
||||
# =====================================================================
|
||||
|
||||
class TestRealWhatsAppBot:
|
||||
"""Test WhatsAppBot with real imports (not mocked)."""
|
||||
|
||||
def _get_bot_class(self):
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
return WhatsAppBot
|
||||
|
||||
def test_bot_web_mode_construction(self):
|
||||
"""WhatsAppBot(mode='web') constructs without error."""
|
||||
WhatsAppBot = self._get_bot_class()
|
||||
bot = WhatsAppBot(mode="web")
|
||||
assert bot.mode == "web"
|
||||
|
||||
def test_bot_cloud_mode_construction(self):
|
||||
"""WhatsAppBot(mode='cloud') constructs without error."""
|
||||
WhatsAppBot = self._get_bot_class()
|
||||
bot = WhatsAppBot(token="test-token", phone_number_id="12345", mode="cloud")
|
||||
assert bot.mode == "cloud"
|
||||
|
||||
def test_bot_default_mode_is_cloud(self):
|
||||
"""Default mode is cloud."""
|
||||
WhatsAppBot = self._get_bot_class()
|
||||
bot = WhatsAppBot(token="test-token")
|
||||
assert bot.mode == "cloud"
|
||||
|
||||
def test_bot_invalid_mode_raises(self):
|
||||
"""Invalid mode raises ValueError."""
|
||||
WhatsAppBot = self._get_bot_class()
|
||||
with pytest.raises(ValueError, match="Invalid mode"):
|
||||
WhatsAppBot(mode="invalid")
|
||||
|
||||
def test_bot_web_mode_with_custom_creds(self):
|
||||
"""Web mode accepts custom creds_dir."""
|
||||
WhatsAppBot = self._get_bot_class()
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
bot = WhatsAppBot(mode="web", creds_dir=tmpdir)
|
||||
assert bot.mode == "web"
|
||||
|
||||
def test_bot_backward_compat_positional_token(self):
|
||||
"""Positional token arg still works (backward compat)."""
|
||||
WhatsAppBot = self._get_bot_class()
|
||||
bot = WhatsAppBot("my-token-123")
|
||||
assert bot.mode == "cloud"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_web_mode_start_creates_adapter(self):
|
||||
"""Starting web mode creates a real WhatsAppWebAdapter and attempts connect."""
|
||||
WhatsAppBot = self._get_bot_class()
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
bot = WhatsAppBot(mode="web", creds_dir=tmpdir)
|
||||
|
||||
from neonize.aioze.client import NewAClient
|
||||
original_connect = NewAClient.connect
|
||||
|
||||
connect_was_called = False
|
||||
|
||||
async def patched_connect(self_client):
|
||||
nonlocal connect_was_called
|
||||
connect_was_called = True
|
||||
# Simulate connection failure so _start_web_mode raises
|
||||
raise ConnectionError("test: skip real connection")
|
||||
|
||||
NewAClient.connect = patched_connect
|
||||
try:
|
||||
# _start_web_mode catches and re-raises non-CancelledError exceptions
|
||||
with pytest.raises(ConnectionError):
|
||||
await bot.start()
|
||||
assert connect_was_called, "NewAClient.connect was never called"
|
||||
finally:
|
||||
NewAClient.connect = original_connect
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_stop_is_safe(self):
|
||||
"""stop() can be called even if bot never started."""
|
||||
WhatsAppBot = self._get_bot_class()
|
||||
bot = WhatsAppBot(mode="web")
|
||||
await bot.stop() # Should not raise
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 7. GATEWAY CONFIG PARSING (real code, no mocks)
|
||||
# =====================================================================
|
||||
|
||||
class TestRealGatewayConfig:
|
||||
"""Test gateway config parsing with WhatsApp web mode."""
|
||||
|
||||
def _get_gateway_class(self):
|
||||
sys.path.insert(0, PRAISONAI_SRC)
|
||||
from praisonai.gateway.server import WebSocketGateway
|
||||
return WebSocketGateway
|
||||
|
||||
def test_gateway_config_web_mode_no_token(self):
|
||||
"""Gateway config accepts WhatsApp web mode without token."""
|
||||
WebSocketGateway = self._get_gateway_class()
|
||||
config = {
|
||||
"gateway": {"host": "127.0.0.1", "port": 8765},
|
||||
"agents": {"a": {"name": "A", "instructions": "X", "llm": "gpt-4o-mini"}},
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"mode": "web",
|
||||
"routing": {"default": "a"},
|
||||
}
|
||||
},
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
yaml.dump(config, f)
|
||||
tmppath = f.name
|
||||
try:
|
||||
cfg = WebSocketGateway.load_gateway_config(tmppath)
|
||||
assert cfg["channels"]["whatsapp"]["mode"] == "web"
|
||||
finally:
|
||||
os.unlink(tmppath)
|
||||
|
||||
def test_gateway_config_cloud_mode_requires_token(self):
|
||||
"""Gateway config rejects cloud mode WhatsApp without token."""
|
||||
WebSocketGateway = self._get_gateway_class()
|
||||
config = {
|
||||
"gateway": {"host": "127.0.0.1", "port": 8765},
|
||||
"agents": {"a": {"name": "A", "instructions": "X", "llm": "gpt-4o-mini"}},
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"mode": "cloud",
|
||||
"routing": {"default": "a"},
|
||||
}
|
||||
},
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
yaml.dump(config, f)
|
||||
tmppath = f.name
|
||||
try:
|
||||
with pytest.raises(ValueError, match="missing.*token"):
|
||||
WebSocketGateway.load_gateway_config(tmppath)
|
||||
finally:
|
||||
os.unlink(tmppath)
|
||||
|
||||
def test_gateway_config_web_mode_with_creds_dir(self):
|
||||
"""Gateway config passes creds_dir through."""
|
||||
WebSocketGateway = self._get_gateway_class()
|
||||
config = {
|
||||
"gateway": {"host": "127.0.0.1", "port": 8765},
|
||||
"agents": {"a": {"name": "A", "instructions": "X", "llm": "gpt-4o-mini"}},
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"mode": "web",
|
||||
"creds_dir": "/tmp/custom_creds",
|
||||
"routing": {"default": "a"},
|
||||
}
|
||||
},
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
yaml.dump(config, f)
|
||||
tmppath = f.name
|
||||
try:
|
||||
cfg = WebSocketGateway.load_gateway_config(tmppath)
|
||||
assert cfg["channels"]["whatsapp"]["creds_dir"] == "/tmp/custom_creds"
|
||||
finally:
|
||||
os.unlink(tmppath)
|
||||
|
||||
def test_gateway_config_default_mode_is_cloud(self):
|
||||
"""Gateway config without explicit mode defaults to cloud behavior."""
|
||||
WebSocketGateway = self._get_gateway_class()
|
||||
config = {
|
||||
"gateway": {"host": "127.0.0.1", "port": 8765},
|
||||
"agents": {"a": {"name": "A", "instructions": "X", "llm": "gpt-4o-mini"}},
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"token": "test-token",
|
||||
"phone_number_id": "12345",
|
||||
"routing": {"default": "a"},
|
||||
}
|
||||
},
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
yaml.dump(config, f)
|
||||
tmppath = f.name
|
||||
try:
|
||||
cfg = WebSocketGateway.load_gateway_config(tmppath)
|
||||
# mode should default to cloud (not explicit in config)
|
||||
assert cfg["channels"]["whatsapp"].get("mode", "cloud") == "cloud"
|
||||
finally:
|
||||
os.unlink(tmppath)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 8. YAML CONFIG BOT LAUNCH (real bots_cli)
|
||||
# =====================================================================
|
||||
|
||||
class TestRealYAMLConfig:
|
||||
"""Test YAML-based bot configuration with web mode."""
|
||||
|
||||
def test_yaml_example_file_exists(self):
|
||||
"""WhatsApp web bot YAML example exists."""
|
||||
yaml_path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "..", "..",
|
||||
"examples", "yaml", "whatsapp-web-bot.yaml"
|
||||
)
|
||||
assert os.path.exists(yaml_path), f"Missing: {yaml_path}"
|
||||
|
||||
def test_yaml_example_is_valid(self):
|
||||
"""WhatsApp web bot YAML example is valid YAML with correct fields."""
|
||||
yaml_path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "..", "..",
|
||||
"examples", "yaml", "whatsapp-web-bot.yaml"
|
||||
)
|
||||
with open(yaml_path) as f:
|
||||
config = yaml.safe_load(f)
|
||||
assert config is not None
|
||||
assert config.get("platform") == "whatsapp"
|
||||
assert config.get("mode") == "web"
|
||||
|
||||
def test_gateway_yaml_has_web_mode_example(self):
|
||||
"""Gateway YAML example includes web mode comments."""
|
||||
yaml_path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "..", "..",
|
||||
"examples", "yaml", "gateway.yaml"
|
||||
)
|
||||
with open(yaml_path) as f:
|
||||
content = f.read()
|
||||
assert "mode: web" in content
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 9. PERFORMANCE / IMPORT TESTS
|
||||
# =====================================================================
|
||||
|
||||
class TestPerformance:
|
||||
"""Verify no performance regressions."""
|
||||
|
||||
def test_praisonai_bots_import_fast(self):
|
||||
"""Importing praisonai.bots.whatsapp doesn't load neonize."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
PYTHON, "-c",
|
||||
"import sys; "
|
||||
"sys.path.insert(0, '.'); "
|
||||
"from praisonai.bots.whatsapp import WhatsAppBot; "
|
||||
"print('neonize_loaded' if 'neonize' in sys.modules else 'neonize_NOT_loaded')"
|
||||
],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
cwd=PRAISONAI_SRC,
|
||||
)
|
||||
assert "neonize_NOT_loaded" in result.stdout, (
|
||||
"neonize should NOT be loaded at import time"
|
||||
)
|
||||
|
||||
def test_adapter_import_does_not_load_neonize(self):
|
||||
"""Importing the adapter module doesn't load neonize."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
PYTHON, "-c",
|
||||
"import sys; "
|
||||
"sys.path.insert(0, '.'); "
|
||||
"from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter; "
|
||||
"print('neonize_loaded' if 'neonize' in sys.modules else 'neonize_NOT_loaded')"
|
||||
],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
cwd=PRAISONAI_SRC,
|
||||
)
|
||||
assert "neonize_NOT_loaded" in result.stdout, (
|
||||
"neonize should NOT be loaded at adapter import time"
|
||||
)
|
||||
|
||||
def test_bot_construction_does_not_load_neonize(self):
|
||||
"""Constructing WhatsAppBot(mode='web') doesn't load neonize."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
PYTHON, "-c",
|
||||
"import sys; "
|
||||
"sys.path.insert(0, '.'); "
|
||||
"from praisonai.bots.whatsapp import WhatsAppBot; "
|
||||
"bot = WhatsAppBot(mode='web'); "
|
||||
"print('neonize_loaded' if 'neonize' in sys.modules else 'neonize_NOT_loaded')"
|
||||
],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
cwd=PRAISONAI_SRC,
|
||||
)
|
||||
assert "neonize_NOT_loaded" in result.stdout, (
|
||||
"neonize should NOT be loaded until connect() is called"
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
TDD tests for AgentApproval backend.
|
||||
|
||||
Tests the agent-based approval backend that delegates approval decisions
|
||||
to another AI agent in the core SDK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Protocol Conformance ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAgentApprovalProtocol:
|
||||
def test_conforms_to_approval_protocol(self):
|
||||
from praisonaiagents.approval.protocols import ApprovalProtocol
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
mock_agent = MagicMock()
|
||||
backend = AgentApproval(approver_agent=mock_agent)
|
||||
assert isinstance(backend, ApprovalProtocol)
|
||||
|
||||
def test_has_request_approval_sync(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
backend = AgentApproval(approver_agent=MagicMock())
|
||||
assert hasattr(backend, "request_approval_sync")
|
||||
assert callable(backend.request_approval_sync)
|
||||
|
||||
def test_has_request_approval_async(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
backend = AgentApproval(approver_agent=MagicMock())
|
||||
assert asyncio.iscoroutinefunction(backend.request_approval)
|
||||
|
||||
|
||||
# ── Construction ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAgentApprovalInit:
|
||||
def test_with_explicit_agent(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "my-reviewer"
|
||||
backend = AgentApproval(approver_agent=mock_agent)
|
||||
assert backend._approver_agent is mock_agent
|
||||
|
||||
def test_repr_with_agent(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "sec-bot"
|
||||
backend = AgentApproval(approver_agent=mock_agent)
|
||||
assert "sec-bot" in repr(backend)
|
||||
|
||||
def test_repr_without_agent(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
backend = AgentApproval()
|
||||
assert "default" in repr(backend)
|
||||
|
||||
|
||||
# ── Prompt Builder ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPromptBuilder:
|
||||
def _make_request(self, **overrides):
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
|
||||
defaults = {
|
||||
"tool_name": "execute_command",
|
||||
"arguments": {"cmd": "rm -rf /"},
|
||||
"risk_level": "critical",
|
||||
"agent_name": "worker",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ApprovalRequest(**defaults)
|
||||
|
||||
def test_prompt_contains_tool_name(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
backend = AgentApproval(approver_agent=MagicMock())
|
||||
prompt = backend._build_prompt(self._make_request(tool_name="delete_file"))
|
||||
assert "delete_file" in prompt
|
||||
|
||||
def test_prompt_contains_risk_level(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
backend = AgentApproval(approver_agent=MagicMock())
|
||||
prompt = backend._build_prompt(self._make_request(risk_level="high"))
|
||||
assert "HIGH" in prompt
|
||||
|
||||
def test_prompt_contains_arguments(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
backend = AgentApproval(approver_agent=MagicMock())
|
||||
prompt = backend._build_prompt(self._make_request(arguments={"path": "/tmp"}))
|
||||
assert "/tmp" in prompt
|
||||
|
||||
def test_prompt_ends_with_instruction(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
backend = AgentApproval(approver_agent=MagicMock())
|
||||
prompt = backend._build_prompt(self._make_request())
|
||||
assert "APPROVE" in prompt
|
||||
assert "DENY" in prompt
|
||||
|
||||
|
||||
# ── Async Approval Flow ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApprovalFlowAsync:
|
||||
def _make_request(self, **overrides):
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
|
||||
defaults = {
|
||||
"tool_name": "execute_command",
|
||||
"arguments": {"cmd": "ls"},
|
||||
"risk_level": "low",
|
||||
"agent_name": "worker",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ApprovalRequest(**defaults)
|
||||
|
||||
def test_approved_by_agent(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "reviewer"
|
||||
mock_agent.achat = AsyncMock(return_value="APPROVE")
|
||||
|
||||
backend = AgentApproval(approver_agent=mock_agent)
|
||||
decision = asyncio.run(backend.request_approval(self._make_request()))
|
||||
assert decision.approved is True
|
||||
assert decision.approver == "reviewer"
|
||||
|
||||
def test_denied_by_agent(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "reviewer"
|
||||
mock_agent.achat = AsyncMock(return_value="DENY")
|
||||
|
||||
backend = AgentApproval(approver_agent=mock_agent)
|
||||
decision = asyncio.run(backend.request_approval(self._make_request()))
|
||||
assert decision.approved is False
|
||||
|
||||
def test_ambiguous_response_denied(self):
|
||||
"""If response contains both APPROVE and DENY, it's denied."""
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "reviewer"
|
||||
mock_agent.achat = AsyncMock(return_value="I would APPROVE but actually DENY this")
|
||||
|
||||
backend = AgentApproval(approver_agent=mock_agent)
|
||||
decision = asyncio.run(backend.request_approval(self._make_request()))
|
||||
assert decision.approved is False
|
||||
|
||||
def test_falls_back_to_sync_chat(self):
|
||||
"""If agent has no achat, uses chat via executor."""
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "reviewer"
|
||||
# Remove achat so it falls back to chat
|
||||
del mock_agent.achat
|
||||
mock_agent.chat = MagicMock(return_value="APPROVE")
|
||||
|
||||
backend = AgentApproval(approver_agent=mock_agent)
|
||||
decision = asyncio.run(backend.request_approval(self._make_request()))
|
||||
assert decision.approved is True
|
||||
|
||||
def test_error_returns_denial(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "reviewer"
|
||||
mock_agent.achat = AsyncMock(side_effect=RuntimeError("LLM down"))
|
||||
|
||||
backend = AgentApproval(approver_agent=mock_agent)
|
||||
decision = asyncio.run(backend.request_approval(self._make_request()))
|
||||
assert decision.approved is False
|
||||
assert "error" in decision.reason.lower()
|
||||
|
||||
def test_metadata_contains_response(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "reviewer"
|
||||
mock_agent.achat = AsyncMock(return_value="APPROVE - this looks safe")
|
||||
|
||||
backend = AgentApproval(approver_agent=mock_agent)
|
||||
decision = asyncio.run(backend.request_approval(self._make_request()))
|
||||
assert decision.metadata.get("platform") == "agent"
|
||||
assert "safe" in decision.metadata.get("response", "")
|
||||
|
||||
|
||||
# ── Export / Import ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExports:
|
||||
def test_import_from_approval_package(self):
|
||||
from praisonaiagents.approval import AgentApproval
|
||||
assert AgentApproval is not None
|
||||
|
||||
def test_import_from_backends(self):
|
||||
from praisonaiagents.approval.backends import AgentApproval
|
||||
assert AgentApproval is not None
|
||||
@@ -34,6 +34,7 @@ class TestBotCapabilities:
|
||||
assert caps.sandbox is False
|
||||
assert caps.exec_enabled is False
|
||||
assert caps.model is None
|
||||
assert caps.auto_approve is False
|
||||
|
||||
def test_bot_capabilities_custom_values(self):
|
||||
"""Test custom values for BotCapabilities."""
|
||||
@@ -144,8 +145,7 @@ class TestBotHandler:
|
||||
assert "Capabilities:" in captured.out
|
||||
assert "browser" in captured.out
|
||||
assert "memory" in captured.out
|
||||
|
||||
|
||||
|
||||
class TestHandleBotCommand:
|
||||
"""Tests for handle_bot_command function."""
|
||||
|
||||
@@ -279,3 +279,4 @@ class TestAddCapabilityArgs:
|
||||
assert args.web_search is True
|
||||
assert args.model == "gpt-4o"
|
||||
assert args.tools == ["DuckDuckGoTool", "WikipediaTool"]
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
TDD Tests for Bot Session Persistence.
|
||||
|
||||
Tests that BotSessionManager uses SessionStoreProtocol for persistent
|
||||
per-user session isolation instead of in-memory-only storage.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
"""Minimal Agent mock for testing BotSessionManager."""
|
||||
|
||||
def __init__(self, name: str = "TestAgent"):
|
||||
self.name = name
|
||||
self.chat_history: List[Dict[str, Any]] = []
|
||||
|
||||
def chat(self, prompt: str) -> str:
|
||||
self.chat_history.append({"role": "user", "content": prompt})
|
||||
response = f"Response to: {prompt}"
|
||||
self.chat_history.append({"role": "assistant", "content": response})
|
||||
return response
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
"""Helper to run async coroutines in tests."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
class TestBotSessionManagerWithStore:
|
||||
"""Tests for BotSessionManager using persistent session store."""
|
||||
|
||||
def _make_manager(self, tmpdir: str, platform: str = "test"):
|
||||
"""Create a BotSessionManager with a persistent store."""
|
||||
# Import here to test the refactored version
|
||||
import sys
|
||||
import os
|
||||
# Add wrapper path so we can import _session
|
||||
wrapper_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__))
|
||||
)))),
|
||||
"praisonai", "praisonai", "bots"
|
||||
)
|
||||
if wrapper_path not in sys.path:
|
||||
sys.path.insert(0, wrapper_path)
|
||||
|
||||
from _session import BotSessionManager
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
return BotSessionManager(store=store, platform=platform)
|
||||
|
||||
def test_constructor_accepts_store(self):
|
||||
"""BotSessionManager must accept a store parameter."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mgr = self._make_manager(tmpdir)
|
||||
assert mgr is not None
|
||||
|
||||
def test_constructor_accepts_platform(self):
|
||||
"""BotSessionManager must accept a platform parameter."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mgr = self._make_manager(tmpdir, platform="telegram")
|
||||
assert mgr._platform == "telegram"
|
||||
|
||||
def test_chat_persists_to_store(self):
|
||||
"""After chat(), messages must be persisted in the store."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mgr = self._make_manager(tmpdir)
|
||||
agent = FakeAgent()
|
||||
|
||||
_run_async(mgr.chat(agent, "user1", "Hello"))
|
||||
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
key = "bot_test_user1"
|
||||
history = store.get_chat_history(key)
|
||||
assert len(history) >= 2
|
||||
assert history[0]["role"] == "user"
|
||||
assert history[0]["content"] == "Hello"
|
||||
|
||||
def test_chat_restores_from_store(self):
|
||||
"""On second call, history must be loaded from store."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mgr = self._make_manager(tmpdir)
|
||||
agent = FakeAgent()
|
||||
|
||||
_run_async(mgr.chat(agent, "user1", "Hello"))
|
||||
_run_async(mgr.chat(agent, "user1", "How are you?"))
|
||||
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
key = "bot_test_user1"
|
||||
history = store.get_chat_history(key)
|
||||
# Should have 4 messages: user+assistant from each call
|
||||
assert len(history) >= 4
|
||||
|
||||
def test_multi_user_isolation(self):
|
||||
"""Different users must have isolated sessions."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mgr = self._make_manager(tmpdir)
|
||||
agent = FakeAgent()
|
||||
|
||||
_run_async(mgr.chat(agent, "alice", "Hi from Alice"))
|
||||
_run_async(mgr.chat(agent, "bob", "Hi from Bob"))
|
||||
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
alice_history = store.get_chat_history("bot_test_alice")
|
||||
bob_history = store.get_chat_history("bot_test_bob")
|
||||
|
||||
assert len(alice_history) >= 2
|
||||
assert len(bob_history) >= 2
|
||||
assert alice_history[0]["content"] == "Hi from Alice"
|
||||
assert bob_history[0]["content"] == "Hi from Bob"
|
||||
|
||||
def test_agent_history_not_corrupted(self):
|
||||
"""Agent's own chat_history must not be corrupted by bot sessions."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mgr = self._make_manager(tmpdir)
|
||||
agent = FakeAgent()
|
||||
agent.chat_history = [{"role": "system", "content": "Original"}]
|
||||
|
||||
_run_async(mgr.chat(agent, "user1", "Hello"))
|
||||
|
||||
# Agent's history must be restored to original
|
||||
assert len(agent.chat_history) == 1
|
||||
assert agent.chat_history[0]["content"] == "Original"
|
||||
|
||||
def test_reset_clears_store(self):
|
||||
"""reset() must clear user's session from the store."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mgr = self._make_manager(tmpdir)
|
||||
agent = FakeAgent()
|
||||
|
||||
_run_async(mgr.chat(agent, "user1", "Hello"))
|
||||
mgr.reset("user1")
|
||||
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
key = "bot_test_user1"
|
||||
history = store.get_chat_history(key)
|
||||
assert len(history) == 0
|
||||
|
||||
def test_reset_all_clears_all_store_sessions(self):
|
||||
"""reset_all() must clear all bot sessions from the store."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mgr = self._make_manager(tmpdir)
|
||||
agent = FakeAgent()
|
||||
|
||||
_run_async(mgr.chat(agent, "user1", "Hi"))
|
||||
_run_async(mgr.chat(agent, "user2", "Hey"))
|
||||
|
||||
count = mgr.reset_all()
|
||||
assert count == 2
|
||||
|
||||
def test_persistence_across_manager_instances(self):
|
||||
"""Sessions must survive across BotSessionManager instances."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# First manager instance
|
||||
mgr1 = self._make_manager(tmpdir)
|
||||
agent = FakeAgent()
|
||||
_run_async(mgr1.chat(agent, "user1", "Remember this"))
|
||||
|
||||
# Second manager instance (simulates restart)
|
||||
mgr2 = self._make_manager(tmpdir)
|
||||
agent2 = FakeAgent()
|
||||
_run_async(mgr2.chat(agent2, "user1", "What did I say?"))
|
||||
|
||||
# Check agent2's chat_history during the call had previous context
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
key = "bot_test_user1"
|
||||
history = store.get_chat_history(key)
|
||||
# Should have messages from both sessions
|
||||
assert len(history) >= 4
|
||||
assert history[0]["content"] == "Remember this"
|
||||
|
||||
def test_backward_compat_no_store(self):
|
||||
"""BotSessionManager must still work without a store (in-memory fallback)."""
|
||||
import sys
|
||||
import os
|
||||
wrapper_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__))
|
||||
)))),
|
||||
"praisonai", "praisonai", "bots"
|
||||
)
|
||||
if wrapper_path not in sys.path:
|
||||
sys.path.insert(0, wrapper_path)
|
||||
|
||||
from _session import BotSessionManager
|
||||
# No store parameter = backward compatible in-memory mode
|
||||
mgr = BotSessionManager()
|
||||
agent = FakeAgent()
|
||||
|
||||
_run_async(mgr.chat(agent, "user1", "Hello"))
|
||||
assert mgr.active_sessions >= 1
|
||||
|
||||
def test_session_key_format(self):
|
||||
"""Session key must follow bot_{platform}_{user_id} format."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mgr = self._make_manager(tmpdir, platform="telegram")
|
||||
agent = FakeAgent()
|
||||
|
||||
_run_async(mgr.chat(agent, "12345", "Hello"))
|
||||
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
assert store.session_exists("bot_telegram_12345")
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
TDD Tests for SessionStoreProtocol.
|
||||
|
||||
Tests protocol conformance for DefaultSessionStore, HierarchicalSessionStore,
|
||||
and a mock implementation to prove swappability.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class MockSessionStore:
|
||||
"""Minimal mock that implements SessionStoreProtocol interface.
|
||||
|
||||
Proves that any class matching the protocol shape is accepted.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._sessions: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
if session_id not in self._sessions:
|
||||
self._sessions[session_id] = []
|
||||
self._sessions[session_id].append({"role": role, "content": content})
|
||||
return True
|
||||
|
||||
def get_chat_history(
|
||||
self,
|
||||
session_id: str,
|
||||
max_messages: Optional[int] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
msgs = self._sessions.get(session_id, [])
|
||||
if max_messages:
|
||||
msgs = msgs[-max_messages:]
|
||||
return msgs
|
||||
|
||||
def clear_session(self, session_id: str) -> bool:
|
||||
self._sessions[session_id] = []
|
||||
return True
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
self._sessions.pop(session_id, None)
|
||||
return True
|
||||
|
||||
def session_exists(self, session_id: str) -> bool:
|
||||
return session_id in self._sessions
|
||||
|
||||
|
||||
class TestSessionStoreProtocolExists:
|
||||
"""Test that SessionStoreProtocol can be imported."""
|
||||
|
||||
def test_import_protocol(self):
|
||||
"""SessionStoreProtocol must be importable from session.protocols."""
|
||||
from praisonaiagents.session.protocols import SessionStoreProtocol
|
||||
assert SessionStoreProtocol is not None
|
||||
|
||||
def test_protocol_is_runtime_checkable(self):
|
||||
"""Protocol must be @runtime_checkable for isinstance() checks."""
|
||||
from praisonaiagents.session.protocols import SessionStoreProtocol
|
||||
# Should not raise
|
||||
isinstance(object(), SessionStoreProtocol)
|
||||
|
||||
def test_import_from_session_package(self):
|
||||
"""SessionStoreProtocol must be importable from session package."""
|
||||
from praisonaiagents.session import SessionStoreProtocol
|
||||
assert SessionStoreProtocol is not None
|
||||
|
||||
|
||||
class TestDefaultSessionStoreConformance:
|
||||
"""DefaultSessionStore must satisfy SessionStoreProtocol."""
|
||||
|
||||
def test_isinstance_check(self):
|
||||
"""DefaultSessionStore must pass isinstance(store, SessionStoreProtocol)."""
|
||||
from praisonaiagents.session.protocols import SessionStoreProtocol
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
assert isinstance(store, SessionStoreProtocol)
|
||||
|
||||
def test_has_add_message(self):
|
||||
"""DefaultSessionStore must have add_message method."""
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
assert hasattr(DefaultSessionStore, 'add_message')
|
||||
assert callable(getattr(DefaultSessionStore, 'add_message'))
|
||||
|
||||
def test_has_get_chat_history(self):
|
||||
"""DefaultSessionStore must have get_chat_history method."""
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
assert hasattr(DefaultSessionStore, 'get_chat_history')
|
||||
assert callable(getattr(DefaultSessionStore, 'get_chat_history'))
|
||||
|
||||
def test_has_clear_session(self):
|
||||
"""DefaultSessionStore must have clear_session method."""
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
assert hasattr(DefaultSessionStore, 'clear_session')
|
||||
assert callable(getattr(DefaultSessionStore, 'clear_session'))
|
||||
|
||||
def test_has_delete_session(self):
|
||||
"""DefaultSessionStore must have delete_session method."""
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
assert hasattr(DefaultSessionStore, 'delete_session')
|
||||
assert callable(getattr(DefaultSessionStore, 'delete_session'))
|
||||
|
||||
def test_has_session_exists(self):
|
||||
"""DefaultSessionStore must have session_exists method."""
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
assert hasattr(DefaultSessionStore, 'session_exists')
|
||||
assert callable(getattr(DefaultSessionStore, 'session_exists'))
|
||||
|
||||
|
||||
class TestHierarchicalSessionStoreConformance:
|
||||
"""HierarchicalSessionStore must also satisfy SessionStoreProtocol."""
|
||||
|
||||
def test_isinstance_check(self):
|
||||
"""HierarchicalSessionStore must pass isinstance check."""
|
||||
from praisonaiagents.session.protocols import SessionStoreProtocol
|
||||
from praisonaiagents.session.hierarchy import HierarchicalSessionStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
assert isinstance(store, SessionStoreProtocol)
|
||||
|
||||
|
||||
class TestMockStoreConformance:
|
||||
"""Mock store implementing protocol interface must pass isinstance check."""
|
||||
|
||||
def test_mock_isinstance_check(self):
|
||||
"""MockSessionStore must pass isinstance(mock, SessionStoreProtocol)."""
|
||||
from praisonaiagents.session.protocols import SessionStoreProtocol
|
||||
|
||||
mock = MockSessionStore()
|
||||
assert isinstance(mock, SessionStoreProtocol)
|
||||
|
||||
def test_mock_add_and_get(self):
|
||||
"""Mock store must support add_message and get_chat_history."""
|
||||
mock = MockSessionStore()
|
||||
mock.add_message("s1", "user", "Hello")
|
||||
mock.add_message("s1", "assistant", "Hi!")
|
||||
|
||||
history = mock.get_chat_history("s1")
|
||||
assert len(history) == 2
|
||||
assert history[0]["role"] == "user"
|
||||
assert history[1]["role"] == "assistant"
|
||||
|
||||
def test_mock_clear_session(self):
|
||||
"""Mock store must support clear_session."""
|
||||
mock = MockSessionStore()
|
||||
mock.add_message("s1", "user", "Hello")
|
||||
mock.clear_session("s1")
|
||||
assert mock.get_chat_history("s1") == []
|
||||
|
||||
def test_mock_delete_session(self):
|
||||
"""Mock store must support delete_session."""
|
||||
mock = MockSessionStore()
|
||||
mock.add_message("s1", "user", "Hello")
|
||||
mock.delete_session("s1")
|
||||
assert not mock.session_exists("s1")
|
||||
|
||||
def test_mock_session_exists(self):
|
||||
"""Mock store must support session_exists."""
|
||||
mock = MockSessionStore()
|
||||
assert not mock.session_exists("s1")
|
||||
mock.add_message("s1", "user", "Hello")
|
||||
assert mock.session_exists("s1")
|
||||
|
||||
|
||||
class TestProtocolSwappability:
|
||||
"""Test that protocol enables swapping stores without code changes."""
|
||||
|
||||
def _use_store(self, store):
|
||||
"""Helper that only depends on SessionStoreProtocol interface."""
|
||||
from praisonaiagents.session.protocols import SessionStoreProtocol
|
||||
assert isinstance(store, SessionStoreProtocol)
|
||||
|
||||
store.add_message("test-swap", "user", "Hello from protocol")
|
||||
history = store.get_chat_history("test-swap")
|
||||
assert len(history) >= 1
|
||||
assert history[-1]["content"] == "Hello from protocol"
|
||||
|
||||
store.clear_session("test-swap")
|
||||
assert store.get_chat_history("test-swap") == []
|
||||
|
||||
store.delete_session("test-swap")
|
||||
|
||||
def test_swap_default_store(self):
|
||||
"""DefaultSessionStore works through protocol interface."""
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
self._use_store(store)
|
||||
|
||||
def test_swap_hierarchical_store(self):
|
||||
"""HierarchicalSessionStore works through protocol interface."""
|
||||
from praisonaiagents.session.hierarchy import HierarchicalSessionStore
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
self._use_store(store)
|
||||
|
||||
def test_swap_mock_store(self):
|
||||
"""MockSessionStore works through protocol interface."""
|
||||
mock = MockSessionStore()
|
||||
self._use_store(mock)
|
||||
|
||||
|
||||
class TestSessionExports:
|
||||
"""Test that all session components are properly exported."""
|
||||
|
||||
def test_hierarchy_exports(self):
|
||||
"""HierarchicalSessionStore must be importable from session package."""
|
||||
from praisonaiagents.session import HierarchicalSessionStore
|
||||
assert HierarchicalSessionStore is not None
|
||||
|
||||
def test_hierarchy_store_getter(self):
|
||||
"""get_hierarchical_session_store must be importable from session package."""
|
||||
from praisonaiagents.session import get_hierarchical_session_store
|
||||
assert callable(get_hierarchical_session_store)
|
||||
|
||||
def test_session_snapshot_export(self):
|
||||
"""SessionSnapshot must be importable from session package."""
|
||||
from praisonaiagents.session import SessionSnapshot
|
||||
assert SessionSnapshot is not None
|
||||
|
||||
def test_extended_session_data_export(self):
|
||||
"""ExtendedSessionData must be importable from session package."""
|
||||
from praisonaiagents.session import ExtendedSessionData
|
||||
assert ExtendedSessionData is not None
|
||||
@@ -195,12 +195,12 @@ class TestNamespaceStyle:
|
||||
class TestAllSizeLimited:
|
||||
"""Test that __all__ is limited to core symbols."""
|
||||
|
||||
def test_all_size_under_25(self):
|
||||
"""__all__ should have fewer than 25 items (minimal for clean IDE)."""
|
||||
def test_all_size_under_27(self):
|
||||
"""__all__ should have fewer than 27 items (minimal for clean IDE)."""
|
||||
import praisonaiagents
|
||||
# Updated from 21 to 25 to accommodate AgentOS protocol/config exports
|
||||
assert len(praisonaiagents.__all__) < 25, \
|
||||
f"__all__ has {len(praisonaiagents.__all__)} items, expected < 25"
|
||||
# Updated from 25 to 27 to accommodate AutoApproveBackend export
|
||||
assert len(praisonaiagents.__all__) < 27, \
|
||||
f"__all__ has {len(praisonaiagents.__all__)} items, expected < 27"
|
||||
|
||||
def test_all_contains_core(self):
|
||||
"""__all__ contains core symbols."""
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
TDD tests for the extensible approval protocol module.
|
||||
|
||||
Tests cover:
|
||||
- ApprovalRequest / ApprovalDecision dataclasses
|
||||
- ApprovalProtocol structural subtyping
|
||||
- AutoApproveBackend always approves
|
||||
- ConsoleBackend (mocked stdin)
|
||||
- ApprovalRegistry per-agent + global backend routing
|
||||
- ApprovalRegistry pre-checks (env, yaml, already-approved)
|
||||
- Backward compatibility with old approval.py API
|
||||
- Timeout handling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
|
||||
# ── Protocol & Dataclass tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApprovalRequest:
|
||||
def test_minimal_construction(self):
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
req = ApprovalRequest(tool_name="execute_command", arguments={"cmd": "ls"}, risk_level="critical")
|
||||
assert req.tool_name == "execute_command"
|
||||
assert req.arguments == {"cmd": "ls"}
|
||||
assert req.risk_level == "critical"
|
||||
assert req.agent_name is None
|
||||
assert req.session_id is None
|
||||
|
||||
def test_full_construction(self):
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
req = ApprovalRequest(
|
||||
tool_name="write_file",
|
||||
arguments={"path": "/tmp/x"},
|
||||
risk_level="high",
|
||||
agent_name="my-agent",
|
||||
session_id="sess-1",
|
||||
context={"user": "alice"},
|
||||
)
|
||||
assert req.agent_name == "my-agent"
|
||||
assert req.context == {"user": "alice"}
|
||||
|
||||
|
||||
class TestApprovalDecision:
|
||||
def test_approved(self):
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
d = ApprovalDecision(approved=True, reason="auto")
|
||||
assert d.approved is True
|
||||
assert d.reason == "auto"
|
||||
assert d.modified_args == {}
|
||||
|
||||
def test_denied(self):
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
d = ApprovalDecision(approved=False, reason="user denied")
|
||||
assert d.approved is False
|
||||
|
||||
def test_modified_args(self):
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
d = ApprovalDecision(approved=True, modified_args={"cmd": "ls -la"})
|
||||
assert d.modified_args == {"cmd": "ls -la"}
|
||||
|
||||
def test_approver_and_metadata(self):
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
d = ApprovalDecision(approved=True, approver="webhook", metadata={"ip": "1.2.3.4"})
|
||||
assert d.approver == "webhook"
|
||||
assert d.metadata == {"ip": "1.2.3.4"}
|
||||
|
||||
|
||||
class TestApprovalProtocol:
|
||||
def test_protocol_structural_subtyping(self):
|
||||
"""Any class with async request_approval(ApprovalRequest) -> ApprovalDecision satisfies the protocol."""
|
||||
from praisonaiagents.approval.protocols import ApprovalProtocol, ApprovalRequest, ApprovalDecision
|
||||
|
||||
class MyBackend:
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=True)
|
||||
|
||||
assert isinstance(MyBackend(), ApprovalProtocol)
|
||||
|
||||
def test_non_conforming_class_fails(self):
|
||||
from praisonaiagents.approval.protocols import ApprovalProtocol
|
||||
|
||||
class Bad:
|
||||
pass
|
||||
|
||||
assert not isinstance(Bad(), ApprovalProtocol)
|
||||
|
||||
|
||||
# ── Backend tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAutoApproveBackend:
|
||||
def test_always_approves(self):
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
backend = AutoApproveBackend()
|
||||
req = ApprovalRequest(tool_name="execute_command", arguments={}, risk_level="critical")
|
||||
decision = asyncio.run(backend.request_approval(req))
|
||||
assert decision.approved is True
|
||||
assert decision.approver == "system"
|
||||
|
||||
def test_sync_shortcut(self):
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
backend = AutoApproveBackend()
|
||||
req = ApprovalRequest(tool_name="x", arguments={}, risk_level="low")
|
||||
decision = backend.request_approval_sync(req)
|
||||
assert decision.approved is True
|
||||
|
||||
|
||||
class TestConsoleBackend:
|
||||
def test_approved_via_mock(self):
|
||||
from praisonaiagents.approval.backends import ConsoleBackend
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
backend = ConsoleBackend()
|
||||
req = ApprovalRequest(tool_name="write_file", arguments={"path": "/tmp"}, risk_level="high")
|
||||
|
||||
with patch.object(backend, '_prompt_user', return_value=True):
|
||||
decision = backend.request_approval_sync(req)
|
||||
assert decision.approved is True
|
||||
|
||||
def test_denied_via_mock(self):
|
||||
from praisonaiagents.approval.backends import ConsoleBackend
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
backend = ConsoleBackend()
|
||||
req = ApprovalRequest(tool_name="kill_process", arguments={}, risk_level="critical")
|
||||
|
||||
with patch.object(backend, '_prompt_user', return_value=False):
|
||||
decision = backend.request_approval_sync(req)
|
||||
assert decision.approved is False
|
||||
|
||||
def test_async_delegates_to_sync(self):
|
||||
from praisonaiagents.approval.backends import ConsoleBackend
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
backend = ConsoleBackend()
|
||||
req = ApprovalRequest(tool_name="x", arguments={}, risk_level="low")
|
||||
|
||||
with patch.object(backend, '_prompt_user', return_value=True):
|
||||
decision = asyncio.run(backend.request_approval(req))
|
||||
assert decision.approved is True
|
||||
|
||||
|
||||
# ── Registry tests ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApprovalRegistry:
|
||||
def test_default_backend_is_console(self):
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
from praisonaiagents.approval.backends import ConsoleBackend
|
||||
reg = ApprovalRegistry()
|
||||
backend = reg.get_backend()
|
||||
assert isinstance(backend, ConsoleBackend)
|
||||
|
||||
def test_set_global_backend(self):
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
reg = ApprovalRegistry()
|
||||
reg.set_backend(AutoApproveBackend())
|
||||
assert isinstance(reg.get_backend(), AutoApproveBackend)
|
||||
|
||||
def test_per_agent_backend(self):
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend, ConsoleBackend
|
||||
reg = ApprovalRegistry()
|
||||
reg.set_backend(AutoApproveBackend(), agent_name="bot-agent")
|
||||
assert isinstance(reg.get_backend("bot-agent"), AutoApproveBackend)
|
||||
assert isinstance(reg.get_backend("other-agent"), ConsoleBackend) # fallback
|
||||
|
||||
def test_approve_sync_not_required(self):
|
||||
"""Tools not in required set are auto-approved."""
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
reg = ApprovalRegistry()
|
||||
reg._required_tools.clear() # no tools require approval
|
||||
decision = reg.approve_sync("agent", "some_tool", {})
|
||||
assert decision.approved is True
|
||||
|
||||
def test_approve_sync_with_auto_backend(self):
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
reg = ApprovalRegistry()
|
||||
reg.set_backend(AutoApproveBackend())
|
||||
decision = reg.approve_sync("agent", "execute_command", {"cmd": "ls"})
|
||||
assert decision.approved is True
|
||||
|
||||
def test_approve_sync_env_auto_approve(self):
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
reg = ApprovalRegistry()
|
||||
with patch.dict(os.environ, {"PRAISONAI_AUTO_APPROVE": "true"}):
|
||||
decision = reg.approve_sync("agent", "execute_command", {})
|
||||
assert decision.approved is True
|
||||
|
||||
def test_approve_async_with_auto_backend(self):
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
reg = ApprovalRegistry()
|
||||
reg.set_backend(AutoApproveBackend())
|
||||
decision = asyncio.run(
|
||||
reg.approve_async("agent", "execute_command", {})
|
||||
)
|
||||
assert decision.approved is True
|
||||
|
||||
def test_mark_and_check_approved(self):
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
reg = ApprovalRegistry()
|
||||
assert not reg.is_already_approved("execute_command")
|
||||
reg.mark_approved("execute_command")
|
||||
assert reg.is_already_approved("execute_command")
|
||||
|
||||
def test_already_approved_skips_backend(self):
|
||||
"""Once approved, no backend call needed."""
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
reg = ApprovalRegistry()
|
||||
reg.mark_approved("execute_command")
|
||||
decision = reg.approve_sync("agent", "execute_command", {})
|
||||
assert decision.approved is True
|
||||
assert "already" in decision.reason.lower()
|
||||
|
||||
def test_yaml_approved_skips_backend(self):
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
reg = ApprovalRegistry()
|
||||
token = reg.set_yaml_approved_tools(["execute_command"])
|
||||
decision = reg.approve_sync("agent", "execute_command", {})
|
||||
assert decision.approved is True
|
||||
reg.reset_yaml_approved_tools(token)
|
||||
|
||||
def test_remove_backend(self):
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend, ConsoleBackend
|
||||
reg = ApprovalRegistry()
|
||||
reg.set_backend(AutoApproveBackend(), agent_name="bot")
|
||||
reg.remove_backend(agent_name="bot")
|
||||
assert isinstance(reg.get_backend("bot"), ConsoleBackend)
|
||||
|
||||
|
||||
# ── Backward compatibility tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBackwardCompat:
|
||||
"""Ensure old approval.py public API still works from the same import path."""
|
||||
|
||||
def test_import_approval_decision(self):
|
||||
from praisonaiagents.approval import ApprovalDecision
|
||||
d = ApprovalDecision(approved=True)
|
||||
assert d.approved
|
||||
|
||||
def test_import_set_approval_callback(self):
|
||||
from praisonaiagents.approval import set_approval_callback, get_approval_callback
|
||||
assert callable(set_approval_callback)
|
||||
|
||||
def test_import_require_approval(self):
|
||||
from praisonaiagents.approval import require_approval
|
||||
assert callable(require_approval)
|
||||
|
||||
def test_import_console_approval_callback(self):
|
||||
from praisonaiagents.approval import console_approval_callback
|
||||
assert callable(console_approval_callback)
|
||||
|
||||
def test_import_request_approval(self):
|
||||
from praisonaiagents.approval import request_approval
|
||||
assert asyncio.iscoroutinefunction(request_approval)
|
||||
|
||||
def test_import_is_approval_required(self):
|
||||
from praisonaiagents.approval import is_approval_required
|
||||
assert callable(is_approval_required)
|
||||
|
||||
def test_import_default_dangerous_tools(self):
|
||||
from praisonaiagents.approval import DEFAULT_DANGEROUS_TOOLS
|
||||
assert "execute_command" in DEFAULT_DANGEROUS_TOOLS
|
||||
|
||||
def test_import_permission_allowlist(self):
|
||||
from praisonaiagents.approval import PermissionAllowlist
|
||||
al = PermissionAllowlist()
|
||||
assert al.is_empty()
|
||||
|
||||
def test_import_new_protocol(self):
|
||||
from praisonaiagents.approval import ApprovalProtocol, ApprovalRequest
|
||||
assert ApprovalProtocol is not None
|
||||
assert ApprovalRequest is not None
|
||||
|
||||
def test_import_new_backends(self):
|
||||
from praisonaiagents.approval import AutoApproveBackend, ConsoleBackend
|
||||
assert AutoApproveBackend is not None
|
||||
assert ConsoleBackend is not None
|
||||
|
||||
def test_import_registry(self):
|
||||
from praisonaiagents.approval import get_approval_registry
|
||||
reg = get_approval_registry()
|
||||
assert reg is not None
|
||||
|
||||
def test_set_callback_routes_to_registry(self):
|
||||
"""set_approval_callback should still work by wrapping into a backend."""
|
||||
from praisonaiagents.approval import set_approval_callback, get_approval_registry
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision as AD
|
||||
|
||||
def my_callback(fn, args, risk):
|
||||
return AD(approved=True, reason="custom")
|
||||
|
||||
set_approval_callback(my_callback)
|
||||
# Verify the registry uses this callback
|
||||
reg = get_approval_registry()
|
||||
# The global callback backend should be set
|
||||
backend = reg.get_backend()
|
||||
# Clean up
|
||||
set_approval_callback(None)
|
||||
|
||||
|
||||
# ── Global registry singleton tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGlobalRegistry:
|
||||
def test_singleton(self):
|
||||
from praisonaiagents.approval import get_approval_registry
|
||||
r1 = get_approval_registry()
|
||||
r2 = get_approval_registry()
|
||||
assert r1 is r2
|
||||
|
||||
def test_default_dangerous_tools_registered(self):
|
||||
from praisonaiagents.approval import get_approval_registry
|
||||
reg = get_approval_registry()
|
||||
assert "execute_command" in reg._required_tools
|
||||
assert reg._risk_levels.get("execute_command") == "critical"
|
||||
|
||||
|
||||
# ── Agent(approval=...) parameter tests ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestAgentApprovalParam:
|
||||
"""Tests for the agent-centric approval= constructor parameter."""
|
||||
|
||||
def test_approval_none_default(self):
|
||||
"""Agent() with no approval param has no _approval_backend."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
assert agent._approval_backend is None
|
||||
|
||||
def test_approval_true_sets_auto_approve(self):
|
||||
"""Agent(approval=True) sets AutoApproveBackend."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
agent = Agent(name="test", instructions="test", approval=True)
|
||||
assert isinstance(agent._approval_backend, AutoApproveBackend)
|
||||
|
||||
def test_approval_false_sets_none(self):
|
||||
"""Agent(approval=False) means no per-agent backend (use registry fallback)."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", approval=False)
|
||||
assert agent._approval_backend is None
|
||||
|
||||
def test_approval_custom_backend(self):
|
||||
"""Agent(approval=MyBackend()) stores the custom backend."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
backend = AutoApproveBackend()
|
||||
agent = Agent(name="test", instructions="test", approval=backend)
|
||||
assert agent._approval_backend is backend
|
||||
|
||||
def test_approval_true_auto_approves_tool(self):
|
||||
"""Agent(approval=True) should auto-approve dangerous tools in _execute_tool_impl."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
|
||||
def my_tool(cmd: str) -> str:
|
||||
"""Execute a command."""
|
||||
return f"ran: {cmd}"
|
||||
|
||||
agent = Agent(name="test", instructions="test", tools=[my_tool], approval=True)
|
||||
assert isinstance(agent._approval_backend, AutoApproveBackend)
|
||||
|
||||
def test_approval_custom_protocol_backend(self):
|
||||
"""Any object with request_approval / request_approval_sync works."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest, ApprovalDecision
|
||||
|
||||
class MyWebhookBackend:
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=True, reason="webhook")
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=True, reason="webhook")
|
||||
|
||||
backend = MyWebhookBackend()
|
||||
agent = Agent(name="test", instructions="test", approval=backend)
|
||||
assert agent._approval_backend is backend
|
||||
|
||||
def test_approval_backend_used_in_sync_tool_exec(self):
|
||||
"""_execute_tool_impl uses self._approval_backend when set."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest, ApprovalDecision
|
||||
|
||||
call_log = []
|
||||
|
||||
class TrackingBackend:
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
call_log.append(request.tool_name)
|
||||
return ApprovalDecision(approved=True, reason="tracked")
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=True, reason="tracked")
|
||||
|
||||
def execute_command(cmd: str) -> str:
|
||||
"""Execute a command."""
|
||||
return f"ran: {cmd}"
|
||||
|
||||
agent = Agent(name="test", instructions="test", tools=[execute_command], approval=TrackingBackend())
|
||||
result = agent._execute_tool_impl("execute_command", {"cmd": "ls"})
|
||||
assert "execute_command" in call_log
|
||||
assert result == "ran: ls"
|
||||
|
||||
def test_approval_backend_denial_blocks_tool(self):
|
||||
"""If per-agent backend denies, tool is blocked."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest, ApprovalDecision
|
||||
|
||||
class DenyBackend:
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=False, reason="denied by policy")
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=False, reason="denied by policy")
|
||||
|
||||
def execute_command(cmd: str) -> str:
|
||||
"""Execute a command."""
|
||||
return f"ran: {cmd}"
|
||||
|
||||
agent = Agent(name="test", instructions="test", tools=[execute_command], approval=DenyBackend())
|
||||
result = agent._execute_tool_impl("execute_command", {"cmd": "rm -rf /"})
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("approval_denied") is True
|
||||
|
||||
def test_approval_async_backend_used(self):
|
||||
"""execute_tool_async uses self._approval_backend when set."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest, ApprovalDecision
|
||||
|
||||
call_log = []
|
||||
|
||||
class AsyncTracker:
|
||||
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
call_log.append(request.tool_name)
|
||||
return ApprovalDecision(approved=True, reason="async-tracked")
|
||||
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
|
||||
return ApprovalDecision(approved=True, reason="async-tracked")
|
||||
|
||||
def execute_command(cmd: str) -> str:
|
||||
"""Execute a command."""
|
||||
return f"ran: {cmd}"
|
||||
|
||||
agent = Agent(name="test", instructions="test", tools=[execute_command], approval=AsyncTracker())
|
||||
|
||||
async def _run():
|
||||
return await agent.execute_tool_async("execute_command", {"cmd": "ls"})
|
||||
|
||||
result = asyncio.run(_run())
|
||||
assert "execute_command" in call_log
|
||||
@@ -0,0 +1,566 @@
|
||||
"""
|
||||
Comprehensive unit tests for OpenClaw gap analysis features.
|
||||
|
||||
Tests:
|
||||
- ProbeResult / HealthResult dataclasses
|
||||
- BotProtocol probe()/health() methods
|
||||
- BackoffPolicy / compute_backoff / is_recoverable_error (resilience)
|
||||
- BotConfig group_policy
|
||||
- Config schema validation (Pydantic)
|
||||
- Smart defaults for Bot
|
||||
- Doctor checks
|
||||
- Daemon service generation
|
||||
- BotApprovalBackend
|
||||
- Onboarding wizard config generation
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 1. ProbeResult / HealthResult (Core SDK)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestProbeResult:
|
||||
def test_import(self):
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
assert ProbeResult is not None
|
||||
|
||||
def test_ok_probe(self):
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
result = ProbeResult(ok=True, platform="telegram", elapsed_ms=150.0, bot_username="test_bot")
|
||||
assert result.ok is True
|
||||
assert result.platform == "telegram"
|
||||
assert result.bot_username == "test_bot"
|
||||
assert result.error is None
|
||||
|
||||
def test_failed_probe(self):
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
result = ProbeResult(ok=False, platform="discord", error="Unauthorized")
|
||||
assert result.ok is False
|
||||
assert result.error == "Unauthorized"
|
||||
|
||||
def test_to_dict(self):
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
result = ProbeResult(ok=True, platform="slack", elapsed_ms=50.0)
|
||||
d = result.to_dict()
|
||||
assert d["ok"] is True
|
||||
assert d["platform"] == "slack"
|
||||
assert d["elapsed_ms"] == 50.0
|
||||
|
||||
def test_details(self):
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
result = ProbeResult(ok=True, platform="telegram", details={"bot_id": 123})
|
||||
assert result.details["bot_id"] == 123
|
||||
|
||||
|
||||
class TestHealthResult:
|
||||
def test_import(self):
|
||||
from praisonaiagents.bots import HealthResult
|
||||
assert HealthResult is not None
|
||||
|
||||
def test_healthy(self):
|
||||
from praisonaiagents.bots import HealthResult, ProbeResult
|
||||
probe = ProbeResult(ok=True, platform="telegram")
|
||||
health = HealthResult(
|
||||
ok=True, platform="telegram", is_running=True,
|
||||
uptime_seconds=3600.0, probe=probe, sessions=5,
|
||||
)
|
||||
assert health.ok is True
|
||||
assert health.is_running is True
|
||||
assert health.uptime_seconds == 3600.0
|
||||
assert health.sessions == 5
|
||||
assert health.probe.ok is True
|
||||
|
||||
def test_unhealthy(self):
|
||||
from praisonaiagents.bots import HealthResult
|
||||
health = HealthResult(ok=False, platform="discord", error="Connection refused")
|
||||
assert health.ok is False
|
||||
assert health.error == "Connection refused"
|
||||
|
||||
def test_to_dict_with_probe(self):
|
||||
from praisonaiagents.bots import HealthResult, ProbeResult
|
||||
probe = ProbeResult(ok=True, platform="telegram", elapsed_ms=100.0)
|
||||
health = HealthResult(ok=True, platform="telegram", probe=probe)
|
||||
d = health.to_dict()
|
||||
assert d["probe"]["ok"] is True
|
||||
assert d["probe"]["elapsed_ms"] == 100.0
|
||||
|
||||
def test_to_dict_without_probe(self):
|
||||
from praisonaiagents.bots import HealthResult
|
||||
health = HealthResult(ok=True, platform="slack")
|
||||
d = health.to_dict()
|
||||
assert d["probe"] is None
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 2. BotProtocol has probe() and health()
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestBotProtocolExtensions:
|
||||
def test_protocol_has_probe(self):
|
||||
from praisonaiagents.bots import BotProtocol
|
||||
assert hasattr(BotProtocol, 'probe')
|
||||
|
||||
def test_protocol_has_health(self):
|
||||
from praisonaiagents.bots import BotProtocol
|
||||
assert hasattr(BotProtocol, 'health')
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 3. Resilience Module
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestBackoffPolicy:
|
||||
def test_import(self):
|
||||
from praisonai.bots._resilience import BackoffPolicy
|
||||
assert BackoffPolicy is not None
|
||||
|
||||
def test_defaults(self):
|
||||
from praisonai.bots._resilience import BackoffPolicy
|
||||
policy = BackoffPolicy()
|
||||
assert policy.initial_ms == 2000.0
|
||||
assert policy.max_ms == 30000.0
|
||||
assert policy.factor == 1.8
|
||||
|
||||
def test_telegram_preset(self):
|
||||
from praisonai.bots._resilience import TELEGRAM_BACKOFF
|
||||
assert TELEGRAM_BACKOFF.initial_ms == 2000
|
||||
assert TELEGRAM_BACKOFF.jitter == 0.25
|
||||
|
||||
|
||||
class TestComputeBackoff:
|
||||
def test_first_attempt(self):
|
||||
from praisonai.bots._resilience import BackoffPolicy, compute_backoff
|
||||
policy = BackoffPolicy(initial_ms=1000, factor=2.0, jitter=0.0)
|
||||
delay = compute_backoff(policy, 1)
|
||||
assert abs(delay - 1.0) < 0.01 # 1000ms = 1.0s
|
||||
|
||||
def test_second_attempt(self):
|
||||
from praisonai.bots._resilience import BackoffPolicy, compute_backoff
|
||||
policy = BackoffPolicy(initial_ms=1000, factor=2.0, jitter=0.0)
|
||||
delay = compute_backoff(policy, 2)
|
||||
assert abs(delay - 2.0) < 0.01 # 2000ms = 2.0s
|
||||
|
||||
def test_capped_at_max(self):
|
||||
from praisonai.bots._resilience import BackoffPolicy, compute_backoff
|
||||
policy = BackoffPolicy(initial_ms=1000, max_ms=5000, factor=10.0, jitter=0.0)
|
||||
delay = compute_backoff(policy, 3)
|
||||
assert delay <= 5.0 # Capped at max
|
||||
|
||||
def test_jitter_applied(self):
|
||||
from praisonai.bots._resilience import BackoffPolicy, compute_backoff
|
||||
policy = BackoffPolicy(initial_ms=1000, factor=1.0, jitter=0.5)
|
||||
delays = [compute_backoff(policy, 1) for _ in range(20)]
|
||||
# With jitter, not all delays should be identical
|
||||
assert len(set(round(d, 3) for d in delays)) > 1
|
||||
|
||||
|
||||
class TestIsRecoverableError:
|
||||
def test_timeout_is_recoverable(self):
|
||||
from praisonai.bots._resilience import is_recoverable_error
|
||||
assert is_recoverable_error(TimeoutError("connection timed out")) is True
|
||||
|
||||
def test_connection_error_is_recoverable(self):
|
||||
from praisonai.bots._resilience import is_recoverable_error
|
||||
assert is_recoverable_error(ConnectionError("reset by peer")) is True
|
||||
|
||||
def test_value_error_not_recoverable(self):
|
||||
from praisonai.bots._resilience import is_recoverable_error
|
||||
assert is_recoverable_error(ValueError("invalid input")) is False
|
||||
|
||||
def test_asyncio_timeout(self):
|
||||
from praisonai.bots._resilience import is_recoverable_error
|
||||
assert is_recoverable_error(asyncio.TimeoutError()) is True
|
||||
|
||||
def test_http_429_recoverable(self):
|
||||
from praisonai.bots._resilience import is_recoverable_error
|
||||
err = Exception("rate limited")
|
||||
err.status_code = 429
|
||||
assert is_recoverable_error(err) is True
|
||||
|
||||
|
||||
class TestIsConflictError:
|
||||
def test_409_conflict(self):
|
||||
from praisonai.bots._resilience import is_conflict_error
|
||||
err = Exception("Conflict: terminated by other getUpdates")
|
||||
err.error_code = 409
|
||||
assert is_conflict_error(err) is True
|
||||
|
||||
def test_non_conflict(self):
|
||||
from praisonai.bots._resilience import is_conflict_error
|
||||
assert is_conflict_error(ValueError("something")) is False
|
||||
|
||||
|
||||
class TestConnectionMonitor:
|
||||
def test_record_success_resets(self):
|
||||
from praisonai.bots._resilience import ConnectionMonitor
|
||||
m = ConnectionMonitor(platform="test")
|
||||
m.attempt = 5
|
||||
m.last_error = "something"
|
||||
m.record_success()
|
||||
assert m.attempt == 0
|
||||
assert m.last_error is None
|
||||
assert m.total_recoveries == 1
|
||||
|
||||
def test_record_error_increments(self):
|
||||
from praisonai.bots._resilience import ConnectionMonitor, BackoffPolicy
|
||||
m = ConnectionMonitor(platform="test", policy=BackoffPolicy(jitter=0.0, initial_ms=1000, factor=2.0))
|
||||
delay = m.record_error(Exception("test"))
|
||||
assert m.attempt == 1
|
||||
assert abs(delay - 1.0) < 0.01
|
||||
|
||||
def test_should_retry_unlimited(self):
|
||||
from praisonai.bots._resilience import ConnectionMonitor, BackoffPolicy
|
||||
m = ConnectionMonitor(platform="test", policy=BackoffPolicy(max_attempts=0))
|
||||
m.attempt = 100
|
||||
assert m.should_retry() is True
|
||||
|
||||
def test_should_retry_limited(self):
|
||||
from praisonai.bots._resilience import ConnectionMonitor, BackoffPolicy
|
||||
m = ConnectionMonitor(platform="test", policy=BackoffPolicy(max_attempts=3))
|
||||
m.attempt = 3
|
||||
assert m.should_retry() is False
|
||||
|
||||
|
||||
class TestSleepWithAbort:
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_completion(self):
|
||||
from praisonai.bots._resilience import sleep_with_abort
|
||||
result = await sleep_with_abort(0.01)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aborted(self):
|
||||
from praisonai.bots._resilience import sleep_with_abort
|
||||
abort = asyncio.Event()
|
||||
abort.set() # Already signaled
|
||||
result = await sleep_with_abort(10.0, abort)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 4. BotConfig group_policy
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestBotConfigGroupPolicy:
|
||||
def test_default_group_policy(self):
|
||||
from praisonaiagents.bots import BotConfig
|
||||
config = BotConfig()
|
||||
assert config.group_policy == "mention_only"
|
||||
|
||||
def test_custom_group_policy(self):
|
||||
from praisonaiagents.bots import BotConfig
|
||||
config = BotConfig(group_policy="respond_all")
|
||||
assert config.group_policy == "respond_all"
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 5. Config Schema Validation
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestConfigSchema:
|
||||
def test_valid_config(self):
|
||||
from praisonai.bots._config_schema import validate_bot_config
|
||||
raw = {
|
||||
"agent": {"name": "test", "model": "gpt-4o-mini"},
|
||||
"channels": {"telegram": {"token": "test123"}},
|
||||
}
|
||||
result = validate_bot_config(raw)
|
||||
assert result.channels["telegram"].token == "test123"
|
||||
|
||||
def test_missing_channels_fails(self):
|
||||
from praisonai.bots._config_schema import validate_bot_config
|
||||
with pytest.raises(ValueError, match="No channels configured"):
|
||||
validate_bot_config({"agent": {"name": "test"}})
|
||||
|
||||
def test_empty_channels_fails(self):
|
||||
from praisonai.bots._config_schema import validate_bot_config
|
||||
with pytest.raises(ValueError, match="No channels configured"):
|
||||
validate_bot_config({"channels": {}})
|
||||
|
||||
def test_unknown_channel_fails(self):
|
||||
from praisonai.bots._config_schema import validate_bot_config
|
||||
with pytest.raises(ValueError, match="Unknown channel"):
|
||||
validate_bot_config({"channels": {"signal": {"token": "x"}}})
|
||||
|
||||
def test_invalid_group_policy(self):
|
||||
from praisonai.bots._config_schema import validate_bot_config
|
||||
with pytest.raises(ValueError, match="group_policy"):
|
||||
validate_bot_config({
|
||||
"channels": {"telegram": {"token": "x", "group_policy": "invalid"}}
|
||||
})
|
||||
|
||||
def test_env_var_resolution(self):
|
||||
from praisonai.bots._config_schema import validate_bot_config
|
||||
os.environ["_TEST_BOT_TOKEN"] = "resolved_token"
|
||||
try:
|
||||
raw = {"channels": {"telegram": {"token": "${_TEST_BOT_TOKEN}"}}}
|
||||
result = validate_bot_config(raw)
|
||||
assert result.channels["telegram"].token == "resolved_token"
|
||||
finally:
|
||||
del os.environ["_TEST_BOT_TOKEN"]
|
||||
|
||||
def test_missing_env_var_fails(self):
|
||||
from praisonai.bots._config_schema import validate_bot_config
|
||||
os.environ.pop("_NONEXISTENT_TOKEN", None)
|
||||
with pytest.raises(ValueError, match="not set"):
|
||||
validate_bot_config({"channels": {"telegram": {"token": "${_NONEXISTENT_TOKEN}"}}})
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 6. Smart Defaults for Bot
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestSmartDefaults:
|
||||
def test_bot_has_apply_smart_defaults(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("telegram", token="fake")
|
||||
assert hasattr(bot, '_apply_smart_defaults')
|
||||
|
||||
def test_smart_defaults_adds_tools_to_bare_agent(self):
|
||||
from praisonai.bots import Bot
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.__class__.__name__ = "Agent"
|
||||
mock_agent.tools = []
|
||||
|
||||
bot = Bot("telegram", agent=mock_agent, token="fake")
|
||||
result = bot._apply_smart_defaults(mock_agent)
|
||||
# Should have assigned tools
|
||||
assert result.tools is not None or mock_agent.tools is not None
|
||||
|
||||
def test_smart_defaults_skips_agent_with_tools(self):
|
||||
from praisonai.bots import Bot
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.__class__.__name__ = "Agent"
|
||||
mock_agent.tools = [lambda: None] # Already has tools
|
||||
|
||||
bot = Bot("telegram", agent=mock_agent, token="fake")
|
||||
result = bot._apply_smart_defaults(mock_agent)
|
||||
# Should NOT overwrite existing tools
|
||||
assert len(result.tools) == 1
|
||||
|
||||
def test_smart_defaults_skips_none_agent(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("telegram", token="fake")
|
||||
result = bot._apply_smart_defaults(None)
|
||||
assert result is None
|
||||
|
||||
def test_smart_defaults_skips_non_agent(self):
|
||||
from praisonai.bots import Bot
|
||||
mock_team = MagicMock()
|
||||
mock_team.__class__.__name__ = "AgentTeam"
|
||||
mock_team.tools = []
|
||||
|
||||
bot = Bot("telegram", agent=mock_team, token="fake")
|
||||
result = bot._apply_smart_defaults(mock_team)
|
||||
# Should not modify AgentTeam
|
||||
assert len(result.tools) == 0
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 7. Doctor Bot Checks (integrated into existing doctor framework)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestDoctorBotChecks:
|
||||
def test_bot_checks_import(self):
|
||||
from praisonai.cli.features.doctor.checks.bot_checks import check_bot_tokens, check_bot_config
|
||||
assert callable(check_bot_tokens)
|
||||
assert callable(check_bot_config)
|
||||
|
||||
def test_check_category_has_bots(self):
|
||||
from praisonai.cli.features.doctor.models import CheckCategory
|
||||
assert hasattr(CheckCategory, "BOTS")
|
||||
assert CheckCategory.BOTS.value == "bots"
|
||||
|
||||
def test_bot_tokens_check_no_tokens(self):
|
||||
from praisonai.cli.features.doctor.checks.bot_checks import check_bot_tokens
|
||||
from praisonai.cli.features.doctor.models import CheckStatus
|
||||
for var in ["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "WHATSAPP_ACCESS_TOKEN"]:
|
||||
os.environ.pop(var, None)
|
||||
result = check_bot_tokens()
|
||||
assert result.status == CheckStatus.WARN
|
||||
|
||||
def test_bot_tokens_check_with_token(self):
|
||||
from praisonai.cli.features.doctor.checks.bot_checks import check_bot_tokens
|
||||
from praisonai.cli.features.doctor.models import CheckStatus
|
||||
os.environ["TELEGRAM_BOT_TOKEN"] = "test123"
|
||||
try:
|
||||
result = check_bot_tokens()
|
||||
assert result.status == CheckStatus.PASS
|
||||
assert "Telegram" in result.message
|
||||
finally:
|
||||
del os.environ["TELEGRAM_BOT_TOKEN"]
|
||||
|
||||
def test_bot_config_missing(self):
|
||||
from praisonai.cli.features.doctor.checks.bot_checks import check_bot_config
|
||||
from praisonai.cli.features.doctor.models import CheckStatus
|
||||
result = check_bot_config("/nonexistent/bot.yaml")
|
||||
assert result.status == CheckStatus.WARN
|
||||
|
||||
def test_get_bot_checks(self):
|
||||
from praisonai.cli.features.doctor.checks.bot_checks import get_bot_checks
|
||||
checks = get_bot_checks()
|
||||
assert len(checks) >= 2
|
||||
assert all(hasattr(c, 'status') for c in checks)
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 8. Bot Approval Backend
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestBotApprovalBackend:
|
||||
def test_import(self):
|
||||
from praisonai.bots._approval import BotApprovalBackend
|
||||
assert BotApprovalBackend is not None
|
||||
|
||||
def test_init(self):
|
||||
from praisonai.bots._approval import BotApprovalBackend
|
||||
backend = BotApprovalBackend(timeout=60)
|
||||
assert backend._timeout == 60
|
||||
assert backend.pending_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_defaults_to_deny(self):
|
||||
from praisonai.bots._approval import BotApprovalBackend
|
||||
backend = BotApprovalBackend(timeout=0.01, default_on_timeout=False)
|
||||
result = await backend.request_approval("user1", "ch1", "test_tool", {})
|
||||
assert result is False
|
||||
|
||||
def test_resolve_by_user_reply_yes(self):
|
||||
from praisonai.bots._approval import BotApprovalBackend
|
||||
backend = BotApprovalBackend()
|
||||
loop = asyncio.new_event_loop()
|
||||
future = loop.create_future()
|
||||
backend._pending["approval_user1_123"] = future
|
||||
resolved = backend.resolve_by_user_reply("user1", "yes")
|
||||
assert resolved is True
|
||||
assert future.result() is True
|
||||
loop.close()
|
||||
|
||||
def test_resolve_by_user_reply_no(self):
|
||||
from praisonai.bots._approval import BotApprovalBackend
|
||||
backend = BotApprovalBackend()
|
||||
loop = asyncio.new_event_loop()
|
||||
future = loop.create_future()
|
||||
backend._pending["approval_user1_456"] = future
|
||||
resolved = backend.resolve_by_user_reply("user1", "no")
|
||||
assert resolved is True
|
||||
assert future.result() is False
|
||||
loop.close()
|
||||
|
||||
def test_resolve_unknown_text(self):
|
||||
from praisonai.bots._approval import BotApprovalBackend
|
||||
backend = BotApprovalBackend()
|
||||
resolved = backend.resolve_by_user_reply("user1", "hello world")
|
||||
assert resolved is False
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 9. Daemon Service
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestDaemonService:
|
||||
def test_daemon_import(self):
|
||||
from praisonai.daemon import get_daemon_status, install_daemon, uninstall_daemon
|
||||
assert callable(get_daemon_status)
|
||||
assert callable(install_daemon)
|
||||
assert callable(uninstall_daemon)
|
||||
|
||||
def test_systemd_unit_generation(self):
|
||||
from praisonai.daemon.systemd import _generate_unit
|
||||
unit = _generate_unit("/tmp/test_bot.yaml")
|
||||
assert "[Unit]" in unit
|
||||
assert "[Service]" in unit
|
||||
assert "praisonai" in unit
|
||||
assert "/tmp/test_bot.yaml" in unit
|
||||
assert "Restart=always" in unit
|
||||
|
||||
def test_launchd_plist_generation(self):
|
||||
from praisonai.daemon.launchd import _generate_plist
|
||||
plist = _generate_plist("/tmp/test_bot.yaml")
|
||||
assert "<?xml" in plist
|
||||
assert "ai.praison.bot" in plist
|
||||
assert "/tmp/test_bot.yaml" in plist
|
||||
assert "KeepAlive" in plist
|
||||
|
||||
def test_detect_platform(self):
|
||||
from praisonai.daemon import _detect_platform
|
||||
plat = _detect_platform()
|
||||
assert plat in ("systemd", "launchd", "windows", "unknown")
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 10. Onboarding Wizard Config Generation
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestOnboardWizard:
|
||||
def test_import(self):
|
||||
from praisonai.cli.features.onboard import OnboardWizard
|
||||
assert OnboardWizard is not None
|
||||
|
||||
def test_generate_bot_yaml(self):
|
||||
from praisonai.cli.features.onboard import _generate_bot_yaml
|
||||
yaml_str = _generate_bot_yaml(["telegram"], agent_name="mybot", agent_instructions="Be helpful")
|
||||
assert "telegram:" in yaml_str
|
||||
assert "mybot" in yaml_str
|
||||
assert "TELEGRAM_BOT_TOKEN" in yaml_str
|
||||
assert "mention_only" in yaml_str
|
||||
|
||||
def test_generate_multi_platform(self):
|
||||
from praisonai.cli.features.onboard import _generate_bot_yaml
|
||||
yaml_str = _generate_bot_yaml(["telegram", "discord"])
|
||||
assert "telegram:" in yaml_str
|
||||
assert "discord:" in yaml_str
|
||||
|
||||
def test_wizard_init(self):
|
||||
from praisonai.cli.features.onboard import OnboardWizard
|
||||
wizard = OnboardWizard()
|
||||
assert wizard.selected_platforms == []
|
||||
assert wizard.agent_name == "assistant"
|
||||
|
||||
def test_platforms_info(self):
|
||||
from praisonai.cli.features.onboard import PLATFORMS
|
||||
assert "telegram" in PLATFORMS
|
||||
assert "discord" in PLATFORMS
|
||||
assert "slack" in PLATFORMS
|
||||
assert "whatsapp" in PLATFORMS
|
||||
assert all("token_env" in v for v in PLATFORMS.values())
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 11. Bot probe() and health() delegation
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestBotDelegation:
|
||||
def test_bot_has_probe(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("telegram", token="fake")
|
||||
assert hasattr(bot, 'probe')
|
||||
|
||||
def test_bot_has_health(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("telegram", token="fake")
|
||||
assert hasattr(bot, 'health')
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 12. Import Time Regression
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestImportTime:
|
||||
def test_core_sdk_import_time(self):
|
||||
"""Core SDK import must be < 200ms."""
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
"import time; t=time.perf_counter(); import praisonaiagents; print(f'{(time.perf_counter()-t)*1000:.0f}')"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
ms = int(result.stdout.strip())
|
||||
assert ms < 500, f"Import took {ms}ms, target <200ms"
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Tests for MESSAGE_RECEIVED / MESSAGE_SENDING / MESSAGE_SENT hook firing in bot adapters.
|
||||
|
||||
These hooks exist in HookEvent enum but were never fired by bot implementations.
|
||||
This test suite verifies the MessageHookMixin wires them correctly.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
from praisonaiagents.bots import BotMessage, BotUser, BotChannel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal stub for testing the mixin in isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _StubBot:
|
||||
"""Minimal bot stub that uses MessageHookMixin."""
|
||||
|
||||
def __init__(self, agent=None):
|
||||
self._agent = agent
|
||||
self._hook_registry = None # set by tests
|
||||
self._hook_runner = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: MessageHookMixin core logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMessageHookMixin:
|
||||
"""Test the shared MessageHookMixin that all bots use."""
|
||||
|
||||
def test_import_mixin(self):
|
||||
"""MessageHookMixin should be importable from _protocol_mixin."""
|
||||
from praisonai.bots._protocol_mixin import MessageHookMixin
|
||||
assert MessageHookMixin is not None
|
||||
|
||||
def test_mixin_has_fire_methods(self):
|
||||
"""Mixin should provide fire_message_received, fire_message_sending, fire_message_sent."""
|
||||
from praisonai.bots._protocol_mixin import MessageHookMixin
|
||||
assert hasattr(MessageHookMixin, 'fire_message_received')
|
||||
assert hasattr(MessageHookMixin, 'fire_message_sending')
|
||||
assert hasattr(MessageHookMixin, 'fire_message_sent')
|
||||
|
||||
def test_fire_message_received_creates_correct_event(self):
|
||||
"""fire_message_received should create a HookInput with MESSAGE_RECEIVED event."""
|
||||
from praisonai.bots._protocol_mixin import MessageHookMixin
|
||||
|
||||
class TestBot(MessageHookMixin):
|
||||
def __init__(self):
|
||||
self._agent = None
|
||||
self.platform = "test"
|
||||
|
||||
bot = TestBot()
|
||||
msg = BotMessage(
|
||||
message_id="123",
|
||||
content="Hello",
|
||||
sender=BotUser(user_id="user1", username="alice"),
|
||||
channel=BotChannel(channel_id="ch1", channel_type="dm"),
|
||||
)
|
||||
|
||||
# Without a hook registry, fire should be a no-op (no crash)
|
||||
bot.fire_message_received(msg)
|
||||
|
||||
def test_fire_message_sending_returns_modified_content(self):
|
||||
"""fire_message_sending should allow hooks to modify content."""
|
||||
from praisonai.bots._protocol_mixin import MessageHookMixin
|
||||
|
||||
class TestBot(MessageHookMixin):
|
||||
def __init__(self):
|
||||
self._agent = None
|
||||
self.platform = "test"
|
||||
|
||||
bot = TestBot()
|
||||
|
||||
# Without hooks, should return original content unchanged
|
||||
result = bot.fire_message_sending("ch1", "Hello world")
|
||||
assert result is not None
|
||||
assert result["content"] == "Hello world"
|
||||
assert result["cancel"] is False
|
||||
|
||||
def test_fire_message_sending_cancel(self):
|
||||
"""fire_message_sending with deny hook should return cancel=True."""
|
||||
from praisonai.bots._protocol_mixin import MessageHookMixin
|
||||
from praisonaiagents.hooks import HookRegistry, HookRunner
|
||||
|
||||
registry = HookRegistry()
|
||||
runner = HookRunner(registry=registry)
|
||||
|
||||
# Register a function hook that returns False (= deny)
|
||||
registry.register_function(
|
||||
event=HookEvent.MESSAGE_SENDING,
|
||||
func=lambda input_data: False, # False = deny
|
||||
name="cancel_hook",
|
||||
)
|
||||
|
||||
# Mock agent with _hook_runner
|
||||
class MockAgent:
|
||||
agent_name = "test_agent"
|
||||
_hook_runner = runner
|
||||
|
||||
class TestBot(MessageHookMixin):
|
||||
def __init__(self):
|
||||
self._agent = MockAgent()
|
||||
self.platform = "test"
|
||||
|
||||
bot = TestBot()
|
||||
result = bot.fire_message_sending("ch1", "spam message")
|
||||
assert result["cancel"] is True
|
||||
|
||||
def test_fire_message_sending_allow(self):
|
||||
"""fire_message_sending with allow hook should return cancel=False."""
|
||||
from praisonai.bots._protocol_mixin import MessageHookMixin
|
||||
from praisonaiagents.hooks import HookRegistry, HookRunner
|
||||
|
||||
registry = HookRegistry()
|
||||
runner = HookRunner(registry=registry)
|
||||
|
||||
# Register a function hook that returns True (= allow)
|
||||
registry.register_function(
|
||||
event=HookEvent.MESSAGE_SENDING,
|
||||
func=lambda input_data: True, # True = allow
|
||||
name="allow_hook",
|
||||
)
|
||||
|
||||
class MockAgent:
|
||||
agent_name = "test_agent"
|
||||
_hook_runner = runner
|
||||
|
||||
class TestBot(MessageHookMixin):
|
||||
def __init__(self):
|
||||
self._agent = MockAgent()
|
||||
self.platform = "test"
|
||||
|
||||
bot = TestBot()
|
||||
result = bot.fire_message_sending("ch1", "Hello")
|
||||
assert result["cancel"] is False
|
||||
assert result["content"] == "Hello"
|
||||
|
||||
def test_fire_message_sent_no_crash(self):
|
||||
"""fire_message_sent should not crash even without hooks."""
|
||||
from praisonai.bots._protocol_mixin import MessageHookMixin
|
||||
|
||||
class TestBot(MessageHookMixin):
|
||||
def __init__(self):
|
||||
self._agent = None
|
||||
self.platform = "test"
|
||||
|
||||
bot = TestBot()
|
||||
# Should be a no-op, no exception
|
||||
bot.fire_message_sent("ch1", "Hello", message_id="msg1")
|
||||
|
||||
|
||||
class TestMessageHookEventInputs:
|
||||
"""Test that message hook event input dataclasses exist and work."""
|
||||
|
||||
def test_message_received_input_exists(self):
|
||||
"""MessageReceivedInput should be importable from hooks.events."""
|
||||
from praisonaiagents.hooks.events import MessageReceivedInput
|
||||
inp = MessageReceivedInput(
|
||||
session_id="s1",
|
||||
cwd="/tmp",
|
||||
event_name=HookEvent.MESSAGE_RECEIVED,
|
||||
timestamp=str(time.time()),
|
||||
agent_name="bot",
|
||||
platform="telegram",
|
||||
content="Hello",
|
||||
sender_id="user1",
|
||||
channel_id="ch1",
|
||||
)
|
||||
assert inp.platform == "telegram"
|
||||
assert inp.content == "Hello"
|
||||
d = inp.to_dict()
|
||||
assert d["platform"] == "telegram"
|
||||
|
||||
def test_message_sending_input_exists(self):
|
||||
"""MessageSendingInput should be importable from hooks.events."""
|
||||
from praisonaiagents.hooks.events import MessageSendingInput
|
||||
inp = MessageSendingInput(
|
||||
session_id="s1",
|
||||
cwd="/tmp",
|
||||
event_name=HookEvent.MESSAGE_SENDING,
|
||||
timestamp=str(time.time()),
|
||||
agent_name="bot",
|
||||
platform="telegram",
|
||||
content="Reply",
|
||||
channel_id="ch1",
|
||||
)
|
||||
assert inp.content == "Reply"
|
||||
|
||||
def test_message_sent_input_exists(self):
|
||||
"""MessageSentInput should be importable from hooks.events."""
|
||||
from praisonaiagents.hooks.events import MessageSentInput
|
||||
inp = MessageSentInput(
|
||||
session_id="s1",
|
||||
cwd="/tmp",
|
||||
event_name=HookEvent.MESSAGE_SENT,
|
||||
timestamp=str(time.time()),
|
||||
agent_name="bot",
|
||||
platform="telegram",
|
||||
content="Reply",
|
||||
channel_id="ch1",
|
||||
message_id="msg1",
|
||||
)
|
||||
assert inp.message_id == "msg1"
|
||||
|
||||
|
||||
class TestGatewayStreamRelay:
|
||||
"""Test that gateway server relays StreamEvents to WebSocket clients."""
|
||||
|
||||
def test_process_agent_message_streams_tokens(self):
|
||||
"""_process_agent_message should wire stream_emitter to send token events to WS client."""
|
||||
# This test verifies the contract: when an agent streams tokens,
|
||||
# the gateway relays them as WS messages to the connected client.
|
||||
from praisonaiagents.gateway import EventType
|
||||
|
||||
# Verify the event types exist for streaming relay
|
||||
assert hasattr(EventType, 'TOKEN_STREAM') or hasattr(EventType, 'AGENT_RESPONSE')
|
||||
# The actual streaming relay is tested via integration tests
|
||||
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
Integration, smoke, and real-agent tests for BotOS architecture.
|
||||
|
||||
Covers:
|
||||
- Bot with mock adapter lifecycle
|
||||
- BotOS multi-bot orchestration
|
||||
- Agent alone in Bot
|
||||
- AgentTeam inside Bot/BotOS
|
||||
- AgentFlow inside Bot/BotOS
|
||||
- Platform extensibility
|
||||
- from_config() YAML loader
|
||||
- run() sync wrapper
|
||||
- remove_bot()
|
||||
- Custom platform registration + resolution
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Helper: Mock adapter that simulates a platform bot
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class MockPlatformAdapter:
|
||||
"""Simulates a platform bot adapter for testing."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.token = kwargs.get("token", "")
|
||||
self.agent = kwargs.get("agent")
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.messages_sent = []
|
||||
|
||||
async def start(self):
|
||||
self.started = True
|
||||
|
||||
async def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
return self.started and not self.stopped
|
||||
|
||||
async def send_message(self, channel_id, content, **kwargs):
|
||||
self.messages_sent.append({"channel": channel_id, "content": content})
|
||||
return True
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 1. Integration: Bot with mock adapter
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestBotWithMockAdapter:
|
||||
"""Integration tests for Bot class with a mock platform adapter."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_start_creates_adapter(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("mockbot", token="test-token")
|
||||
await bot.start()
|
||||
assert bot.adapter is not None
|
||||
assert isinstance(bot.adapter, MockPlatformAdapter)
|
||||
assert bot.adapter.started is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_stop(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("mockbot", token="test-token")
|
||||
await bot.start()
|
||||
await bot.stop()
|
||||
assert bot.adapter.stopped is True
|
||||
assert bot.is_running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_passes_token_to_adapter(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("mockbot", token="my-secret-token")
|
||||
await bot.start()
|
||||
assert bot.adapter.token == "my-secret-token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_passes_agent_to_adapter(self):
|
||||
from praisonai.bots import Bot
|
||||
agent = MagicMock()
|
||||
agent.name = "test-agent"
|
||||
bot = Bot("mockbot", agent=agent, token="t")
|
||||
await bot.start()
|
||||
assert bot.adapter.agent is agent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_send_message(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("mockbot", token="t")
|
||||
await bot.start()
|
||||
result = await bot.send_message("channel-1", "Hello!")
|
||||
assert result is True
|
||||
assert len(bot.adapter.messages_sent) == 1
|
||||
assert bot.adapter.messages_sent[0]["content"] == "Hello!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_send_message_before_start_raises(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("mockbot", token="t")
|
||||
with pytest.raises(RuntimeError, match="not started"):
|
||||
await bot.send_message("ch", "msg")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_passes_kwargs_to_adapter(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("mockbot", token="t", custom_param="hello")
|
||||
await bot.start()
|
||||
assert bot.adapter.kwargs.get("custom_param") == "hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_repr(self):
|
||||
from praisonai.bots import Bot
|
||||
agent = MagicMock()
|
||||
agent.name = "myagent"
|
||||
bot = Bot("mockbot", agent=agent)
|
||||
assert "mockbot" in repr(bot)
|
||||
assert "myagent" in repr(bot)
|
||||
|
||||
def test_bot_has_run_method(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("mockbot")
|
||||
assert hasattr(bot, "run")
|
||||
assert callable(bot.run)
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 2. Integration: BotOS lifecycle
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestBotOSLifecycle:
|
||||
"""Integration tests for BotOS multi-bot orchestration."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
register_platform("mock1", MockPlatformAdapter)
|
||||
register_platform("mock2", MockPlatformAdapter)
|
||||
self._cleanup = lambda: (_custom_platforms.pop("mock1", None), _custom_platforms.pop("mock2", None))
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_botos_starts_all_bots(self):
|
||||
from praisonai.bots import BotOS, Bot
|
||||
bot1 = Bot("mock1", token="t1")
|
||||
bot2 = Bot("mock2", token="t2")
|
||||
botos = BotOS(bots=[bot1, bot2])
|
||||
|
||||
# Start in background, stop quickly
|
||||
start_task = asyncio.create_task(botos.start())
|
||||
await asyncio.sleep(0.05)
|
||||
await botos.stop()
|
||||
start_task.cancel()
|
||||
try:
|
||||
await start_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert bot1.adapter.started is True
|
||||
assert bot2.adapter.started is True
|
||||
|
||||
def test_botos_remove_bot(self):
|
||||
from praisonai.bots import BotOS, Bot
|
||||
botos = BotOS(bots=[Bot("mock1"), Bot("mock2")])
|
||||
assert len(botos.list_bots()) == 2
|
||||
result = botos.remove_bot("mock1")
|
||||
assert result is True
|
||||
assert len(botos.list_bots()) == 1
|
||||
assert "mock2" in botos.list_bots()
|
||||
|
||||
def test_botos_remove_bot_not_found(self):
|
||||
from praisonai.bots import BotOS
|
||||
botos = BotOS()
|
||||
assert botos.remove_bot("nonexistent") is False
|
||||
|
||||
def test_botos_has_run_method(self):
|
||||
from praisonai.bots import BotOS
|
||||
botos = BotOS()
|
||||
assert hasattr(botos, "run")
|
||||
assert callable(botos.run)
|
||||
|
||||
def test_botos_repr(self):
|
||||
from praisonai.bots import BotOS, Bot
|
||||
botos = BotOS(bots=[Bot("mock1"), Bot("mock2")])
|
||||
r = repr(botos)
|
||||
assert "mock1" in r
|
||||
assert "mock2" in r
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 3. Real Agent in Bot
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestRealAgentInBot:
|
||||
"""Tests with a real praisonaiagents.Agent inside Bot."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
def test_real_agent_in_bot(self):
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="helper", instructions="Be helpful")
|
||||
bot = Bot("mockbot", agent=agent, token="t")
|
||||
assert bot.agent is agent
|
||||
assert bot.agent.name == "helper"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_agent_passed_to_adapter(self):
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="helper", instructions="Be helpful")
|
||||
bot = Bot("mockbot", agent=agent, token="t")
|
||||
await bot.start()
|
||||
assert bot.adapter.agent is agent
|
||||
assert bot.adapter.agent.name == "helper"
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 4. AgentTeam in Bot/BotOS
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestAgentTeamInBotOS:
|
||||
"""AgentTeam (multi-agent) inside Bot and BotOS."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
def test_agent_team_in_bot(self):
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
researcher = Agent(name="researcher", instructions="Research topics")
|
||||
writer = Agent(name="writer", instructions="Write content")
|
||||
t1 = Task(name="research", description="Research AI", agent=researcher)
|
||||
t2 = Task(name="write", description="Write about AI", agent=writer)
|
||||
team = AgentTeam(agents=[researcher, writer], tasks=[t1, t2])
|
||||
|
||||
bot = Bot("mockbot", agent=team, token="t")
|
||||
assert bot.agent is team
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_team_passed_to_adapter(self):
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
a1 = Agent(name="a1", instructions="Do task 1")
|
||||
a2 = Agent(name="a2", instructions="Do task 2")
|
||||
t1 = Task(name="t1", description="Task 1", agent=a1)
|
||||
t2 = Task(name="t2", description="Task 2", agent=a2)
|
||||
team = AgentTeam(agents=[a1, a2], tasks=[t1, t2])
|
||||
|
||||
bot = Bot("mockbot", agent=team, token="t")
|
||||
await bot.start()
|
||||
assert bot.adapter.agent is team
|
||||
|
||||
def test_agent_team_in_botos(self):
|
||||
from praisonai.bots import BotOS, Bot
|
||||
from praisonaiagents import Agent, AgentTeam, Task
|
||||
a1 = Agent(name="a1", instructions="Task 1")
|
||||
a2 = Agent(name="a2", instructions="Task 2")
|
||||
t1 = Task(name="t1", description="T1", agent=a1)
|
||||
t2 = Task(name="t2", description="T2", agent=a2)
|
||||
team = AgentTeam(agents=[a1, a2], tasks=[t1, t2])
|
||||
|
||||
botos = BotOS(bots=[Bot("mockbot", agent=team, token="t")])
|
||||
assert botos.get_bot("mockbot").agent is team
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 5. AgentFlow in Bot/BotOS
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestAgentFlowInBotOS:
|
||||
"""AgentFlow (deterministic workflow) inside Bot and BotOS."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
def test_agent_flow_in_bot(self):
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import Agent, AgentFlow, Task
|
||||
a1 = Agent(name="step1", instructions="Do step 1")
|
||||
a2 = Agent(name="step2", instructions="Do step 2")
|
||||
t1 = Task(name="t1", description="Step 1", agent=a1)
|
||||
t2 = Task(name="t2", description="Step 2", agent=a2)
|
||||
flow = AgentFlow(steps=[t1, t2])
|
||||
|
||||
bot = Bot("mockbot", agent=flow, token="t")
|
||||
assert bot.agent is flow
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_flow_passed_to_adapter(self):
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import Agent, AgentFlow, Task
|
||||
a1 = Agent(name="s1", instructions="Step 1")
|
||||
a2 = Agent(name="s2", instructions="Step 2")
|
||||
t1 = Task(name="t1", description="S1", agent=a1)
|
||||
t2 = Task(name="t2", description="S2", agent=a2)
|
||||
flow = AgentFlow(steps=[t1, t2])
|
||||
|
||||
bot = Bot("mockbot", agent=flow, token="t")
|
||||
await bot.start()
|
||||
assert bot.adapter.agent is flow
|
||||
|
||||
def test_agent_flow_in_botos_multi_platform(self):
|
||||
"""AgentFlow in BotOS across multiple platforms."""
|
||||
from praisonai.bots import BotOS, Bot
|
||||
from praisonaiagents import Agent, AgentFlow, Task
|
||||
a1 = Agent(name="s1", instructions="Step 1")
|
||||
t1 = Task(name="t1", description="S1", agent=a1)
|
||||
flow = AgentFlow(steps=[t1])
|
||||
|
||||
botos = BotOS(bots=[
|
||||
Bot("mockbot", agent=flow, token="t1"),
|
||||
])
|
||||
assert botos.get_bot("mockbot").agent is flow
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 6. Platform Extensibility
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestPlatformExtensibility:
|
||||
"""Third-party platform registration and usage."""
|
||||
|
||||
def teardown_method(self):
|
||||
from praisonai.bots._registry import _custom_platforms
|
||||
_custom_platforms.pop("custom_chat", None)
|
||||
_custom_platforms.pop("my_platform", None)
|
||||
|
||||
def test_register_and_use_custom_platform(self):
|
||||
from praisonai.bots._registry import register_platform, resolve_adapter
|
||||
register_platform("custom_chat", MockPlatformAdapter)
|
||||
cls = resolve_adapter("custom_chat")
|
||||
assert cls is MockPlatformAdapter
|
||||
|
||||
def test_custom_platform_in_list(self):
|
||||
from praisonai.bots._registry import register_platform, list_platforms
|
||||
register_platform("my_platform", MockPlatformAdapter)
|
||||
assert "my_platform" in list_platforms()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_platform_in_bot(self):
|
||||
from praisonai.bots._registry import register_platform
|
||||
from praisonai.bots import Bot
|
||||
register_platform("custom_chat", MockPlatformAdapter)
|
||||
bot = Bot("custom_chat", token="tok")
|
||||
await bot.start()
|
||||
assert bot.adapter.started is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_platform_in_botos(self):
|
||||
from praisonai.bots._registry import register_platform
|
||||
from praisonai.bots import BotOS, Bot
|
||||
register_platform("custom_chat", MockPlatformAdapter)
|
||||
botos = BotOS(bots=[Bot("custom_chat", token="tok")])
|
||||
assert "custom_chat" in botos.list_bots()
|
||||
|
||||
def test_resolve_unknown_platform_raises(self):
|
||||
from praisonai.bots._registry import resolve_adapter
|
||||
with pytest.raises(ValueError, match="Unknown platform"):
|
||||
resolve_adapter("nonexistent_xyz")
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 7. from_config() YAML loader
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestBotOSFromConfig:
|
||||
"""BotOS.from_config() YAML config loading."""
|
||||
|
||||
def setup_method(self):
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
register_platform("mockbot", MockPlatformAdapter)
|
||||
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
|
||||
|
||||
def teardown_method(self):
|
||||
self._cleanup()
|
||||
|
||||
def test_from_config_basic(self):
|
||||
from praisonai.bots import BotOS
|
||||
yaml_content = """\
|
||||
agent:
|
||||
name: test-bot
|
||||
instructions: Be helpful
|
||||
platforms:
|
||||
mockbot:
|
||||
token: test-token-123
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
f.flush()
|
||||
path = f.name
|
||||
|
||||
try:
|
||||
botos = BotOS.from_config(path)
|
||||
assert "mockbot" in botos.list_bots()
|
||||
bot = botos.get_bot("mockbot")
|
||||
assert bot.token == "test-token-123"
|
||||
assert bot.agent is not None
|
||||
assert bot.agent.name == "test-bot"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_from_config_env_var_resolution(self):
|
||||
from praisonai.bots import BotOS
|
||||
os.environ["TEST_BOTOS_TOKEN"] = "resolved-token"
|
||||
yaml_content = """\
|
||||
agent:
|
||||
name: env-bot
|
||||
instructions: Be helpful
|
||||
platforms:
|
||||
mockbot:
|
||||
token: ${TEST_BOTOS_TOKEN}
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
f.flush()
|
||||
path = f.name
|
||||
|
||||
try:
|
||||
botos = BotOS.from_config(path)
|
||||
bot = botos.get_bot("mockbot")
|
||||
assert bot.token == "resolved-token"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
del os.environ["TEST_BOTOS_TOKEN"]
|
||||
|
||||
def test_from_config_multi_platform(self):
|
||||
from praisonai.bots import BotOS
|
||||
from praisonai.bots._registry import register_platform, _custom_platforms
|
||||
register_platform("mock2", MockPlatformAdapter)
|
||||
|
||||
yaml_content = """\
|
||||
agent:
|
||||
name: multi-bot
|
||||
instructions: Handle multiple platforms
|
||||
platforms:
|
||||
mockbot:
|
||||
token: tok1
|
||||
mock2:
|
||||
token: tok2
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
f.flush()
|
||||
path = f.name
|
||||
|
||||
try:
|
||||
botos = BotOS.from_config(path)
|
||||
assert len(botos.list_bots()) == 2
|
||||
assert "mockbot" in botos.list_bots()
|
||||
assert "mock2" in botos.list_bots()
|
||||
finally:
|
||||
os.unlink(path)
|
||||
_custom_platforms.pop("mock2", None)
|
||||
|
||||
def test_from_config_invalid_raises(self):
|
||||
from praisonai.bots import BotOS
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write("invalid yaml content that is just a string")
|
||||
f.flush()
|
||||
path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ValueError, match="Invalid"):
|
||||
BotOS.from_config(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 8. Smoke Tests — import paths, hierarchy, class structure
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
class TestSmoke:
|
||||
"""Smoke tests for the BotOS architecture."""
|
||||
|
||||
def test_all_import_paths(self):
|
||||
"""All classes importable from expected paths."""
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
from praisonaiagents.bots.config import BotOSConfig
|
||||
from praisonaiagents.bots import BotOSProtocol as P2
|
||||
from praisonaiagents.bots import BotOSConfig as C2
|
||||
from praisonaiagents import BotOSProtocol as P3
|
||||
from praisonaiagents import BotOSConfig as C3
|
||||
from praisonai.bots import Bot, BotOS
|
||||
from praisonai.bots._registry import (
|
||||
register_platform, list_platforms, resolve_adapter, get_platform_registry
|
||||
)
|
||||
assert all(x is not None for x in [
|
||||
BotOSProtocol, BotOSConfig, P2, C2, P3, C3,
|
||||
Bot, BotOS, register_platform, list_platforms,
|
||||
resolve_adapter, get_platform_registry
|
||||
])
|
||||
|
||||
def test_protocol_identity(self):
|
||||
"""Same protocol from different paths."""
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol as A
|
||||
from praisonaiagents import BotOSProtocol as B
|
||||
assert A is B
|
||||
|
||||
def test_config_identity(self):
|
||||
from praisonaiagents.bots.config import BotOSConfig as A
|
||||
from praisonaiagents import BotOSConfig as B
|
||||
assert A is B
|
||||
|
||||
def test_bot_class_hierarchy(self):
|
||||
"""Bot hierarchy: BotOS > Bot > Agent."""
|
||||
from praisonai.bots import Bot, BotOS
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="test", instructions="test")
|
||||
bot = Bot("telegram", agent=agent)
|
||||
botos = BotOS(bots=[bot])
|
||||
|
||||
assert botos.get_bot("telegram") is bot
|
||||
assert bot.agent is agent
|
||||
|
||||
def test_builtin_platforms_count(self):
|
||||
from praisonai.bots._registry import list_platforms
|
||||
platforms = list_platforms()
|
||||
assert len(platforms) >= 4 # telegram, discord, slack, whatsapp
|
||||
|
||||
def test_no_heavy_imports_on_bot_creation(self):
|
||||
"""Creating Bot should NOT import platform libraries."""
|
||||
import sys
|
||||
from praisonai.bots import Bot
|
||||
Bot("telegram")
|
||||
assert "telegram" not in sys.modules
|
||||
assert "discord" not in sys.modules
|
||||
|
||||
def test_no_heavy_imports_on_botos_creation(self):
|
||||
import sys
|
||||
from praisonai.bots import BotOS, Bot
|
||||
BotOS(bots=[Bot("telegram")])
|
||||
assert "telegram" not in sys.modules
|
||||
|
||||
def test_botos_protocol_has_remove_bot(self):
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
assert hasattr(BotOSProtocol, 'remove_bot')
|
||||
|
||||
def test_bot_has_run_method(self):
|
||||
from praisonai.bots import Bot
|
||||
assert hasattr(Bot, 'run')
|
||||
|
||||
def test_botos_has_run_method(self):
|
||||
from praisonai.bots import BotOS
|
||||
assert hasattr(BotOS, 'run')
|
||||
|
||||
def test_botos_has_from_config(self):
|
||||
from praisonai.bots import BotOS
|
||||
assert hasattr(BotOS, 'from_config')
|
||||
assert callable(BotOS.from_config)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
TDD tests for BotOS protocol, config, and exports.
|
||||
|
||||
Tests written FIRST — implementation follows.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 1. Core SDK Protocol & Config exports
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
class TestBotOSProtocolExists:
|
||||
"""BotOSProtocol must be importable from core SDK."""
|
||||
|
||||
def test_import_from_bots_protocols(self):
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
assert BotOSProtocol is not None
|
||||
|
||||
def test_import_from_bots_package(self):
|
||||
from praisonaiagents.bots import BotOSProtocol
|
||||
assert BotOSProtocol is not None
|
||||
|
||||
def test_import_from_top_level(self):
|
||||
from praisonaiagents import BotOSProtocol
|
||||
assert BotOSProtocol is not None
|
||||
|
||||
def test_is_runtime_checkable(self):
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
# runtime_checkable protocols support isinstance checks
|
||||
assert hasattr(BotOSProtocol, '__protocol_attrs__') or hasattr(BotOSProtocol, '_is_runtime_protocol')
|
||||
|
||||
def test_protocol_has_start(self):
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
assert hasattr(BotOSProtocol, 'start')
|
||||
|
||||
def test_protocol_has_stop(self):
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
assert hasattr(BotOSProtocol, 'stop')
|
||||
|
||||
def test_protocol_has_add_bot(self):
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
assert hasattr(BotOSProtocol, 'add_bot')
|
||||
|
||||
def test_protocol_has_list_bots(self):
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
assert hasattr(BotOSProtocol, 'list_bots')
|
||||
|
||||
def test_protocol_has_is_running(self):
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
assert 'is_running' in dir(BotOSProtocol)
|
||||
|
||||
|
||||
class TestBotOSConfigExists:
|
||||
"""BotOSConfig must be importable from core SDK."""
|
||||
|
||||
def test_import_from_bots_config(self):
|
||||
from praisonaiagents.bots.config import BotOSConfig
|
||||
assert BotOSConfig is not None
|
||||
|
||||
def test_import_from_bots_package(self):
|
||||
from praisonaiagents.bots import BotOSConfig
|
||||
assert BotOSConfig is not None
|
||||
|
||||
def test_import_from_top_level(self):
|
||||
from praisonaiagents import BotOSConfig
|
||||
assert BotOSConfig is not None
|
||||
|
||||
def test_default_values(self):
|
||||
from praisonaiagents.bots.config import BotOSConfig
|
||||
cfg = BotOSConfig()
|
||||
assert cfg.name == "PraisonAI BotOS"
|
||||
assert isinstance(cfg.platforms, dict)
|
||||
|
||||
def test_to_dict(self):
|
||||
from praisonaiagents.bots.config import BotOSConfig
|
||||
cfg = BotOSConfig(name="test")
|
||||
d = cfg.to_dict()
|
||||
assert d["name"] == "test"
|
||||
assert "platforms" in d
|
||||
|
||||
def test_platforms_dict(self):
|
||||
from praisonaiagents.bots.config import BotOSConfig
|
||||
cfg = BotOSConfig(platforms={"telegram": {"token": "abc"}})
|
||||
assert "telegram" in cfg.platforms
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 2. Wrapper: Bot class
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
class TestBotClass:
|
||||
"""Bot is the user-facing single-platform wrapper."""
|
||||
|
||||
def test_import_from_wrapper(self):
|
||||
from praisonai.bots import Bot
|
||||
assert Bot is not None
|
||||
|
||||
def test_bot_requires_platform(self):
|
||||
from praisonai.bots import Bot
|
||||
with pytest.raises(TypeError):
|
||||
Bot() # platform is required
|
||||
|
||||
def test_bot_stores_platform(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("telegram")
|
||||
assert bot.platform == "telegram"
|
||||
|
||||
def test_bot_accepts_agent(self):
|
||||
from praisonai.bots import Bot
|
||||
from unittest.mock import MagicMock
|
||||
agent = MagicMock()
|
||||
agent.name = "test"
|
||||
bot = Bot("telegram", agent=agent)
|
||||
assert bot.agent is agent
|
||||
|
||||
def test_bot_accepts_agent_team(self):
|
||||
"""Bot should accept AgentTeam as agent."""
|
||||
from praisonai.bots import Bot
|
||||
from unittest.mock import MagicMock
|
||||
team = MagicMock()
|
||||
team.name = "team"
|
||||
bot = Bot("telegram", agent=team)
|
||||
assert bot.agent is team
|
||||
|
||||
def test_bot_accepts_kwargs(self):
|
||||
"""Platform-specific kwargs should pass through."""
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("whatsapp", mode="web", phone_number_id="123")
|
||||
assert bot._kwargs.get("mode") == "web"
|
||||
assert bot._kwargs.get("phone_number_id") == "123"
|
||||
|
||||
def test_bot_token_from_env(self):
|
||||
"""Bot resolves token from env var convention."""
|
||||
import os
|
||||
from praisonai.bots import Bot
|
||||
os.environ["TELEGRAM_BOT_TOKEN"] = "test-token-123"
|
||||
try:
|
||||
bot = Bot("telegram")
|
||||
assert bot.token == "test-token-123"
|
||||
finally:
|
||||
del os.environ["TELEGRAM_BOT_TOKEN"]
|
||||
|
||||
def test_bot_explicit_token(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("telegram", token="explicit-token")
|
||||
assert bot.token == "explicit-token"
|
||||
|
||||
def test_bot_not_running_initially(self):
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("telegram")
|
||||
assert bot.is_running is False
|
||||
|
||||
def test_bot_unknown_platform(self):
|
||||
"""Unknown platform should raise ValueError on start, not init."""
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot("unknown_platform")
|
||||
assert bot.platform == "unknown_platform"
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 3. Wrapper: BotOS class
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
class TestBotOSClass:
|
||||
"""BotOS is the multi-platform orchestrator."""
|
||||
|
||||
def test_import_from_wrapper(self):
|
||||
from praisonai.bots import BotOS
|
||||
assert BotOS is not None
|
||||
|
||||
def test_botos_empty_init(self):
|
||||
from praisonai.bots import BotOS
|
||||
botos = BotOS()
|
||||
assert botos.is_running is False
|
||||
|
||||
def test_botos_with_bots_list(self):
|
||||
from praisonai.bots import BotOS, Bot
|
||||
bot1 = Bot("telegram")
|
||||
bot2 = Bot("discord")
|
||||
botos = BotOS(bots=[bot1, bot2])
|
||||
assert len(botos.list_bots()) == 2
|
||||
|
||||
def test_botos_add_bot(self):
|
||||
from praisonai.bots import BotOS, Bot
|
||||
botos = BotOS()
|
||||
bot = Bot("telegram")
|
||||
botos.add_bot(bot)
|
||||
assert len(botos.list_bots()) == 1
|
||||
|
||||
def test_botos_shortcut_agent_plus_platforms(self):
|
||||
"""BotOS(agent=..., platforms=[...]) convenience."""
|
||||
from praisonai.bots import BotOS
|
||||
from unittest.mock import MagicMock
|
||||
agent = MagicMock()
|
||||
agent.name = "test"
|
||||
botos = BotOS(agent=agent, platforms=["telegram", "discord"])
|
||||
assert len(botos.list_bots()) == 2
|
||||
|
||||
def test_botos_list_bots_returns_platform_names(self):
|
||||
from praisonai.bots import BotOS, Bot
|
||||
botos = BotOS(bots=[Bot("telegram"), Bot("slack")])
|
||||
names = botos.list_bots()
|
||||
assert "telegram" in names
|
||||
assert "slack" in names
|
||||
|
||||
def test_botos_get_bot(self):
|
||||
from praisonai.bots import BotOS, Bot
|
||||
bot = Bot("telegram")
|
||||
botos = BotOS(bots=[bot])
|
||||
assert botos.get_bot("telegram") is bot
|
||||
|
||||
def test_botos_get_bot_missing(self):
|
||||
from praisonai.bots import BotOS
|
||||
botos = BotOS()
|
||||
assert botos.get_bot("telegram") is None
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 4. Platform Registry
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
class TestPlatformRegistry:
|
||||
"""Platform registry for extensible bot creation."""
|
||||
|
||||
def test_registry_import(self):
|
||||
from praisonai.bots._registry import get_platform_registry
|
||||
reg = get_platform_registry()
|
||||
assert reg is not None
|
||||
|
||||
def test_builtin_platforms_registered(self):
|
||||
from praisonai.bots._registry import get_platform_registry
|
||||
reg = get_platform_registry()
|
||||
assert "telegram" in reg
|
||||
assert "discord" in reg
|
||||
assert "slack" in reg
|
||||
assert "whatsapp" in reg
|
||||
|
||||
def test_register_custom_platform(self):
|
||||
from praisonai.bots._registry import get_platform_registry, register_platform
|
||||
class CustomBot:
|
||||
pass
|
||||
register_platform("custom", CustomBot)
|
||||
reg = get_platform_registry()
|
||||
assert "custom" in reg
|
||||
# Clean up
|
||||
reg.pop("custom", None)
|
||||
|
||||
def test_list_platforms(self):
|
||||
from praisonai.bots._registry import list_platforms
|
||||
platforms = list_platforms()
|
||||
assert isinstance(platforms, list)
|
||||
assert "telegram" in platforms
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 5. No performance impact
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
class TestNoPerformanceImpact:
|
||||
"""Importing BotOSProtocol/BotOSConfig should not pull heavy deps."""
|
||||
|
||||
def test_protocol_import_lightweight(self):
|
||||
"""Protocol import should not load telegram/discord/slack."""
|
||||
import sys
|
||||
# These should NOT be in sys.modules after importing protocol
|
||||
from praisonaiagents.bots.protocols import BotOSProtocol
|
||||
assert 'telegram' not in sys.modules
|
||||
assert 'discord' not in sys.modules
|
||||
assert 'slack_bolt' not in sys.modules
|
||||
|
||||
def test_config_import_lightweight(self):
|
||||
from praisonaiagents.bots.config import BotOSConfig
|
||||
import sys
|
||||
assert 'telegram' not in sys.modules
|
||||
assert 'discord' not in sys.modules
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Unit tests for the Chainlit Bot UI streaming bridge.
|
||||
|
||||
Tests the core streaming bridge pattern (StreamEventEmitter → Queue → consumer)
|
||||
WITHOUT requiring chainlit as a dependency. Uses mocks for all chainlit objects.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import queue
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType, StreamEventEmitter
|
||||
|
||||
|
||||
class TestStreamEventEmitterBridge(unittest.TestCase):
|
||||
"""Test that StreamEventEmitter can bridge to an async consumer via Queue."""
|
||||
|
||||
def test_sync_callback_puts_events_into_queue(self):
|
||||
"""Core bridge pattern: sync callback → queue."""
|
||||
event_queue = queue.Queue()
|
||||
|
||||
def relay(event):
|
||||
event_queue.put_nowait(event)
|
||||
|
||||
emitter = StreamEventEmitter()
|
||||
emitter.add_callback(relay)
|
||||
|
||||
# Emit events
|
||||
evt1 = StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello")
|
||||
evt2 = StreamEvent(type=StreamEventType.DELTA_TEXT, content=" world")
|
||||
evt3 = StreamEvent(type=StreamEventType.STREAM_END)
|
||||
|
||||
emitter.emit(evt1)
|
||||
emitter.emit(evt2)
|
||||
emitter.emit(evt3)
|
||||
|
||||
# Verify queue has all events
|
||||
self.assertEqual(event_queue.qsize(), 3)
|
||||
self.assertEqual(event_queue.get_nowait().content, "Hello")
|
||||
self.assertEqual(event_queue.get_nowait().content, " world")
|
||||
self.assertEqual(event_queue.get_nowait().type, StreamEventType.STREAM_END)
|
||||
|
||||
def test_callback_cleanup_after_chat(self):
|
||||
"""Verify callback is removed after agent.chat completes."""
|
||||
emitter = StreamEventEmitter()
|
||||
|
||||
def relay(event):
|
||||
pass
|
||||
|
||||
emitter.add_callback(relay)
|
||||
self.assertEqual(len(emitter._callbacks), 1)
|
||||
|
||||
emitter.remove_callback(relay)
|
||||
self.assertEqual(len(emitter._callbacks), 0)
|
||||
|
||||
def test_multiple_callbacks_isolation(self):
|
||||
"""Multiple callbacks (e.g., gateway + chainlit) don't interfere."""
|
||||
q1 = queue.Queue()
|
||||
q2 = queue.Queue()
|
||||
|
||||
def relay1(event):
|
||||
q1.put_nowait(event)
|
||||
|
||||
def relay2(event):
|
||||
q2.put_nowait(event)
|
||||
|
||||
emitter = StreamEventEmitter()
|
||||
emitter.add_callback(relay1)
|
||||
emitter.add_callback(relay2)
|
||||
|
||||
evt = StreamEvent(type=StreamEventType.DELTA_TEXT, content="test")
|
||||
emitter.emit(evt)
|
||||
|
||||
self.assertEqual(q1.qsize(), 1)
|
||||
self.assertEqual(q2.qsize(), 1)
|
||||
self.assertEqual(q1.get_nowait().content, "test")
|
||||
self.assertEqual(q2.get_nowait().content, "test")
|
||||
|
||||
|
||||
class TestStreamConsumer(unittest.TestCase):
|
||||
"""Test the async consumer that reads from queue and dispatches to Chainlit."""
|
||||
|
||||
def test_delta_text_calls_stream_token(self):
|
||||
"""DELTA_TEXT events should call msg.stream_token(content)."""
|
||||
event_queue = queue.Queue()
|
||||
event_queue.put_nowait(StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello"))
|
||||
event_queue.put_nowait(StreamEvent(type=StreamEventType.DELTA_TEXT, content=" world"))
|
||||
|
||||
# Use sentinel to signal end
|
||||
_STREAM_END = object()
|
||||
event_queue.put_nowait(_STREAM_END)
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.stream_token = AsyncMock()
|
||||
mock_msg.update = AsyncMock()
|
||||
|
||||
async def consume():
|
||||
while True:
|
||||
try:
|
||||
event = event_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
await asyncio.sleep(0.01)
|
||||
continue
|
||||
|
||||
if event is _STREAM_END:
|
||||
break
|
||||
|
||||
if hasattr(event, 'type') and event.type == StreamEventType.DELTA_TEXT:
|
||||
content = getattr(event, 'content', '') or ''
|
||||
if content:
|
||||
await mock_msg.stream_token(content)
|
||||
|
||||
asyncio.run(consume())
|
||||
|
||||
self.assertEqual(mock_msg.stream_token.call_count, 2)
|
||||
mock_msg.stream_token.assert_any_call("Hello")
|
||||
mock_msg.stream_token.assert_any_call(" world")
|
||||
|
||||
def test_tool_call_events(self):
|
||||
"""DELTA_TOOL_CALL events should be captured."""
|
||||
events = [
|
||||
StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
tool_call={"index": 0, "name": "search_web", "arguments": '{"query":'}
|
||||
),
|
||||
StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
tool_call={"index": 0, "arguments": '"python"}'}
|
||||
),
|
||||
StreamEvent(type=StreamEventType.TOOL_CALL_END),
|
||||
]
|
||||
|
||||
captured_tool_calls = {}
|
||||
for event in events:
|
||||
if event.type == StreamEventType.DELTA_TOOL_CALL:
|
||||
tc = event.tool_call or {}
|
||||
idx = tc.get("index", 0)
|
||||
if idx not in captured_tool_calls:
|
||||
captured_tool_calls[idx] = {"name": tc.get("name"), "args": ""}
|
||||
args = tc.get("arguments", "")
|
||||
if args:
|
||||
captured_tool_calls[idx]["args"] += args
|
||||
|
||||
self.assertIn(0, captured_tool_calls)
|
||||
self.assertEqual(captured_tool_calls[0]["name"], "search_web")
|
||||
self.assertEqual(captured_tool_calls[0]["args"], '{"query":"python"}')
|
||||
|
||||
def test_error_event(self):
|
||||
"""ERROR events should be captured."""
|
||||
event = StreamEvent(type=StreamEventType.ERROR, error="Connection failed")
|
||||
self.assertEqual(event.type, StreamEventType.ERROR)
|
||||
self.assertEqual(event.error, "Connection failed")
|
||||
|
||||
def test_stream_end_signals_completion(self):
|
||||
"""STREAM_END should signal the consumer to stop."""
|
||||
event_queue = queue.Queue()
|
||||
event_queue.put_nowait(StreamEvent(type=StreamEventType.STREAM_END))
|
||||
|
||||
_STREAM_END = object()
|
||||
event_queue.put_nowait(_STREAM_END)
|
||||
|
||||
consumed = []
|
||||
|
||||
async def consume():
|
||||
while True:
|
||||
try:
|
||||
event = event_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if event is _STREAM_END:
|
||||
break
|
||||
consumed.append(event)
|
||||
|
||||
asyncio.run(consume())
|
||||
self.assertEqual(len(consumed), 1)
|
||||
self.assertEqual(consumed[0].type, StreamEventType.STREAM_END)
|
||||
|
||||
|
||||
class TestNoChainlitInCoreSdk(unittest.TestCase):
|
||||
"""Verify chainlit is NOT imported in core SDK."""
|
||||
|
||||
def test_no_chainlit_import_in_core(self):
|
||||
"""grep-equivalent: no 'import chainlit' in praisonaiagents/ source."""
|
||||
import os
|
||||
core_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"praisonaiagents"
|
||||
)
|
||||
|
||||
violations = []
|
||||
for root, dirs, files in os.walk(core_path):
|
||||
# Skip venv, __pycache__, .egg-info
|
||||
dirs[:] = [d for d in dirs if d not in ("venv", "__pycache__", ".egg-info", "praisonaiagents.egg-info")]
|
||||
for fname in files:
|
||||
if not fname.endswith(".py"):
|
||||
continue
|
||||
fpath = os.path.join(root, fname)
|
||||
try:
|
||||
with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
if "import chainlit" in stripped or "from chainlit" in stripped:
|
||||
violations.append(f"{fpath}:{i}: {stripped}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.assertEqual(
|
||||
violations, [],
|
||||
"Core SDK must not import chainlit. Found:\n" + "\n".join(violations)
|
||||
)
|
||||
|
||||
|
||||
class TestStreamEventTypes(unittest.TestCase):
|
||||
"""Verify all event types used by the bridge exist."""
|
||||
|
||||
def test_required_event_types_exist(self):
|
||||
self.assertTrue(hasattr(StreamEventType, "DELTA_TEXT"))
|
||||
self.assertTrue(hasattr(StreamEventType, "DELTA_TOOL_CALL"))
|
||||
self.assertTrue(hasattr(StreamEventType, "TOOL_CALL_END"))
|
||||
self.assertTrue(hasattr(StreamEventType, "STREAM_END"))
|
||||
self.assertTrue(hasattr(StreamEventType, "ERROR"))
|
||||
self.assertTrue(hasattr(StreamEventType, "FIRST_TOKEN"))
|
||||
|
||||
def test_stream_event_fields(self):
|
||||
"""StreamEvent has all fields needed by the bridge."""
|
||||
evt = StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
content="hello",
|
||||
tool_call={"name": "test"},
|
||||
error="err",
|
||||
agent_id="agent1",
|
||||
)
|
||||
self.assertEqual(evt.content, "hello")
|
||||
self.assertEqual(evt.tool_call, {"name": "test"})
|
||||
self.assertEqual(evt.error, "err")
|
||||
self.assertEqual(evt.agent_id, "agent1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
TDD Tests for Memory System Fixes.
|
||||
|
||||
Tests written BEFORE implementation to drive the fixes for:
|
||||
1. history=True auto-generates session_id (was broken: _session_store stayed None)
|
||||
2. auto_memory wires process_interaction after agent response
|
||||
3. backend='redis'/'postgres' handling (was storing raw string as _memory_instance)
|
||||
4. MemoryProtocol extended with get_context/save_session
|
||||
5. _history_session_id reconciled with _session_id
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
import logging
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_agent(**kwargs):
|
||||
"""Create an Agent with memory config, mocking LLM to avoid real API calls."""
|
||||
from praisonaiagents import Agent
|
||||
defaults = dict(
|
||||
name="test_agent",
|
||||
instructions="Be helpful",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
agent = Agent(**defaults)
|
||||
return agent
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. history=True auto-generates session_id
|
||||
# ===========================================================================
|
||||
|
||||
class TestHistoryAutoSessionId:
|
||||
"""history=True should auto-generate a session_id so _init_session_store
|
||||
can create the store and history injection fires."""
|
||||
|
||||
def test_history_true_sets_session_id(self):
|
||||
"""When memory=MemoryConfig(history=True), _session_id must NOT be None."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(history=True))
|
||||
# After construction, session_id should be auto-set
|
||||
assert agent._session_id is not None, (
|
||||
"history=True must auto-generate a session_id"
|
||||
)
|
||||
|
||||
def test_history_true_sets_history_session_id(self):
|
||||
"""_history_session_id must match _session_id when auto-generated."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(history=True))
|
||||
assert agent._history_session_id is not None
|
||||
assert agent._history_session_id == agent._session_id
|
||||
|
||||
def test_history_true_with_explicit_session_id(self):
|
||||
"""Explicit session_id should be respected, not overwritten."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(history=True, session_id="my-session"))
|
||||
assert agent._session_id == "my-session"
|
||||
assert agent._history_session_id == "my-session"
|
||||
|
||||
def test_history_true_with_auto_save(self):
|
||||
"""auto_save should be used as session_id when history=True."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(history=True, auto_save="chat1"))
|
||||
assert agent._history_session_id == "chat1"
|
||||
|
||||
def test_history_false_no_session_id(self):
|
||||
"""history=False should NOT auto-generate session_id."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(history=False))
|
||||
# session_id should remain None (no auto-gen)
|
||||
assert agent._history_enabled is False
|
||||
|
||||
def test_history_preset_sets_session_id(self):
|
||||
"""memory='history' preset should auto-generate session_id."""
|
||||
from praisonaiagents import Agent
|
||||
agent = _make_agent(memory="history")
|
||||
assert agent._history_enabled is True
|
||||
assert agent._session_id is not None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. auto_memory wires process_interaction after agent response
|
||||
# ===========================================================================
|
||||
|
||||
class TestAutoMemoryWiring:
|
||||
"""When auto_memory=True in MemoryConfig, AutoMemory.process_interaction
|
||||
should be called after each agent response."""
|
||||
|
||||
def test_auto_memory_flag_stored(self):
|
||||
"""auto_memory flag should be accessible on agent."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(auto_memory=True))
|
||||
assert agent._auto_memory is True or agent._auto_memory is not None
|
||||
|
||||
def test_auto_memory_instance_created_lazily(self):
|
||||
"""AutoMemory wrapper should be created when auto_memory=True."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(auto_memory=True))
|
||||
# The _auto_memory_instance should exist (may be lazy)
|
||||
assert hasattr(agent, '_auto_memory_instance') or hasattr(agent, '_auto_memory')
|
||||
|
||||
def test_process_auto_memory_method_exists(self):
|
||||
"""Agent should have a _process_auto_memory method."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(auto_memory=True))
|
||||
assert hasattr(agent, '_process_auto_memory'), (
|
||||
"Agent must have _process_auto_memory method for wiring"
|
||||
)
|
||||
|
||||
def test_process_auto_memory_noop_when_disabled(self):
|
||||
"""_process_auto_memory should be a no-op when auto_memory=False."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(auto_memory=False))
|
||||
# Should not raise
|
||||
agent._process_auto_memory("hello", "world")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. backend='redis'/'postgres' handling
|
||||
# ===========================================================================
|
||||
|
||||
class TestBackendHandling:
|
||||
"""backend='redis' and 'postgres' should NOT silently store a raw string
|
||||
as _memory_instance. They should either work or raise a clear error."""
|
||||
|
||||
def test_redis_backend_not_string(self):
|
||||
"""backend='redis' must NOT result in _memory_instance being a string."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(backend="redis"))
|
||||
if agent._memory_instance is not None:
|
||||
assert not isinstance(agent._memory_instance, str), (
|
||||
"backend='redis' must not store raw string as memory instance"
|
||||
)
|
||||
|
||||
def test_postgres_backend_not_string(self):
|
||||
"""backend='postgres' must NOT result in _memory_instance being a string."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(backend="postgres"))
|
||||
if agent._memory_instance is not None:
|
||||
assert not isinstance(agent._memory_instance, str), (
|
||||
"backend='postgres' must not store raw string as memory instance"
|
||||
)
|
||||
|
||||
def test_file_backend_creates_file_memory(self):
|
||||
"""backend='file' should create a FileMemory instance."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
from praisonaiagents.memory.file_memory import FileMemory
|
||||
agent = _make_agent(memory=MemoryConfig(backend="file"))
|
||||
assert isinstance(agent._memory_instance, FileMemory)
|
||||
|
||||
def test_unknown_backend_warns(self):
|
||||
"""Unknown backend should log a warning, not silently break."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
import logging
|
||||
with patch.object(logging, 'warning') as mock_warn:
|
||||
agent = _make_agent(memory=MemoryConfig(backend="unknown_db"))
|
||||
# Should either warn or the instance should be None/FileMemory fallback
|
||||
if agent._memory_instance is not None:
|
||||
assert not isinstance(agent._memory_instance, str)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. MemoryProtocol extended with get_context and save_session
|
||||
# ===========================================================================
|
||||
|
||||
class TestMemoryProtocolExtensions:
|
||||
"""MemoryProtocol should include get_context and save_session for
|
||||
consistent interface across FileMemory and Memory."""
|
||||
|
||||
def test_agent_memory_protocol_exists(self):
|
||||
"""AgentMemoryProtocol should exist with get_context/save_session."""
|
||||
from praisonaiagents.memory.protocols import AgentMemoryProtocol
|
||||
assert hasattr(AgentMemoryProtocol, 'get_context')
|
||||
assert hasattr(AgentMemoryProtocol, 'save_session')
|
||||
|
||||
def test_file_memory_satisfies_agent_protocol(self):
|
||||
"""FileMemory should satisfy AgentMemoryProtocol."""
|
||||
from praisonaiagents.memory.protocols import AgentMemoryProtocol
|
||||
from praisonaiagents.memory.file_memory import FileMemory
|
||||
fm = FileMemory(user_id="test")
|
||||
assert isinstance(fm, AgentMemoryProtocol)
|
||||
|
||||
def test_agent_memory_protocol_runtime_checkable(self):
|
||||
"""AgentMemoryProtocol should be runtime_checkable."""
|
||||
from praisonaiagents.memory.protocols import AgentMemoryProtocol
|
||||
|
||||
class FakeMemory:
|
||||
def get_context(self, query=None):
|
||||
return ""
|
||||
def save_session(self, name, conversation_history=None, metadata=None):
|
||||
pass
|
||||
|
||||
assert isinstance(FakeMemory(), AgentMemoryProtocol)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. _history_session_id reconciled with _session_id
|
||||
# ===========================================================================
|
||||
|
||||
class TestHistorySessionIdReconciliation:
|
||||
"""_history_session_id and _session_id should be reconciled so that
|
||||
_init_session_store creates the store AND history injection uses the right ID."""
|
||||
|
||||
def test_history_session_id_matches_session_id_when_auto(self):
|
||||
"""When history=True and no explicit IDs, both should match."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(history=True))
|
||||
if agent._session_id is not None:
|
||||
assert agent._history_session_id == agent._session_id
|
||||
|
||||
def test_explicit_session_id_used_for_history(self):
|
||||
"""Explicit session_id should propagate to _history_session_id."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(history=True, session_id="explicit"))
|
||||
assert agent._session_id == "explicit"
|
||||
assert agent._history_session_id == "explicit"
|
||||
|
||||
def test_auto_save_session_id_for_history(self):
|
||||
"""auto_save should be used as _history_session_id."""
|
||||
from praisonaiagents import Agent, MemoryConfig
|
||||
agent = _make_agent(memory=MemoryConfig(history=True, auto_save="my_session"))
|
||||
assert agent._history_session_id == "my_session"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 6. Smoke tests - end-to-end memory config
|
||||
# ===========================================================================
|
||||
|
||||
class TestMemoryConfigSmoke:
|
||||
"""Smoke tests for common memory configurations."""
|
||||
|
||||
def test_memory_true(self):
|
||||
"""memory=True should create agent with FileMemory, no crash."""
|
||||
from praisonaiagents import Agent
|
||||
agent = _make_agent(memory=True)
|
||||
assert agent._memory_instance is not None
|
||||
|
||||
def test_memory_false(self):
|
||||
"""memory=False should create agent with no memory."""
|
||||
from praisonaiagents import Agent
|
||||
agent = _make_agent(memory=False)
|
||||
assert agent._memory_instance is None
|
||||
|
||||
def test_memory_history_preset(self):
|
||||
"""memory='history' preset should enable history."""
|
||||
from praisonaiagents import Agent
|
||||
agent = _make_agent(memory="history")
|
||||
assert agent._history_enabled is True
|
||||
|
||||
def test_memory_learn_preset(self):
|
||||
"""memory='learn' preset should enable learn."""
|
||||
from praisonaiagents import Agent
|
||||
agent = _make_agent(memory="learn")
|
||||
# Should not crash; learn config should be present
|
||||
assert agent._memory_instance is not None
|
||||
|
||||
def test_store_memory_public_api(self):
|
||||
"""agent.store_memory() should work with memory=True."""
|
||||
from praisonaiagents import Agent
|
||||
agent = _make_agent(memory=True)
|
||||
# Should not raise
|
||||
agent.store_memory("User likes Python", "long_term")
|
||||
|
||||
def test_get_memory_context(self):
|
||||
"""agent.get_memory_context() should return string."""
|
||||
from praisonaiagents import Agent
|
||||
agent = _make_agent(memory=True)
|
||||
ctx = agent.get_memory_context()
|
||||
assert isinstance(ctx, str)
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
TDD tests for Agent parameter consolidation.
|
||||
|
||||
Tests:
|
||||
1. Deprecation warnings fire for old params
|
||||
2. New config fields work (ExecutionConfig.code_execution, MemoryConfig.auto_save, AutonomyConfig.verification_hooks)
|
||||
3. Backward compatibility: old params still function correctly
|
||||
4. Config fields take precedence over deprecated standalone params
|
||||
"""
|
||||
|
||||
import warnings
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDeprecationWarnings:
|
||||
"""Test that deprecated params emit DeprecationWarning."""
|
||||
|
||||
def test_allow_delegation_warns(self):
|
||||
"""allow_delegation=True should emit DeprecationWarning."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", allow_delegation=True)
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
msgs = [str(x.message) for x in deprecation_warnings]
|
||||
assert any("allow_delegation" in m for m in msgs), f"Expected allow_delegation deprecation, got: {msgs}"
|
||||
|
||||
def test_allow_code_execution_warns(self):
|
||||
"""allow_code_execution=True should emit DeprecationWarning."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", allow_code_execution=True)
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
msgs = [str(x.message) for x in deprecation_warnings]
|
||||
assert any("allow_code_execution" in m for m in msgs), f"Expected allow_code_execution deprecation, got: {msgs}"
|
||||
|
||||
def test_code_execution_mode_unsafe_warns(self):
|
||||
"""code_execution_mode='unsafe' should emit DeprecationWarning."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", code_execution_mode="unsafe")
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
msgs = [str(x.message) for x in deprecation_warnings]
|
||||
assert any("code_execution_mode" in m for m in msgs), f"Expected code_execution_mode deprecation, got: {msgs}"
|
||||
|
||||
def test_code_execution_mode_safe_no_warn(self):
|
||||
"""code_execution_mode='safe' (default) should NOT emit DeprecationWarning."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", code_execution_mode="safe")
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
msgs = [str(x.message) for x in deprecation_warnings]
|
||||
assert not any("code_execution_mode" in m for m in msgs), f"Unexpected code_execution_mode deprecation for default 'safe'"
|
||||
|
||||
def test_auto_save_warns(self):
|
||||
"""auto_save='session' should emit DeprecationWarning."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", auto_save="my_session")
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
msgs = [str(x.message) for x in deprecation_warnings]
|
||||
assert any("auto_save" in m for m in msgs), f"Expected auto_save deprecation, got: {msgs}"
|
||||
|
||||
def test_rate_limiter_warns(self):
|
||||
"""rate_limiter=obj should emit DeprecationWarning."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
class FakeLimiter:
|
||||
def acquire(self): pass
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", rate_limiter=FakeLimiter())
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
msgs = [str(x.message) for x in deprecation_warnings]
|
||||
assert any("rate_limiter" in m for m in msgs), f"Expected rate_limiter deprecation, got: {msgs}"
|
||||
|
||||
def test_verification_hooks_warns(self):
|
||||
"""verification_hooks=[...] should emit DeprecationWarning."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", verification_hooks=["hook1"])
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
msgs = [str(x.message) for x in deprecation_warnings]
|
||||
assert any("verification_hooks" in m for m in msgs), f"Expected verification_hooks deprecation, got: {msgs}"
|
||||
|
||||
def test_no_deprecation_on_clean_usage(self):
|
||||
"""Clean usage (no deprecated params) should NOT emit DeprecationWarning."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test")
|
||||
deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
|
||||
# Filter out llm= deprecation which is a separate concern
|
||||
non_llm = [x for x in deprecation_warnings if "llm" not in str(x.message).lower()]
|
||||
assert len(non_llm) == 0, f"Unexpected deprecation warnings: {[str(x.message) for x in non_llm]}"
|
||||
|
||||
|
||||
class TestNewConfigFields:
|
||||
"""Test new fields on config dataclasses."""
|
||||
|
||||
def test_execution_config_code_execution_field(self):
|
||||
"""ExecutionConfig should have code_execution field."""
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
config = ExecutionConfig(code_execution=True, code_mode="unsafe")
|
||||
assert config.code_execution is True
|
||||
assert config.code_mode == "unsafe"
|
||||
|
||||
def test_execution_config_rate_limiter_field(self):
|
||||
"""ExecutionConfig should have rate_limiter field."""
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
|
||||
class FakeLimiter:
|
||||
def acquire(self): pass
|
||||
|
||||
limiter = FakeLimiter()
|
||||
config = ExecutionConfig(rate_limiter=limiter)
|
||||
assert config.rate_limiter is limiter
|
||||
|
||||
def test_execution_config_defaults(self):
|
||||
"""ExecutionConfig defaults should be safe."""
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
config = ExecutionConfig()
|
||||
assert config.code_execution is False
|
||||
assert config.code_mode == "safe"
|
||||
assert config.rate_limiter is None
|
||||
|
||||
def test_execution_config_to_dict(self):
|
||||
"""ExecutionConfig.to_dict() should include code_execution and code_mode."""
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
config = ExecutionConfig(code_execution=True, code_mode="unsafe")
|
||||
d = config.to_dict()
|
||||
assert d["code_execution"] is True
|
||||
assert d["code_mode"] == "unsafe"
|
||||
|
||||
def test_memory_config_auto_save_field(self):
|
||||
"""MemoryConfig should have auto_save field."""
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
config = MemoryConfig(auto_save="my_session")
|
||||
assert config.auto_save == "my_session"
|
||||
|
||||
def test_memory_config_auto_save_default(self):
|
||||
"""MemoryConfig.auto_save should default to None."""
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
config = MemoryConfig()
|
||||
assert config.auto_save is None
|
||||
|
||||
def test_memory_config_to_dict_includes_auto_save(self):
|
||||
"""MemoryConfig.to_dict() should include auto_save."""
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
config = MemoryConfig(auto_save="bot_session")
|
||||
d = config.to_dict()
|
||||
assert d["auto_save"] == "bot_session"
|
||||
|
||||
def test_autonomy_config_verification_hooks_field(self):
|
||||
"""AutonomyConfig should have verification_hooks field."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
hooks = ["hook1", "hook2"]
|
||||
config = AutonomyConfig(verification_hooks=hooks)
|
||||
assert config.verification_hooks == hooks
|
||||
|
||||
def test_autonomy_config_verification_hooks_default(self):
|
||||
"""AutonomyConfig.verification_hooks should default to None."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
config = AutonomyConfig()
|
||||
assert config.verification_hooks is None
|
||||
|
||||
def test_autonomy_config_from_dict_verification_hooks(self):
|
||||
"""AutonomyConfig.from_dict() should extract verification_hooks."""
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
hooks = ["hook_a"]
|
||||
config = AutonomyConfig.from_dict({"verification_hooks": hooks})
|
||||
assert config.verification_hooks == hooks
|
||||
|
||||
|
||||
class TestConsolidatedExtraction:
|
||||
"""Test that config fields are extracted into Agent attributes."""
|
||||
|
||||
def test_execution_config_code_execution_sets_attribute(self):
|
||||
"""Agent(execution=ExecutionConfig(code_execution=True)) should set allow_code_execution."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="test",
|
||||
execution=ExecutionConfig(code_execution=True, code_mode="unsafe"),
|
||||
)
|
||||
assert agent.allow_code_execution is True
|
||||
assert agent.code_execution_mode == "unsafe"
|
||||
|
||||
def test_execution_config_rate_limiter_sets_attribute(self):
|
||||
"""Agent(execution=ExecutionConfig(rate_limiter=obj)) should set _rate_limiter."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
|
||||
class FakeLimiter:
|
||||
def acquire(self): pass
|
||||
|
||||
limiter = FakeLimiter()
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="test",
|
||||
execution=ExecutionConfig(rate_limiter=limiter),
|
||||
)
|
||||
assert agent._rate_limiter is limiter
|
||||
|
||||
def test_autonomy_config_verification_hooks_sets_attribute(self):
|
||||
"""Agent(autonomy=AutonomyConfig(verification_hooks=[...])) should set _verification_hooks."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.autonomy import AutonomyConfig
|
||||
hooks = ["hook1"]
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="test",
|
||||
autonomy=AutonomyConfig(verification_hooks=hooks),
|
||||
)
|
||||
assert agent._verification_hooks == hooks
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Test that deprecated params still work (no breakage)."""
|
||||
|
||||
def test_allow_delegation_still_stored(self):
|
||||
"""allow_delegation should still be stored on Agent."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", allow_delegation=True)
|
||||
assert agent.allow_delegation is True
|
||||
|
||||
def test_allow_code_execution_still_stored(self):
|
||||
"""allow_code_execution should still be stored on Agent."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", allow_code_execution=True)
|
||||
assert agent.allow_code_execution is True
|
||||
|
||||
def test_auto_save_still_stored(self):
|
||||
"""auto_save should still be stored on Agent."""
|
||||
from praisonaiagents import Agent
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", auto_save="session1")
|
||||
assert agent.auto_save == "session1"
|
||||
|
||||
def test_rate_limiter_still_stored(self):
|
||||
"""rate_limiter should still be stored on Agent."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
class FakeLimiter:
|
||||
def acquire(self): pass
|
||||
|
||||
limiter = FakeLimiter()
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", rate_limiter=limiter)
|
||||
assert agent._rate_limiter is limiter
|
||||
|
||||
def test_verification_hooks_still_stored(self):
|
||||
"""verification_hooks should still be stored on Agent."""
|
||||
from praisonaiagents import Agent
|
||||
hooks = ["hook1"]
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(name="test", instructions="test", verification_hooks=hooks)
|
||||
assert agent._verification_hooks == hooks
|
||||
|
||||
|
||||
class TestConfigPrecedence:
|
||||
"""Test that config fields take precedence over deprecated standalone params."""
|
||||
|
||||
def test_execution_config_overrides_standalone_code_execution(self):
|
||||
"""ExecutionConfig.code_execution should override standalone allow_code_execution."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="test",
|
||||
allow_code_execution=False, # deprecated standalone
|
||||
execution=ExecutionConfig(code_execution=True), # config takes precedence
|
||||
)
|
||||
assert agent.allow_code_execution is True
|
||||
|
||||
def test_execution_config_overrides_standalone_rate_limiter(self):
|
||||
"""ExecutionConfig.rate_limiter should override standalone rate_limiter."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
|
||||
class Limiter1:
|
||||
def acquire(self): pass
|
||||
|
||||
class Limiter2:
|
||||
def acquire(self): pass
|
||||
|
||||
old_limiter = Limiter1()
|
||||
new_limiter = Limiter2()
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="test",
|
||||
rate_limiter=old_limiter, # deprecated standalone
|
||||
execution=ExecutionConfig(rate_limiter=new_limiter), # config takes precedence
|
||||
)
|
||||
assert agent._rate_limiter is new_limiter
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Integration tests for schedule tools with Agent class.
|
||||
|
||||
Tests that agents can use schedule tools via:
|
||||
1. Direct function references in tools list
|
||||
2. String-name resolution via TOOL_MAPPINGS
|
||||
3. Tool schema generation for LLM
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestAgentScheduleToolIntegration:
|
||||
"""Test Agent + schedule tools integration (no LLM calls)."""
|
||||
|
||||
def test_agent_accepts_schedule_tools_as_functions(self):
|
||||
"""Agent can be created with schedule tool functions in tools list."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
|
||||
|
||||
agent = Agent(
|
||||
name="test-scheduler",
|
||||
instructions="You can set schedules.",
|
||||
tools=[schedule_add, schedule_list, schedule_remove],
|
||||
)
|
||||
assert len(agent.tools) == 3
|
||||
|
||||
def test_agent_resolves_schedule_tool_strings(self):
|
||||
"""Agent resolves 'schedule_add' etc. from TOOL_MAPPINGS."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="test-scheduler",
|
||||
instructions="You can set schedules.",
|
||||
tools=["schedule_add", "schedule_list", "schedule_remove"],
|
||||
)
|
||||
# String tool names are resolved via _resolve_tool_names → registry
|
||||
# They may or may not resolve depending on registry state,
|
||||
# but the tools list should be populated
|
||||
# The key test is that Agent creation doesn't crash
|
||||
assert agent is not None
|
||||
|
||||
def test_schedule_tool_schema_generation(self):
|
||||
"""Schedule tools produce valid OpenAI function schemas."""
|
||||
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
|
||||
from praisonaiagents.tools.decorator import get_tool_schema
|
||||
|
||||
for fn in [schedule_add, schedule_list, schedule_remove]:
|
||||
schema = get_tool_schema(fn)
|
||||
assert schema is not None
|
||||
assert schema["type"] == "function"
|
||||
assert "function" in schema
|
||||
assert "name" in schema["function"]
|
||||
assert "parameters" in schema["function"]
|
||||
|
||||
def test_schedule_add_schema_has_required_params(self):
|
||||
"""schedule_add schema includes name, schedule, message parameters."""
|
||||
from praisonaiagents.tools.schedule_tools import schedule_add
|
||||
from praisonaiagents.tools.decorator import get_tool_schema
|
||||
|
||||
schema = get_tool_schema(schedule_add)
|
||||
params = schema["function"]["parameters"]
|
||||
assert "name" in params["properties"]
|
||||
assert "schedule" in params["properties"]
|
||||
assert "message" in params["properties"]
|
||||
# name and schedule are required, message has a default
|
||||
assert "name" in params["required"]
|
||||
assert "schedule" in params["required"]
|
||||
|
||||
def test_schedule_remove_schema_has_name_param(self):
|
||||
"""schedule_remove schema includes name parameter."""
|
||||
from praisonaiagents.tools.schedule_tools import schedule_remove
|
||||
from praisonaiagents.tools.decorator import get_tool_schema
|
||||
|
||||
schema = get_tool_schema(schedule_remove)
|
||||
params = schema["function"]["parameters"]
|
||||
assert "name" in params["properties"]
|
||||
assert "name" in params["required"]
|
||||
|
||||
def test_schedule_tools_execute_via_agent_execute_tool(self, tmp_path):
|
||||
"""Agent._execute_tool_call can invoke schedule tools."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
|
||||
|
||||
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
|
||||
|
||||
agent = Agent(
|
||||
name="test-exec",
|
||||
instructions="You can set schedules.",
|
||||
tools=[schedule_add, schedule_list, schedule_remove],
|
||||
)
|
||||
|
||||
# Simulate LLM calling schedule_add
|
||||
result = agent._execute_tool_impl(
|
||||
"schedule_add",
|
||||
{"name": "test-job", "schedule": "hourly", "message": "Check email"},
|
||||
)
|
||||
assert "test-job" in str(result)
|
||||
assert "added" in str(result).lower()
|
||||
|
||||
# Simulate LLM calling schedule_list
|
||||
result = agent._execute_tool_impl("schedule_list", {})
|
||||
assert "test-job" in str(result)
|
||||
|
||||
# Simulate LLM calling schedule_remove
|
||||
result = agent._execute_tool_impl(
|
||||
"schedule_remove", {"name": "test-job"}
|
||||
)
|
||||
assert "removed" in str(result).lower()
|
||||
|
||||
def test_end_to_end_schedule_crud(self, tmp_path):
|
||||
"""Full CRUD cycle: add → list → remove → list (empty)."""
|
||||
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
|
||||
|
||||
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
|
||||
|
||||
# Add
|
||||
r = schedule_add(name="daily-news", schedule="daily", message="Summarize AI news")
|
||||
assert "added" in r.lower()
|
||||
|
||||
# List
|
||||
r = schedule_list()
|
||||
assert "daily-news" in r
|
||||
assert "every 1 day" in r
|
||||
|
||||
# Add another
|
||||
r = schedule_add(name="hourly-check", schedule="*/30m", message="Check inbox")
|
||||
assert "added" in r.lower()
|
||||
|
||||
# List shows both
|
||||
r = schedule_list()
|
||||
assert "daily-news" in r
|
||||
assert "hourly-check" in r
|
||||
assert "2 schedule" in r
|
||||
|
||||
# Remove one
|
||||
r = schedule_remove(name="daily-news")
|
||||
assert "removed" in r.lower()
|
||||
|
||||
# List shows one
|
||||
r = schedule_list()
|
||||
assert "daily-news" not in r
|
||||
assert "hourly-check" in r
|
||||
assert "1 schedule" in r
|
||||
|
||||
# Duplicate name prevention
|
||||
r = schedule_add(name="hourly-check", schedule="hourly", message="Dup")
|
||||
assert "already exists" in r.lower()
|
||||
|
||||
|
||||
class TestScheduleToolImportPaths:
|
||||
"""Test various import paths work correctly."""
|
||||
|
||||
def test_import_from_tools_module(self):
|
||||
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
|
||||
assert callable(schedule_add)
|
||||
assert callable(schedule_list)
|
||||
assert callable(schedule_remove)
|
||||
|
||||
def test_import_from_schedule_tools_directly(self):
|
||||
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list, schedule_remove
|
||||
assert callable(schedule_add)
|
||||
assert callable(schedule_list)
|
||||
assert callable(schedule_remove)
|
||||
|
||||
def test_import_scheduler_models(self):
|
||||
from praisonaiagents.scheduler import Schedule, ScheduleJob
|
||||
s = Schedule(kind="every", every_seconds=60)
|
||||
j = ScheduleJob(name="test", schedule=s)
|
||||
assert j.name == "test"
|
||||
|
||||
def test_import_scheduler_parser(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("hourly")
|
||||
assert s.kind == "every"
|
||||
|
||||
def test_import_scheduler_store(self):
|
||||
from praisonaiagents.scheduler import FileScheduleStore
|
||||
assert FileScheduleStore is not None
|
||||
|
||||
def test_import_scheduler_runner(self):
|
||||
from praisonaiagents.scheduler import ScheduleRunner
|
||||
assert ScheduleRunner is not None
|
||||
@@ -0,0 +1,412 @@
|
||||
"""TDD tests for schedule tools — agent-centric scheduling via tools.
|
||||
|
||||
Tests cover:
|
||||
1. Schedule models (ScheduleJob, Schedule)
|
||||
2. FileScheduleStore (CRUD + persistence)
|
||||
3. Schedule tools (schedule_add, schedule_list, schedule_remove)
|
||||
4. Schedule parser (interval, cron, at)
|
||||
5. ScheduleRunner (tick loop)
|
||||
6. Integration: agent uses schedule tools
|
||||
"""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# ─── 1. Model Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestScheduleModels:
|
||||
"""Test ScheduleJob and Schedule dataclasses."""
|
||||
|
||||
def test_schedule_every_creation(self):
|
||||
from praisonaiagents.scheduler.models import Schedule
|
||||
s = Schedule(kind="every", every_seconds=3600)
|
||||
assert s.kind == "every"
|
||||
assert s.every_seconds == 3600
|
||||
|
||||
def test_schedule_cron_creation(self):
|
||||
from praisonaiagents.scheduler.models import Schedule
|
||||
s = Schedule(kind="cron", cron_expr="0 7 * * *")
|
||||
assert s.kind == "cron"
|
||||
assert s.cron_expr == "0 7 * * *"
|
||||
|
||||
def test_schedule_at_creation(self):
|
||||
from praisonaiagents.scheduler.models import Schedule
|
||||
s = Schedule(kind="at", at="2026-03-01T09:00:00")
|
||||
assert s.kind == "at"
|
||||
assert s.at == "2026-03-01T09:00:00"
|
||||
|
||||
def test_schedule_job_creation(self):
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
sched = Schedule(kind="every", every_seconds=60)
|
||||
job = ScheduleJob(
|
||||
name="test-job",
|
||||
schedule=sched,
|
||||
message="Hello world",
|
||||
)
|
||||
assert job.name == "test-job"
|
||||
assert job.id # auto-generated
|
||||
assert job.enabled is True
|
||||
assert job.message == "Hello world"
|
||||
|
||||
def test_schedule_job_to_dict_roundtrip(self):
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
sched = Schedule(kind="every", every_seconds=300)
|
||||
job = ScheduleJob(name="roundtrip", schedule=sched, message="test")
|
||||
d = job.to_dict()
|
||||
assert isinstance(d, dict)
|
||||
assert d["name"] == "roundtrip"
|
||||
assert d["schedule"]["kind"] == "every"
|
||||
# Roundtrip
|
||||
job2 = ScheduleJob.from_dict(d)
|
||||
assert job2.name == job.name
|
||||
assert job2.id == job.id
|
||||
assert job2.schedule.every_seconds == 300
|
||||
|
||||
def test_schedule_job_defaults(self):
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
sched = Schedule(kind="every", every_seconds=60)
|
||||
job = ScheduleJob(name="defaults", schedule=sched)
|
||||
assert job.enabled is True
|
||||
assert job.message == ""
|
||||
assert job.agent_id is None
|
||||
assert job.session_target == "isolated"
|
||||
assert job.delete_after_run is False
|
||||
|
||||
|
||||
# ─── 2. Store Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestFileScheduleStore:
|
||||
"""Test FileScheduleStore CRUD and persistence."""
|
||||
|
||||
def _make_store(self, tmp_path):
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
return FileScheduleStore(store_dir=str(tmp_path))
|
||||
|
||||
def _make_job(self, name="test-job"):
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
return ScheduleJob(
|
||||
name=name,
|
||||
schedule=Schedule(kind="every", every_seconds=60),
|
||||
message="test message",
|
||||
)
|
||||
|
||||
def test_add_and_get(self, tmp_path):
|
||||
store = self._make_store(tmp_path)
|
||||
job = self._make_job()
|
||||
store.add(job)
|
||||
retrieved = store.get(job.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.name == "test-job"
|
||||
|
||||
def test_add_duplicate_id_raises(self, tmp_path):
|
||||
store = self._make_store(tmp_path)
|
||||
job = self._make_job()
|
||||
store.add(job)
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.add(job)
|
||||
|
||||
def test_list_all(self, tmp_path):
|
||||
store = self._make_store(tmp_path)
|
||||
store.add(self._make_job("job-1"))
|
||||
store.add(self._make_job("job-2"))
|
||||
jobs = store.list()
|
||||
assert len(jobs) == 2
|
||||
|
||||
def test_list_by_agent_id(self, tmp_path):
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
store = self._make_store(tmp_path)
|
||||
j1 = ScheduleJob(name="a", schedule=Schedule(kind="every", every_seconds=60), agent_id="agent-1")
|
||||
j2 = ScheduleJob(name="b", schedule=Schedule(kind="every", every_seconds=60), agent_id="agent-2")
|
||||
store.add(j1)
|
||||
store.add(j2)
|
||||
assert len(store.list(agent_id="agent-1")) == 1
|
||||
|
||||
def test_remove(self, tmp_path):
|
||||
store = self._make_store(tmp_path)
|
||||
job = self._make_job()
|
||||
store.add(job)
|
||||
assert store.remove(job.id) is True
|
||||
assert store.get(job.id) is None
|
||||
|
||||
def test_remove_nonexistent(self, tmp_path):
|
||||
store = self._make_store(tmp_path)
|
||||
assert store.remove("nonexistent") is False
|
||||
|
||||
def test_persistence_across_instances(self, tmp_path):
|
||||
store1 = self._make_store(tmp_path)
|
||||
job = self._make_job()
|
||||
store1.add(job)
|
||||
|
||||
# New store instance reads from same dir
|
||||
store2 = self._make_store(tmp_path)
|
||||
retrieved = store2.get(job.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.name == "test-job"
|
||||
|
||||
def test_update(self, tmp_path):
|
||||
store = self._make_store(tmp_path)
|
||||
job = self._make_job()
|
||||
store.add(job)
|
||||
job.enabled = False
|
||||
store.update(job)
|
||||
retrieved = store.get(job.id)
|
||||
assert retrieved.enabled is False
|
||||
|
||||
def test_empty_store_list(self, tmp_path):
|
||||
store = self._make_store(tmp_path)
|
||||
assert store.list() == []
|
||||
|
||||
|
||||
# ─── 3. Parser Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestScheduleParser:
|
||||
"""Test schedule expression parsing."""
|
||||
|
||||
def test_parse_hourly(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
s = parse_schedule("hourly")
|
||||
assert s.kind == "every"
|
||||
assert s.every_seconds == 3600
|
||||
|
||||
def test_parse_daily(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
s = parse_schedule("daily")
|
||||
assert s.kind == "every"
|
||||
assert s.every_seconds == 86400
|
||||
|
||||
def test_parse_interval_minutes(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
s = parse_schedule("*/30m")
|
||||
assert s.kind == "every"
|
||||
assert s.every_seconds == 1800
|
||||
|
||||
def test_parse_interval_hours(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
s = parse_schedule("*/6h")
|
||||
assert s.kind == "every"
|
||||
assert s.every_seconds == 21600
|
||||
|
||||
def test_parse_interval_seconds(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
s = parse_schedule("*/10s")
|
||||
assert s.kind == "every"
|
||||
assert s.every_seconds == 10
|
||||
|
||||
def test_parse_cron_expression(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
s = parse_schedule("cron:0 7 * * *")
|
||||
assert s.kind == "cron"
|
||||
assert s.cron_expr == "0 7 * * *"
|
||||
|
||||
def test_parse_at_iso(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
s = parse_schedule("at:2026-03-01T09:00:00")
|
||||
assert s.kind == "at"
|
||||
assert s.at == "2026-03-01T09:00:00"
|
||||
|
||||
def test_parse_numeric_seconds(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
s = parse_schedule("3600")
|
||||
assert s.kind == "every"
|
||||
assert s.every_seconds == 3600
|
||||
|
||||
def test_parse_relative_minutes(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
s = parse_schedule("in 20 minutes")
|
||||
assert s.kind == "at"
|
||||
assert s.at is not None # Should be an ISO timestamp ~20min from now
|
||||
|
||||
def test_parse_invalid_raises(self):
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
with pytest.raises(ValueError):
|
||||
parse_schedule("")
|
||||
|
||||
|
||||
# ─── 4. Schedule Tool Tests ──────────────────────────────────────────────────
|
||||
|
||||
class TestScheduleTools:
|
||||
"""Test schedule_add, schedule_list, schedule_remove tool functions."""
|
||||
|
||||
def test_schedule_add_returns_confirmation(self, tmp_path):
|
||||
from praisonaiagents.tools.schedule_tools import schedule_add
|
||||
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
|
||||
result = schedule_add(name="test-reminder", schedule="hourly", message="Check email")
|
||||
assert "test-reminder" in result
|
||||
assert "scheduled" in result.lower() or "added" in result.lower()
|
||||
|
||||
def test_schedule_list_returns_jobs(self, tmp_path):
|
||||
from praisonaiagents.tools.schedule_tools import schedule_list, schedule_add
|
||||
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
mock_gs.return_value = store
|
||||
schedule_add(name="job-1", schedule="hourly", message="Task 1")
|
||||
schedule_add(name="job-2", schedule="daily", message="Task 2")
|
||||
result = schedule_list()
|
||||
assert "job-1" in result
|
||||
assert "job-2" in result
|
||||
|
||||
def test_schedule_remove_returns_confirmation(self, tmp_path):
|
||||
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_remove
|
||||
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
mock_gs.return_value = store
|
||||
schedule_add(name="to-remove", schedule="hourly", message="Temp")
|
||||
result = schedule_remove(name="to-remove")
|
||||
assert "removed" in result.lower() or "deleted" in result.lower()
|
||||
|
||||
def test_schedule_remove_nonexistent(self, tmp_path):
|
||||
from praisonaiagents.tools.schedule_tools import schedule_remove
|
||||
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
|
||||
result = schedule_remove(name="nonexistent")
|
||||
assert "not found" in result.lower()
|
||||
|
||||
def test_schedule_list_empty(self, tmp_path):
|
||||
from praisonaiagents.tools.schedule_tools import schedule_list
|
||||
with patch('praisonaiagents.tools.schedule_tools._get_store') as mock_gs:
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
mock_gs.return_value = FileScheduleStore(store_dir=str(tmp_path))
|
||||
result = schedule_list()
|
||||
assert "no schedule" in result.lower() or "empty" in result.lower() or "0" in result
|
||||
|
||||
|
||||
# ─── 5. Runner Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestScheduleRunner:
|
||||
"""Test ScheduleRunner tick loop."""
|
||||
|
||||
def test_runner_creation(self, tmp_path):
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
runner = ScheduleRunner(store=store)
|
||||
assert runner is not None
|
||||
|
||||
def test_get_due_jobs_interval(self, tmp_path):
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
# Job with 1-second interval, last_run 10 seconds ago
|
||||
job = ScheduleJob(
|
||||
name="due-job",
|
||||
schedule=Schedule(kind="every", every_seconds=1),
|
||||
message="test",
|
||||
)
|
||||
job.last_run_at = time.time() - 10 # Ran 10s ago, interval is 1s → due
|
||||
store.add(job)
|
||||
runner = ScheduleRunner(store=store)
|
||||
due = runner.get_due_jobs()
|
||||
assert len(due) == 1
|
||||
assert due[0].name == "due-job"
|
||||
|
||||
def test_get_due_jobs_not_yet(self, tmp_path):
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
job = ScheduleJob(
|
||||
name="not-due",
|
||||
schedule=Schedule(kind="every", every_seconds=9999),
|
||||
message="test",
|
||||
)
|
||||
job.last_run_at = time.time() # Just ran
|
||||
store.add(job)
|
||||
runner = ScheduleRunner(store=store)
|
||||
due = runner.get_due_jobs()
|
||||
assert len(due) == 0
|
||||
|
||||
def test_get_due_jobs_at_past(self, tmp_path):
|
||||
"""One-shot 'at' jobs in the past should be due."""
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
past = "2020-01-01T00:00:00"
|
||||
job = ScheduleJob(
|
||||
name="past-at",
|
||||
schedule=Schedule(kind="at", at=past),
|
||||
message="test",
|
||||
)
|
||||
store.add(job)
|
||||
runner = ScheduleRunner(store=store)
|
||||
due = runner.get_due_jobs()
|
||||
assert len(due) == 1
|
||||
|
||||
def test_disabled_job_not_due(self, tmp_path):
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
store = FileScheduleStore(store_dir=str(tmp_path))
|
||||
job = ScheduleJob(
|
||||
name="disabled",
|
||||
schedule=Schedule(kind="every", every_seconds=1),
|
||||
message="test",
|
||||
enabled=False,
|
||||
)
|
||||
store.add(job)
|
||||
runner = ScheduleRunner(store=store)
|
||||
due = runner.get_due_jobs()
|
||||
assert len(due) == 0
|
||||
|
||||
|
||||
# ─── 6. Hook Event Tests ─────────────────────────────────────────────────────
|
||||
|
||||
class TestScheduleHookEvents:
|
||||
"""Test that scheduling hook events exist."""
|
||||
|
||||
def test_schedule_hook_events_exist(self):
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
assert hasattr(HookEvent, 'SCHEDULE_ADD')
|
||||
assert hasattr(HookEvent, 'SCHEDULE_REMOVE')
|
||||
assert hasattr(HookEvent, 'SCHEDULE_TRIGGER')
|
||||
|
||||
def test_schedule_hook_event_values(self):
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
assert HookEvent.SCHEDULE_ADD.value == "schedule_add"
|
||||
assert HookEvent.SCHEDULE_REMOVE.value == "schedule_remove"
|
||||
assert HookEvent.SCHEDULE_TRIGGER.value == "schedule_trigger"
|
||||
|
||||
|
||||
# ─── 7. Lazy Import Tests ────────────────────────────────────────────────────
|
||||
|
||||
class TestLazyImport:
|
||||
"""Ensure schedule tools don't impact import time."""
|
||||
|
||||
def test_praisonaiagents_import_no_scheduler(self):
|
||||
"""Importing praisonaiagents should NOT load scheduler module."""
|
||||
# We verify by checking TOOL_MAPPINGS references lazy paths
|
||||
from praisonaiagents.tools import TOOL_MAPPINGS
|
||||
for key in ['schedule_add', 'schedule_list', 'schedule_remove']:
|
||||
assert key in TOOL_MAPPINGS
|
||||
module_path, _ = TOOL_MAPPINGS[key]
|
||||
assert module_path == '.schedule_tools'
|
||||
|
||||
|
||||
# ─── 8. TOOL_MAPPINGS Registration Test ──────────────────────────────────────
|
||||
|
||||
class TestToolMappings:
|
||||
"""Test schedule tools are registered in TOOL_MAPPINGS for string-name resolution."""
|
||||
|
||||
def test_schedule_tools_in_mappings(self):
|
||||
from praisonaiagents.tools import TOOL_MAPPINGS
|
||||
assert 'schedule_add' in TOOL_MAPPINGS
|
||||
assert 'schedule_list' in TOOL_MAPPINGS
|
||||
assert 'schedule_remove' in TOOL_MAPPINGS
|
||||
|
||||
def test_schedule_tools_lazy_loadable(self):
|
||||
"""Verify tools can be lazy-loaded via __getattr__."""
|
||||
from praisonaiagents import tools
|
||||
schedule_add = getattr(tools, 'schedule_add')
|
||||
assert callable(schedule_add)
|
||||
schedule_list = getattr(tools, 'schedule_list')
|
||||
assert callable(schedule_list)
|
||||
schedule_remove = getattr(tools, 'schedule_remove')
|
||||
assert callable(schedule_remove)
|
||||
@@ -0,0 +1,819 @@
|
||||
"""
|
||||
Comprehensive tests for WhatsApp message filtering feature.
|
||||
|
||||
Tests cover:
|
||||
- Constructor parameter handling (allowed_numbers, allowed_groups, respond_to_all)
|
||||
- Phone number normalization
|
||||
- Filtering logic in _on_web_message (self-only, allowlists, respond_to_all)
|
||||
- CLI flag parsing and wiring
|
||||
- YAML config wiring
|
||||
- Edge cases
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest # noqa: F401 — used by test discovery
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "praisonai"))
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _make_bot(**kwargs):
|
||||
"""Create a WhatsAppBot in web mode with filtering kwargs."""
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
defaults = dict(mode="web")
|
||||
defaults.update(kwargs)
|
||||
return WhatsAppBot(**defaults)
|
||||
|
||||
|
||||
def _make_neonize_event(
|
||||
is_from_me=False,
|
||||
is_group=False,
|
||||
sender="[email protected]",
|
||||
chat="[email protected]",
|
||||
text="hello",
|
||||
timestamp=None,
|
||||
):
|
||||
"""Create a mock neonize MessageEv with MessageSource fields.
|
||||
|
||||
Args:
|
||||
timestamp: Epoch seconds (float). If *None* defaults to
|
||||
``time.time()`` so the event looks "fresh".
|
||||
"""
|
||||
import time as _time
|
||||
event = MagicMock()
|
||||
event.Info.MessageSource.IsFromMe = is_from_me
|
||||
event.Info.MessageSource.IsGroup = is_group
|
||||
event.Info.MessageSource.Sender = MagicMock()
|
||||
event.Info.MessageSource.Sender.__str__ = lambda self: sender
|
||||
event.Info.MessageSource.Chat = MagicMock()
|
||||
event.Info.MessageSource.Chat.__str__ = lambda self: chat
|
||||
event.Info.ID = "msg-test-123"
|
||||
# Store as seconds (the production code handles ms→s conversion)
|
||||
event.Info.Timestamp = timestamp if timestamp is not None else _time.time()
|
||||
event.Message.conversation = text
|
||||
event.Message.extendedTextMessage = None
|
||||
return event
|
||||
|
||||
|
||||
# ── Constructor: Filtering Defaults ──────────────────────────────
|
||||
|
||||
class TestFilteringDefaults:
|
||||
"""Test default filtering behavior (self-only)."""
|
||||
|
||||
def test_default_respond_to_all_false(self):
|
||||
bot = _make_bot()
|
||||
assert bot._respond_to_all is False
|
||||
|
||||
def test_default_allowed_numbers_empty(self):
|
||||
bot = _make_bot()
|
||||
assert bot._allowed_numbers == set()
|
||||
|
||||
def test_default_allowed_groups_empty(self):
|
||||
bot = _make_bot()
|
||||
assert bot._allowed_groups == set()
|
||||
|
||||
|
||||
# ── Constructor: respond_to_all ──────────────────────────────────
|
||||
|
||||
class TestRespondToAll:
|
||||
"""Test respond_to_all parameter."""
|
||||
|
||||
def test_respond_to_all_true(self):
|
||||
bot = _make_bot(respond_to_all=True)
|
||||
assert bot._respond_to_all is True
|
||||
|
||||
def test_respond_to_all_false_explicit(self):
|
||||
bot = _make_bot(respond_to_all=False)
|
||||
assert bot._respond_to_all is False
|
||||
|
||||
|
||||
# ── Constructor: Phone Number Normalization ──────────────────────
|
||||
|
||||
class TestPhoneNumberNormalization:
|
||||
"""Test that allowed_numbers are normalized (digits only)."""
|
||||
|
||||
def test_plain_number(self):
|
||||
bot = _make_bot(allowed_numbers=["1234567890"])
|
||||
assert "1234567890" in bot._allowed_numbers
|
||||
|
||||
def test_plus_prefix_stripped(self):
|
||||
bot = _make_bot(allowed_numbers=["+1234567890"])
|
||||
assert "1234567890" in bot._allowed_numbers
|
||||
|
||||
def test_dashes_stripped(self):
|
||||
bot = _make_bot(allowed_numbers=["1-234-567-8901"])
|
||||
assert "12345678901" in bot._allowed_numbers
|
||||
|
||||
def test_spaces_stripped(self):
|
||||
bot = _make_bot(allowed_numbers=["1 234 567 8901"])
|
||||
assert "12345678901" in bot._allowed_numbers
|
||||
|
||||
def test_parentheses_stripped(self):
|
||||
bot = _make_bot(allowed_numbers=["(123) 456-7890"])
|
||||
assert "1234567890" in bot._allowed_numbers
|
||||
|
||||
def test_multiple_numbers(self):
|
||||
bot = _make_bot(allowed_numbers=["+1-555-1234", "9876543210"])
|
||||
assert "15551234" in bot._allowed_numbers
|
||||
assert "9876543210" in bot._allowed_numbers
|
||||
|
||||
def test_empty_string_ignored(self):
|
||||
bot = _make_bot(allowed_numbers=["", "1234"])
|
||||
assert "" not in bot._allowed_numbers
|
||||
assert "1234" in bot._allowed_numbers
|
||||
|
||||
def test_none_allowed_numbers(self):
|
||||
bot = _make_bot(allowed_numbers=None)
|
||||
assert bot._allowed_numbers == set()
|
||||
|
||||
|
||||
# ── Constructor: Allowed Groups ──────────────────────────────────
|
||||
|
||||
class TestAllowedGroups:
|
||||
"""Test allowed_groups parameter."""
|
||||
|
||||
def test_single_group(self):
|
||||
bot = _make_bot(allowed_groups=["[email protected]"])
|
||||
assert "[email protected]" in bot._allowed_groups
|
||||
|
||||
def test_multiple_groups(self):
|
||||
bot = _make_bot(allowed_groups=["[email protected]", "[email protected]"])
|
||||
assert "[email protected]" in bot._allowed_groups
|
||||
assert "[email protected]" in bot._allowed_groups
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
bot = _make_bot(allowed_groups=[" [email protected] "])
|
||||
assert "[email protected]" in bot._allowed_groups
|
||||
|
||||
def test_none_allowed_groups(self):
|
||||
bot = _make_bot(allowed_groups=None)
|
||||
assert bot._allowed_groups == set()
|
||||
|
||||
|
||||
# ── Filtering Logic: _on_web_message ─────────────────────────────
|
||||
|
||||
class TestFilteringSelfOnly:
|
||||
"""Test default self-only filtering (no allowlists, no respond_to_all).
|
||||
|
||||
'Self-only' means the user is messaging their own number (self-chat).
|
||||
IsFromMe alone is NOT enough — the chat JID must equal the sender JID.
|
||||
"""
|
||||
|
||||
def test_true_self_chat_processed(self):
|
||||
"""Self-chat (sender==chat, IsFromMe) should pass."""
|
||||
bot = _make_bot()
|
||||
assert bot._respond_to_all is False
|
||||
assert bot._allowed_numbers == set()
|
||||
assert bot._allowed_groups == set()
|
||||
|
||||
event = _make_neonize_event(
|
||||
is_from_me=True,
|
||||
sender="[email protected]",
|
||||
chat="[email protected]",
|
||||
text="hello bot",
|
||||
)
|
||||
is_from_me = bool(event.Info.MessageSource.IsFromMe)
|
||||
assert is_from_me is True
|
||||
|
||||
def test_is_from_me_in_other_chat_rejected(self):
|
||||
"""IsFromMe=True but in someone else's chat → rejected (not self-chat)."""
|
||||
bot = _make_bot()
|
||||
event = _make_neonize_event(
|
||||
is_from_me=True,
|
||||
sender="[email protected]",
|
||||
chat="[email protected]", # different chat
|
||||
text="hello",
|
||||
)
|
||||
# sender != chat → NOT self-chat → should be filtered
|
||||
sender_user = "1234567890"
|
||||
chat_user = "9999999999"
|
||||
assert sender_user != chat_user # not self-chat
|
||||
|
||||
def test_other_person_message_rejected(self):
|
||||
"""IsFromMe=False with no allowlists → should be rejected."""
|
||||
bot = _make_bot()
|
||||
event = _make_neonize_event(is_from_me=False, text="hello")
|
||||
|
||||
is_from_me = bool(event.Info.MessageSource.IsFromMe)
|
||||
is_group = bool(event.Info.MessageSource.IsGroup) or "@g.us" in str(event.Info.MessageSource.Chat)
|
||||
|
||||
should_filter = True
|
||||
if bot._respond_to_all:
|
||||
should_filter = False
|
||||
elif is_from_me:
|
||||
# Changed: is_from_me alone no longer passes; need self-chat check
|
||||
should_filter = False # but E2E test covers the stricter logic
|
||||
elif is_group and bot._allowed_groups:
|
||||
should_filter = False
|
||||
elif not is_group and bot._allowed_numbers:
|
||||
should_filter = False
|
||||
|
||||
assert should_filter is True # message should be filtered out
|
||||
|
||||
|
||||
class TestFilteringRespondToAll:
|
||||
"""Test respond_to_all=True skips filtering entirely."""
|
||||
|
||||
def test_respond_to_all_allows_any_message(self):
|
||||
bot = _make_bot(respond_to_all=True)
|
||||
# With respond_to_all, filter is skipped for all messages
|
||||
assert bot._respond_to_all is True
|
||||
|
||||
def test_respond_to_all_allows_group_messages(self):
|
||||
bot = _make_bot(respond_to_all=True)
|
||||
assert bot._respond_to_all is True
|
||||
|
||||
|
||||
class TestFilteringAllowedNumbers:
|
||||
"""Test allowed_numbers filtering."""
|
||||
|
||||
def test_allowed_number_passes(self):
|
||||
bot = _make_bot(allowed_numbers=["1234567890"])
|
||||
sender = "[email protected]"
|
||||
sender_num = sender.split("@")[0].split(":")[0]
|
||||
assert sender_num in bot._allowed_numbers
|
||||
|
||||
def test_disallowed_number_rejected(self):
|
||||
bot = _make_bot(allowed_numbers=["1234567890"])
|
||||
sender = "[email protected]"
|
||||
sender_num = sender.split("@")[0].split(":")[0]
|
||||
assert sender_num not in bot._allowed_numbers
|
||||
|
||||
def test_sender_with_device_suffix(self):
|
||||
"""Neonize sometimes includes :device_id in sender JID."""
|
||||
bot = _make_bot(allowed_numbers=["1234567890"])
|
||||
sender = "1234567890:[email protected]"
|
||||
sender_num = sender.split("@")[0].split(":")[0]
|
||||
assert sender_num in bot._allowed_numbers
|
||||
|
||||
def test_self_still_allowed_with_allowlist(self):
|
||||
"""Self-messages should still pass even when allowlist is set."""
|
||||
bot = _make_bot(allowed_numbers=["9999999999"])
|
||||
# Self-message from different number — IsFromMe takes priority
|
||||
event = _make_neonize_event(is_from_me=True, sender="[email protected]")
|
||||
is_from_me = bool(event.Info.MessageSource.IsFromMe)
|
||||
assert is_from_me is True # self always passes
|
||||
|
||||
|
||||
class TestFilteringAllowedGroups:
|
||||
"""Test allowed_groups filtering."""
|
||||
|
||||
def test_allowed_group_passes(self):
|
||||
bot = _make_bot(allowed_groups=["[email protected]"])
|
||||
chat = "[email protected]"
|
||||
chat_str = chat.split("@")[0]
|
||||
assert chat in bot._allowed_groups or chat_str in bot._allowed_groups
|
||||
|
||||
def test_allowed_group_by_id_only(self):
|
||||
"""Allow matching by group ID without @g.us suffix."""
|
||||
bot = _make_bot(allowed_groups=["120363123456"])
|
||||
chat = "[email protected]"
|
||||
chat_str = chat.split("@")[0]
|
||||
assert chat_str in bot._allowed_groups
|
||||
|
||||
def test_disallowed_group_rejected(self):
|
||||
bot = _make_bot(allowed_groups=["[email protected]"])
|
||||
chat = "[email protected]"
|
||||
chat_str = chat.split("@")[0]
|
||||
assert chat not in bot._allowed_groups
|
||||
assert chat_str not in bot._allowed_groups
|
||||
|
||||
def test_group_message_from_other_ignored_without_group_allowlist(self):
|
||||
"""Group message without allowed_groups set → rejected (handled by e2e tests)."""
|
||||
default_bot = _make_bot()
|
||||
assert default_bot._allowed_groups == set()
|
||||
|
||||
|
||||
class TestFilteringCombined:
|
||||
"""Test combined allowed_numbers + allowed_groups."""
|
||||
|
||||
def test_allowed_number_in_dm(self):
|
||||
bot = _make_bot(
|
||||
allowed_numbers=["1234567890"],
|
||||
allowed_groups=["[email protected]"],
|
||||
)
|
||||
sender = "[email protected]"
|
||||
sender_num = sender.split("@")[0].split(":")[0]
|
||||
assert sender_num in bot._allowed_numbers
|
||||
|
||||
def test_allowed_group_still_works(self):
|
||||
combined = _make_bot(
|
||||
allowed_numbers=["1234567890"],
|
||||
allowed_groups=["[email protected]"],
|
||||
)
|
||||
assert "[email protected]" in combined._allowed_groups
|
||||
|
||||
def test_disallowed_number_not_in_groups_rejected(self):
|
||||
combined = _make_bot(
|
||||
allowed_numbers=["1234567890"],
|
||||
allowed_groups=["[email protected]"],
|
||||
)
|
||||
sender = "[email protected]"
|
||||
sender_num = sender.split("@")[0].split(":")[0]
|
||||
assert sender_num not in combined._allowed_numbers
|
||||
|
||||
def test_self_message_always_passes_combined(self):
|
||||
combined = _make_bot(
|
||||
allowed_numbers=["1234567890"],
|
||||
allowed_groups=["[email protected]"],
|
||||
)
|
||||
# Even with allowlists, self should pass
|
||||
event = _make_neonize_event(is_from_me=True, sender="[email protected]")
|
||||
assert bool(event.Info.MessageSource.IsFromMe) is True
|
||||
assert combined._respond_to_all is False # verify bot config used
|
||||
|
||||
|
||||
# ── End-to-End Filtering Simulation ──────────────────────────────
|
||||
|
||||
class TestFilteringEndToEnd:
|
||||
"""Simulate the actual filtering logic from _on_web_message.
|
||||
|
||||
This mirrors the UPDATED logic:
|
||||
- Default mode requires *self-chat* (sender == chat, IsFromMe, not group).
|
||||
- IsFromMe alone in another person's chat is REJECTED.
|
||||
- Allowlists and respond_to_all bypass as before.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _jid_user(jid_str: str) -> str:
|
||||
return jid_str.split("@")[0].split(":")[0] if jid_str else ""
|
||||
|
||||
def _should_process(self, bot, is_from_me, is_group, sender_jid, chat_jid):
|
||||
"""Reproduce the UPDATED filtering logic from _on_web_message."""
|
||||
if bot._respond_to_all:
|
||||
return True
|
||||
|
||||
is_self_chat = (
|
||||
is_from_me
|
||||
and not is_group
|
||||
and self._jid_user(sender_jid) == self._jid_user(chat_jid)
|
||||
)
|
||||
|
||||
if is_self_chat:
|
||||
return True
|
||||
|
||||
if is_group and bot._allowed_groups:
|
||||
chat_jid_str = chat_jid.split("@")[0] if "@" in chat_jid else chat_jid
|
||||
if chat_jid in bot._allowed_groups or chat_jid_str in bot._allowed_groups:
|
||||
return True
|
||||
return False
|
||||
|
||||
if not is_group and bot._allowed_numbers:
|
||||
sender_num = self._jid_user(sender_jid)
|
||||
if sender_num in bot._allowed_numbers:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Not self-chat, not in any allowlist
|
||||
return False
|
||||
|
||||
# ── Default self-chat mode ──────────────────────────────────
|
||||
|
||||
def test_e2e_self_chat_passes(self):
|
||||
bot = _make_bot()
|
||||
# True self-chat: sender==chat, is_from_me
|
||||
assert self._should_process(
|
||||
bot, True, False, "[email protected]", "[email protected]"
|
||||
) is True
|
||||
|
||||
def test_e2e_is_from_me_other_chat_rejected(self):
|
||||
"""IsFromMe in another person's chat should NOT pass default filter."""
|
||||
bot = _make_bot()
|
||||
assert self._should_process(
|
||||
bot, True, False, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
|
||||
def test_e2e_other_person_rejected(self):
|
||||
bot = _make_bot()
|
||||
assert self._should_process(
|
||||
bot, False, False, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
|
||||
def test_e2e_group_rejected_by_default(self):
|
||||
bot = _make_bot()
|
||||
assert self._should_process(
|
||||
bot, False, True, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
|
||||
def test_e2e_is_from_me_in_group_rejected(self):
|
||||
"""IsFromMe in a group chat should NOT pass default filter."""
|
||||
bot = _make_bot()
|
||||
assert self._should_process(
|
||||
bot, True, True, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
|
||||
# ── respond_to_all ──────────────────────────────────────────
|
||||
|
||||
def test_e2e_respond_to_all(self):
|
||||
bot = _make_bot(respond_to_all=True)
|
||||
assert self._should_process(bot, True, False, "me@s", "me@s") is True
|
||||
assert self._should_process(bot, False, False, "other@s", "other@s") is True
|
||||
assert self._should_process(bot, False, True, "other@s", "[email protected]") is True
|
||||
# IsFromMe in other chat also passes with respond_to_all
|
||||
assert self._should_process(bot, True, False, "me@s", "friend@s") is True
|
||||
|
||||
# ── allowed_numbers ─────────────────────────────────────────
|
||||
|
||||
def test_e2e_allowed_numbers(self):
|
||||
bot = _make_bot(allowed_numbers=["1234567890"])
|
||||
# Self-chat always passes
|
||||
assert self._should_process(
|
||||
bot, True, False, "[email protected]", "[email protected]"
|
||||
) is True
|
||||
# Allowed number passes
|
||||
assert self._should_process(
|
||||
bot, False, False, "[email protected]", "[email protected]"
|
||||
) is True
|
||||
# Disallowed number rejected
|
||||
assert self._should_process(
|
||||
bot, False, False, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
# Group message still rejected (no group allowlist)
|
||||
assert self._should_process(
|
||||
bot, False, True, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
|
||||
# ── allowed_groups ──────────────────────────────────────────
|
||||
|
||||
def test_e2e_allowed_groups(self):
|
||||
bot = _make_bot(allowed_groups=["[email protected]"])
|
||||
# Self-chat passes
|
||||
assert self._should_process(
|
||||
bot, True, False, "me@s", "me@s"
|
||||
) is True
|
||||
# Allowed group passes
|
||||
assert self._should_process(
|
||||
bot, False, True, "other@s", "[email protected]"
|
||||
) is True
|
||||
# Disallowed group rejected
|
||||
assert self._should_process(
|
||||
bot, False, True, "other@s", "[email protected]"
|
||||
) is False
|
||||
# DM from other rejected
|
||||
assert self._should_process(
|
||||
bot, False, False, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
|
||||
# ── combined ────────────────────────────────────────────────
|
||||
|
||||
def test_e2e_combined(self):
|
||||
bot = _make_bot(
|
||||
allowed_numbers=["5551234"],
|
||||
allowed_groups=["[email protected]"],
|
||||
)
|
||||
# Self-chat passes
|
||||
assert self._should_process(
|
||||
bot, True, False, "[email protected]", "[email protected]"
|
||||
) is True
|
||||
# IsFromMe in OTHER chat does NOT pass
|
||||
assert self._should_process(
|
||||
bot, True, False, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
# Allowed number
|
||||
assert self._should_process(
|
||||
bot, False, False, "[email protected]", "[email protected]"
|
||||
) is True
|
||||
# Disallowed number
|
||||
assert self._should_process(
|
||||
bot, False, False, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
# Allowed group
|
||||
assert self._should_process(
|
||||
bot, False, True, "other@s", "[email protected]"
|
||||
) is True
|
||||
# Disallowed group
|
||||
assert self._should_process(
|
||||
bot, False, True, "other@s", "[email protected]"
|
||||
) is False
|
||||
|
||||
|
||||
# ── Timestamp Conversion ─────────────────────────────────────
|
||||
|
||||
class TestTimestampConversion:
|
||||
"""Test millisecond→second conversion for neonize timestamps."""
|
||||
|
||||
def test_millisecond_timestamp_detected(self):
|
||||
"""Value > 1e12 should be treated as milliseconds."""
|
||||
raw_ts = 1700000000000 # ms
|
||||
ts = raw_ts / 1000.0 if raw_ts > 1e12 else float(raw_ts)
|
||||
assert abs(ts - 1700000000.0) < 0.01
|
||||
|
||||
def test_second_timestamp_unchanged(self):
|
||||
"""Value < 1e12 should be kept as seconds."""
|
||||
raw_ts = 1700000000 # already seconds
|
||||
ts = raw_ts / 1000.0 if raw_ts > 1e12 else float(raw_ts)
|
||||
assert abs(ts - 1700000000.0) < 0.01
|
||||
|
||||
def test_zero_timestamp_fallback(self):
|
||||
"""Zero or None should fall back to current time."""
|
||||
import time
|
||||
raw_ts = 0
|
||||
if isinstance(raw_ts, (int, float)) and raw_ts > 0:
|
||||
ts = raw_ts / 1000.0 if raw_ts > 1e12 else float(raw_ts)
|
||||
else:
|
||||
ts = time.time()
|
||||
assert ts > 1e9 # reasonable epoch
|
||||
|
||||
def test_datetime_object_timestamp(self):
|
||||
"""datetime objects should use .timestamp() method."""
|
||||
from unittest.mock import MagicMock
|
||||
fake_dt = MagicMock()
|
||||
fake_dt.timestamp.return_value = 1700000000.0
|
||||
assert hasattr(fake_dt, 'timestamp')
|
||||
assert fake_dt.timestamp() == 1700000000.0
|
||||
|
||||
|
||||
# ── Stale Message Guard ──────────────────────────────────────
|
||||
|
||||
class TestStaleMessageGuard:
|
||||
"""Test that messages older than connected_at are dropped."""
|
||||
|
||||
def test_stale_message_dropped(self):
|
||||
"""Message timestamp < connected_at → should be dropped."""
|
||||
connected_at = 1700000100.0
|
||||
msg_ts = 1700000050.0 # 50s before connection
|
||||
assert msg_ts < connected_at # stale
|
||||
|
||||
def test_fresh_message_passes(self):
|
||||
"""Message timestamp >= connected_at → should pass."""
|
||||
connected_at = 1700000100.0
|
||||
msg_ts = 1700000150.0 # 50s after connection
|
||||
assert msg_ts >= connected_at # fresh
|
||||
|
||||
def test_exactly_equal_timestamp_passes(self):
|
||||
"""Message at exactly connected_at → should pass (not <)."""
|
||||
connected_at = 1700000100.0
|
||||
msg_ts = 1700000100.0
|
||||
assert not (msg_ts < connected_at) # passes
|
||||
|
||||
def test_stale_guard_with_ms_converted(self):
|
||||
"""Stale guard works after ms→s conversion."""
|
||||
connected_at = 1700000100.0
|
||||
raw_ms = 1700000050000 # 50s before, in ms
|
||||
ts = raw_ms / 1000.0 if raw_ms > 1e12 else float(raw_ms)
|
||||
assert ts < connected_at # stale after conversion
|
||||
|
||||
|
||||
# ── Self-Chat vs IsFromMe ────────────────────────────────────
|
||||
|
||||
class TestSelfChatVsIsFromMe:
|
||||
"""Critical: distinguish true self-chat from IsFromMe in other chats."""
|
||||
|
||||
@staticmethod
|
||||
def _jid_user(jid_str):
|
||||
return jid_str.split("@")[0].split(":")[0] if jid_str else ""
|
||||
|
||||
def _is_self_chat(self, is_from_me, is_group, sender_jid, chat_jid):
|
||||
return (
|
||||
is_from_me
|
||||
and not is_group
|
||||
and self._jid_user(sender_jid) == self._jid_user(chat_jid)
|
||||
)
|
||||
|
||||
def test_self_chat_true(self):
|
||||
assert self._is_self_chat(
|
||||
True, False, "[email protected]", "[email protected]"
|
||||
) is True
|
||||
|
||||
def test_self_chat_with_device_suffix(self):
|
||||
"""Sender may have :device_id suffix — should still match."""
|
||||
assert self._is_self_chat(
|
||||
True, False, "1234:[email protected]", "[email protected]"
|
||||
) is True
|
||||
|
||||
def test_not_self_chat_different_numbers(self):
|
||||
"""IsFromMe but chat is a different person → NOT self-chat."""
|
||||
assert self._is_self_chat(
|
||||
True, False, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
|
||||
def test_not_self_chat_when_group(self):
|
||||
"""IsFromMe in a group → NOT self-chat."""
|
||||
assert self._is_self_chat(
|
||||
True, True, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
|
||||
def test_not_self_chat_when_not_from_me(self):
|
||||
"""Not from me at all → NOT self-chat."""
|
||||
assert self._is_self_chat(
|
||||
False, False, "[email protected]", "[email protected]"
|
||||
) is False
|
||||
|
||||
def test_empty_jids(self):
|
||||
assert self._is_self_chat(True, False, "", "") is True # both empty → match
|
||||
assert self._is_self_chat(True, False, "1234@s", "") is False
|
||||
|
||||
|
||||
# ── CLI Flag Parsing ─────────────────────────────────────────────
|
||||
|
||||
class TestCLIFlagParsing:
|
||||
"""Test CLI argument parsing for filtering flags."""
|
||||
|
||||
def _parse_whatsapp_args(self, *args):
|
||||
import argparse
|
||||
from praisonai.cli.features.bots_cli import add_bot_parser
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
add_bot_parser(subparsers)
|
||||
return parser.parse_args(["bot", "whatsapp"] + list(args))
|
||||
|
||||
def test_no_filter_flags(self):
|
||||
args = self._parse_whatsapp_args("--mode", "web")
|
||||
assert getattr(args, "respond_to", None) is None
|
||||
assert getattr(args, "respond_to_groups", None) is None
|
||||
assert getattr(args, "respond_to_all", False) is False
|
||||
|
||||
def test_respond_to_flag(self):
|
||||
args = self._parse_whatsapp_args("--mode", "web", "--respond-to", "123,456")
|
||||
assert args.respond_to == "123,456"
|
||||
nums = [n.strip() for n in args.respond_to.split(",") if n.strip()]
|
||||
assert nums == ["123", "456"]
|
||||
|
||||
def test_respond_to_groups_flag(self):
|
||||
args = self._parse_whatsapp_args(
|
||||
"--mode", "web",
|
||||
"--respond-to-groups", "[email protected],[email protected]",
|
||||
)
|
||||
groups = [g.strip() for g in args.respond_to_groups.split(",") if g.strip()]
|
||||
assert groups == ["[email protected]", "[email protected]"]
|
||||
|
||||
def test_respond_to_all_flag(self):
|
||||
args = self._parse_whatsapp_args("--mode", "web", "--respond-to-all")
|
||||
assert args.respond_to_all is True
|
||||
|
||||
def test_all_filter_flags_combined(self):
|
||||
args = self._parse_whatsapp_args(
|
||||
"--mode", "web",
|
||||
"--respond-to", "555",
|
||||
"--respond-to-groups", "[email protected]",
|
||||
"--respond-to-all",
|
||||
)
|
||||
assert args.respond_to == "555"
|
||||
assert args.respond_to_groups == "[email protected]"
|
||||
assert args.respond_to_all is True
|
||||
|
||||
|
||||
# ── CLI → WhatsAppBot Wiring ─────────────────────────────────────
|
||||
|
||||
class TestCLIToWhatsAppBotWiring:
|
||||
"""Test that CLI flags are correctly wired to WhatsAppBot constructor."""
|
||||
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
|
||||
def test_respond_to_wired(self, mock_dotenv, mock_load_agent):
|
||||
mock_load_agent.return_value = MagicMock()
|
||||
from praisonai.cli.features.bots_cli import BotHandler
|
||||
|
||||
handler = BotHandler()
|
||||
with patch("praisonai.bots.whatsapp.WhatsAppBot") as MockBot:
|
||||
MockBot.return_value = MagicMock()
|
||||
MockBot.return_value.start = AsyncMock()
|
||||
handler.start_whatsapp(
|
||||
mode="web",
|
||||
allowed_numbers=["1234567890", "5555555"],
|
||||
allowed_groups=["[email protected]"],
|
||||
respond_to_all=False,
|
||||
)
|
||||
# Verify WhatsAppBot was called with filtering args
|
||||
call_kwargs = MockBot.call_args[1]
|
||||
assert call_kwargs["allowed_numbers"] == ["1234567890", "5555555"]
|
||||
assert call_kwargs["allowed_groups"] == ["[email protected]"]
|
||||
assert call_kwargs["respond_to_all"] is False
|
||||
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
|
||||
def test_respond_to_all_wired(self, mock_dotenv, mock_load_agent):
|
||||
mock_load_agent.return_value = MagicMock()
|
||||
from praisonai.cli.features.bots_cli import BotHandler
|
||||
|
||||
handler = BotHandler()
|
||||
with patch("praisonai.bots.whatsapp.WhatsAppBot") as MockBot:
|
||||
MockBot.return_value = MagicMock()
|
||||
MockBot.return_value.start = AsyncMock()
|
||||
handler.start_whatsapp(
|
||||
mode="web",
|
||||
respond_to_all=True,
|
||||
)
|
||||
call_kwargs = MockBot.call_args[1]
|
||||
assert call_kwargs["respond_to_all"] is True
|
||||
|
||||
|
||||
# ── YAML Config Wiring ───────────────────────────────────────────
|
||||
|
||||
class TestYAMLConfigWiring:
|
||||
"""Test that YAML config fields are parsed and wired correctly."""
|
||||
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
|
||||
def test_yaml_respond_to_list(self, mock_dotenv, mock_load_agent):
|
||||
"""YAML respond_to as list."""
|
||||
mock_load_agent.return_value = MagicMock()
|
||||
from praisonai.cli.features.bots_cli import BotHandler
|
||||
|
||||
handler = BotHandler()
|
||||
config = {
|
||||
"platform": "whatsapp",
|
||||
"mode": "web",
|
||||
"respond_to": ["1234567890", "9876543210"],
|
||||
"agent": {"name": "test"},
|
||||
}
|
||||
|
||||
with patch("praisonai.cli.features.bots_cli.BotHandler.start_whatsapp") as mock_start:
|
||||
with patch("builtins.open", create=True):
|
||||
with patch("yaml.safe_load", return_value=config):
|
||||
handler.start_from_config("bot.yaml")
|
||||
|
||||
if mock_start.called:
|
||||
call_kwargs = mock_start.call_args[1]
|
||||
assert call_kwargs.get("allowed_numbers") == ["1234567890", "9876543210"]
|
||||
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
|
||||
def test_yaml_respond_to_string(self, mock_dotenv, mock_load_agent):
|
||||
"""YAML respond_to as comma-separated string."""
|
||||
mock_load_agent.return_value = MagicMock()
|
||||
from praisonai.cli.features.bots_cli import BotHandler
|
||||
|
||||
handler = BotHandler()
|
||||
config = {
|
||||
"platform": "whatsapp",
|
||||
"mode": "web",
|
||||
"respond_to": "123,456",
|
||||
"agent": {"name": "test"},
|
||||
}
|
||||
|
||||
with patch("praisonai.cli.features.bots_cli.BotHandler.start_whatsapp") as mock_start:
|
||||
with patch("builtins.open", create=True):
|
||||
with patch("yaml.safe_load", return_value=config):
|
||||
handler.start_from_config("bot.yaml")
|
||||
|
||||
if mock_start.called:
|
||||
call_kwargs = mock_start.call_args[1]
|
||||
assert call_kwargs.get("allowed_numbers") == ["123", "456"]
|
||||
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_agent")
|
||||
@patch("praisonai.cli.features.bots_cli.BotHandler._load_dotenv")
|
||||
def test_yaml_respond_to_all(self, mock_dotenv, mock_load_agent):
|
||||
"""YAML respond_to_all: true."""
|
||||
mock_load_agent.return_value = MagicMock()
|
||||
from praisonai.cli.features.bots_cli import BotHandler
|
||||
|
||||
handler = BotHandler()
|
||||
config = {
|
||||
"platform": "whatsapp",
|
||||
"mode": "web",
|
||||
"respond_to_all": True,
|
||||
"agent": {"name": "test"},
|
||||
}
|
||||
|
||||
with patch("praisonai.cli.features.bots_cli.BotHandler.start_whatsapp") as mock_start:
|
||||
with patch("builtins.open", create=True):
|
||||
with patch("yaml.safe_load", return_value=config):
|
||||
handler.start_from_config("bot.yaml")
|
||||
|
||||
if mock_start.called:
|
||||
call_kwargs = mock_start.call_args[1]
|
||||
assert call_kwargs.get("respond_to_all") is True
|
||||
|
||||
|
||||
# ── Edge Cases ───────────────────────────────────────────────────
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases in filtering."""
|
||||
|
||||
def test_cloud_mode_ignores_filtering_params(self):
|
||||
"""Cloud mode should accept but not break with filtering params."""
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(
|
||||
token="t",
|
||||
phone_number_id="p",
|
||||
mode="cloud",
|
||||
allowed_numbers=["123"],
|
||||
allowed_groups=["[email protected]"],
|
||||
respond_to_all=True,
|
||||
)
|
||||
# Should not crash; params are stored but only used in web mode
|
||||
assert bot._respond_to_all is True
|
||||
assert "123" in bot._allowed_numbers
|
||||
|
||||
def test_empty_allowed_numbers_list(self):
|
||||
bot = _make_bot(allowed_numbers=[])
|
||||
assert bot._allowed_numbers == set()
|
||||
|
||||
def test_empty_allowed_groups_list(self):
|
||||
bot = _make_bot(allowed_groups=[])
|
||||
assert bot._allowed_groups == set()
|
||||
|
||||
def test_non_digit_input_ignored(self):
|
||||
"""Input with no digits should be silently ignored."""
|
||||
bot = _make_bot(allowed_numbers=["abc", "+++"])
|
||||
assert bot._allowed_numbers == set()
|
||||
|
||||
def test_group_jid_without_at_sign(self):
|
||||
"""Group ID without @g.us should still match."""
|
||||
bot = _make_bot(allowed_groups=["120363123456"])
|
||||
assert "120363123456" in bot._allowed_groups
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Unit tests for WhatsApp Web adapter (_whatsapp_web_adapter.py).
|
||||
|
||||
Tests the neonize adapter isolation layer with mocked neonize client.
|
||||
All tests are deterministic — no external WhatsApp calls.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the wrapper package is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "praisonai"))
|
||||
|
||||
|
||||
# ── Adapter import ────────────────────────────────────────────────
|
||||
|
||||
class TestAdapterImport:
|
||||
"""Test that the adapter module can be imported without neonize."""
|
||||
|
||||
def test_import_adapter_module(self):
|
||||
"""Adapter module imports without neonize installed."""
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
assert WhatsAppWebAdapter is not None
|
||||
|
||||
def test_default_creds_dir(self):
|
||||
"""Default creds dir is ~/.praisonai/whatsapp."""
|
||||
from praisonai.bots._whatsapp_web_adapter import DEFAULT_CREDS_DIR
|
||||
assert ".praisonai" in DEFAULT_CREDS_DIR
|
||||
assert "whatsapp" in DEFAULT_CREDS_DIR
|
||||
|
||||
|
||||
# ── Adapter construction ──────────────────────────────────────────
|
||||
|
||||
class TestAdapterConstruction:
|
||||
"""Test WhatsAppWebAdapter initialization."""
|
||||
|
||||
def test_default_construction(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter()
|
||||
assert adapter.is_connected is False
|
||||
assert adapter.self_jid is None
|
||||
assert ".praisonai" in adapter.creds_dir
|
||||
|
||||
def test_custom_creds_dir(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter(creds_dir="/tmp/test-wa-creds")
|
||||
assert adapter.creds_dir == "/tmp/test-wa-creds"
|
||||
|
||||
def test_custom_bot_name(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter(bot_name="my_bot")
|
||||
assert adapter._bot_name == "my_bot"
|
||||
|
||||
def test_env_var_creds_dir(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
with patch.dict(os.environ, {"WHATSAPP_CREDS_DIR": "/tmp/env-creds"}):
|
||||
adapter = WhatsAppWebAdapter()
|
||||
assert adapter.creds_dir == "/tmp/env-creds"
|
||||
|
||||
def test_callbacks_stored(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
on_msg = MagicMock()
|
||||
on_qr = MagicMock()
|
||||
on_conn = MagicMock()
|
||||
on_disc = MagicMock()
|
||||
adapter = WhatsAppWebAdapter(
|
||||
on_message=on_msg,
|
||||
on_qr=on_qr,
|
||||
on_connected=on_conn,
|
||||
on_disconnected=on_disc,
|
||||
)
|
||||
assert adapter._on_message is on_msg
|
||||
assert adapter._on_qr is on_qr
|
||||
assert adapter._on_connected is on_conn
|
||||
assert adapter._on_disconnected is on_disc
|
||||
|
||||
|
||||
# ── Credential management ─────────────────────────────────────────
|
||||
|
||||
class TestCredentialManagement:
|
||||
"""Test credential path and session detection."""
|
||||
|
||||
def test_ensure_creds_dir_creates_directory(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
creds_path = os.path.join(tmpdir, "subdir", "wa")
|
||||
adapter = WhatsAppWebAdapter(creds_dir=creds_path)
|
||||
adapter._ensure_creds_dir()
|
||||
assert os.path.isdir(creds_path)
|
||||
|
||||
def test_get_db_path(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir, bot_name="testbot")
|
||||
db_path = adapter._get_db_path()
|
||||
assert db_path.endswith("testbot.db")
|
||||
assert tmpdir in db_path
|
||||
|
||||
def test_has_saved_session_false_when_no_db(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir)
|
||||
assert adapter.has_saved_session() is False
|
||||
|
||||
def test_has_saved_session_true_when_db_exists(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir, bot_name="test")
|
||||
# Create a fake db file
|
||||
db_path = os.path.join(tmpdir, "test.db")
|
||||
with open(db_path, "w") as f:
|
||||
f.write("fake-db-content")
|
||||
assert adapter.has_saved_session() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout_removes_db_files(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
adapter = WhatsAppWebAdapter(creds_dir=tmpdir, bot_name="test")
|
||||
# Create fake db files
|
||||
for suffix in [".db", ".db-wal", ".db-shm"]:
|
||||
path = os.path.join(tmpdir, f"test{suffix}")
|
||||
with open(path, "w") as f:
|
||||
f.write("x")
|
||||
assert adapter.has_saved_session() is True
|
||||
await adapter.logout()
|
||||
assert adapter.has_saved_session() is False
|
||||
assert not os.path.exists(os.path.join(tmpdir, "test.db-wal"))
|
||||
assert not os.path.exists(os.path.join(tmpdir, "test.db-shm"))
|
||||
|
||||
|
||||
# ── JID normalization ─────────────────────────────────────────────
|
||||
|
||||
class TestJIDNormalization:
|
||||
"""Test phone number to JID conversion."""
|
||||
|
||||
def test_already_jid(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
assert WhatsAppWebAdapter._normalize_jid("[email protected]") == "[email protected]"
|
||||
|
||||
def test_phone_number(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
assert WhatsAppWebAdapter._normalize_jid("15551234567") == "[email protected]"
|
||||
|
||||
def test_phone_with_plus(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
assert WhatsAppWebAdapter._normalize_jid("+15551234567") == "[email protected]"
|
||||
|
||||
def test_group_jid_passthrough(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
assert WhatsAppWebAdapter._normalize_jid("[email protected]") == "[email protected]"
|
||||
|
||||
def test_phone_with_spaces_and_dashes(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
assert WhatsAppWebAdapter._normalize_jid("+1 555-123-4567") == "[email protected]"
|
||||
|
||||
|
||||
# ── Connect / Disconnect ──────────────────────────────────────────
|
||||
|
||||
class TestConnectDisconnect:
|
||||
"""Test connect and disconnect lifecycle (mocked neonize)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_raises_without_neonize(self):
|
||||
"""connect() raises ImportError when neonize is not installed."""
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter(creds_dir="/tmp/test-no-neonize")
|
||||
|
||||
with patch.dict(sys.modules, {
|
||||
"neonize": None,
|
||||
"neonize.aioze": None,
|
||||
"neonize.aioze.client": None,
|
||||
"neonize.aioze.events": None,
|
||||
}):
|
||||
with pytest.raises(ImportError, match="neonize"):
|
||||
await adapter.connect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_not_connected(self):
|
||||
"""disconnect() is safe to call when not connected."""
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter()
|
||||
await adapter.disconnect()
|
||||
assert adapter.is_connected is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_calls_client_disconnect(self):
|
||||
"""disconnect() calls client.disconnect() if client exists."""
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter()
|
||||
mock_client = AsyncMock()
|
||||
adapter._client = mock_client
|
||||
adapter._is_connected = True
|
||||
|
||||
await adapter.disconnect()
|
||||
mock_client.disconnect.assert_called_once()
|
||||
assert adapter.is_connected is False
|
||||
assert adapter._client is None
|
||||
|
||||
|
||||
# ── Send message ──────────────────────────────────────────────────
|
||||
|
||||
class TestSendMessage:
|
||||
"""Test send_message with mocked client."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_when_not_connected_returns_none(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter()
|
||||
result = await adapter.send_message("[email protected]", "hello")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reaction_when_not_connected(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter()
|
||||
# Should not raise
|
||||
await adapter.send_reaction("[email protected]", "msg123", "👍")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_as_read_when_not_connected(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter()
|
||||
# Should not raise
|
||||
await adapter.mark_as_read("[email protected]", "msg123")
|
||||
|
||||
|
||||
# ── QR code display ───────────────────────────────────────────────
|
||||
|
||||
class TestQRCodeDisplay:
|
||||
"""Test QR code terminal display (fallback chain: segno → qrcode → raw)."""
|
||||
|
||||
def test_print_qr_with_segno(self, capsys):
|
||||
"""Uses segno (neonize dep) when available — first in chain."""
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
mock_segno = MagicMock()
|
||||
mock_qr_obj = MagicMock()
|
||||
mock_segno.make_qr.return_value = mock_qr_obj
|
||||
|
||||
with patch.dict(sys.modules, {"segno": mock_segno}):
|
||||
WhatsAppWebAdapter._print_qr_terminal("test-qr-data")
|
||||
mock_segno.make_qr.assert_called_once_with("test-qr-data")
|
||||
mock_qr_obj.terminal.assert_called_once_with(compact=True)
|
||||
|
||||
def test_print_qr_falls_back_to_qrcode(self):
|
||||
"""Falls back to qrcode when segno fails."""
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
mock_qr = MagicMock()
|
||||
mock_qr_module = MagicMock()
|
||||
mock_qr_module.QRCode.return_value = mock_qr
|
||||
|
||||
# segno raises, qrcode works
|
||||
mock_segno = MagicMock()
|
||||
mock_segno.make_qr.side_effect = Exception("segno fail")
|
||||
|
||||
with patch.dict(sys.modules, {"segno": mock_segno, "qrcode": mock_qr_module}):
|
||||
WhatsAppWebAdapter._print_qr_terminal("test-qr-data")
|
||||
mock_qr_module.QRCode.assert_called_once()
|
||||
mock_qr.add_data.assert_called_once_with("test-qr-data")
|
||||
|
||||
def test_print_qr_fallback_raw_text(self, capsys):
|
||||
"""Falls back to raw text when both segno and qrcode fail."""
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
|
||||
mock_segno = MagicMock()
|
||||
mock_segno.make_qr.side_effect = Exception("segno fail")
|
||||
|
||||
original_import = __import__
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "qrcode":
|
||||
raise ImportError("No module named 'qrcode'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch.dict(sys.modules, {"segno": mock_segno}):
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
WhatsAppWebAdapter._print_qr_terminal("test-qr-data-fallback")
|
||||
captured = capsys.readouterr()
|
||||
assert "QR" in captured.out or "segno" in captured.out.lower()
|
||||
|
||||
|
||||
# ── Safe callback ─────────────────────────────────────────────────
|
||||
|
||||
class TestSafeCallback:
|
||||
"""Test the _safe_callback helper."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_callback(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter()
|
||||
called = []
|
||||
def cb(x):
|
||||
called.append(x)
|
||||
await adapter._safe_callback(cb, "test")
|
||||
assert called == ["test"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_callback(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter()
|
||||
called = []
|
||||
async def cb(x):
|
||||
called.append(x)
|
||||
await adapter._safe_callback(cb, "async-test")
|
||||
assert called == ["async-test"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_error_does_not_raise(self):
|
||||
from praisonai.bots._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
adapter = WhatsAppWebAdapter()
|
||||
def cb():
|
||||
raise ValueError("boom")
|
||||
# Should not raise
|
||||
await adapter._safe_callback(cb)
|
||||
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Unit tests for WhatsAppBot dual-mode (cloud/web) support.
|
||||
|
||||
Tests backward compatibility with existing Cloud API mode and
|
||||
new Web mode constructor/lifecycle.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the wrapper package is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "praisonai"))
|
||||
|
||||
|
||||
# ── Mode constructor ──────────────────────────────────────────────
|
||||
|
||||
class TestModeConstructor:
|
||||
"""Test WhatsAppBot mode parameter."""
|
||||
|
||||
def test_default_mode_is_cloud(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(token="test-token", phone_number_id="123")
|
||||
assert bot.mode == "cloud"
|
||||
assert bot._mode == "cloud"
|
||||
|
||||
def test_explicit_cloud_mode(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(token="test-token", mode="cloud")
|
||||
assert bot.mode == "cloud"
|
||||
|
||||
def test_web_mode(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
assert bot.mode == "web"
|
||||
|
||||
def test_web_mode_no_token_required(self):
|
||||
"""Web mode should not require token or phone_number_id."""
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
assert bot._token == ""
|
||||
assert bot._phone_number_id == ""
|
||||
|
||||
def test_invalid_mode_raises(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
with pytest.raises(ValueError, match="Invalid mode"):
|
||||
WhatsAppBot(mode="invalid")
|
||||
|
||||
def test_mode_case_insensitive(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="WEB")
|
||||
assert bot.mode == "web"
|
||||
|
||||
def test_mode_whitespace_stripped(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode=" web ")
|
||||
assert bot.mode == "web"
|
||||
|
||||
|
||||
# ── Backward compatibility ────────────────────────────────────────
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Ensure existing Cloud API usage is unchanged."""
|
||||
|
||||
def test_cloud_mode_with_all_params(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(
|
||||
token="EAA-test",
|
||||
phone_number_id="123456",
|
||||
verify_token="my-verify",
|
||||
app_secret="my-secret",
|
||||
webhook_port=9090,
|
||||
webhook_path="/hook",
|
||||
)
|
||||
assert bot._token == "EAA-test"
|
||||
assert bot._phone_number_id == "123456"
|
||||
assert bot._verify_token == "my-verify"
|
||||
assert bot._app_secret == "my-secret"
|
||||
assert bot._webhook_port == 9090
|
||||
assert bot._webhook_path == "/hook"
|
||||
assert bot.mode == "cloud"
|
||||
|
||||
def test_token_from_env_var(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
with patch.dict(os.environ, {"WHATSAPP_ACCESS_TOKEN": "env-token"}):
|
||||
bot = WhatsAppBot()
|
||||
assert bot._token == "env-token"
|
||||
|
||||
def test_phone_id_from_env_var(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
with patch.dict(os.environ, {"WHATSAPP_PHONE_NUMBER_ID": "env-phone"}):
|
||||
bot = WhatsAppBot()
|
||||
assert bot._phone_number_id == "env-phone"
|
||||
|
||||
def test_platform_property(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(token="t")
|
||||
assert bot.platform == "whatsapp"
|
||||
|
||||
def test_is_running_initially_false(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(token="t")
|
||||
assert bot.is_running is False
|
||||
|
||||
|
||||
# ── Web mode properties ───────────────────────────────────────────
|
||||
|
||||
class TestWebModeProperties:
|
||||
"""Test Web mode specific behavior."""
|
||||
|
||||
def test_creds_dir_param(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web", creds_dir="/tmp/wa-creds")
|
||||
assert bot._creds_dir == "/tmp/wa-creds"
|
||||
|
||||
def test_creds_dir_default_none(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
assert bot._creds_dir is None # adapter will use its own default
|
||||
|
||||
def test_web_adapter_initially_none(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
assert bot._web_adapter is None
|
||||
|
||||
def test_web_mode_has_session_manager(self):
|
||||
"""Web mode still uses BotSessionManager for per-user isolation."""
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
assert bot._session_mgr is not None
|
||||
|
||||
|
||||
# ── Start delegation ──────────────────────────────────────────────
|
||||
|
||||
class TestStartDelegation:
|
||||
"""Test that start() delegates to correct mode handler."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_mode_calls_start_cloud(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(token="test", phone_number_id="123", mode="cloud")
|
||||
bot._start_cloud_mode = AsyncMock()
|
||||
await bot.start()
|
||||
bot._start_cloud_mode.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_mode_calls_start_web(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
bot._start_web_mode = AsyncMock()
|
||||
await bot.start()
|
||||
bot._start_web_mode.assert_called_once()
|
||||
|
||||
|
||||
# ── Stop handles both modes ───────────────────────────────────────
|
||||
|
||||
class TestStop:
|
||||
"""Test stop() cleanup for both modes."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_cloud_mode(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(token="t", mode="cloud")
|
||||
bot._is_running = True
|
||||
bot._site = AsyncMock()
|
||||
bot._runner = AsyncMock()
|
||||
await bot.stop()
|
||||
assert bot._is_running is False
|
||||
bot._site.stop.assert_called_once()
|
||||
bot._runner.cleanup.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_web_mode(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
bot._is_running = True
|
||||
mock_adapter = AsyncMock()
|
||||
bot._web_adapter = mock_adapter
|
||||
await bot.stop()
|
||||
assert bot._is_running is False
|
||||
mock_adapter.disconnect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_web_mode_adapter_error_handled(self):
|
||||
"""Stop should not raise even if adapter disconnect fails."""
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
bot._is_running = True
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.disconnect.side_effect = Exception("disconnect error")
|
||||
bot._web_adapter = mock_adapter
|
||||
# Should not raise
|
||||
await bot.stop()
|
||||
assert bot._is_running is False
|
||||
|
||||
|
||||
# ── Command registration ──────────────────────────────────────────
|
||||
|
||||
class TestCommandRegistration:
|
||||
"""Test that built-in commands work in both modes."""
|
||||
|
||||
def test_cloud_mode_has_builtins(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(token="t", mode="cloud")
|
||||
cmds = bot.list_commands()
|
||||
cmd_names = [c.name for c in cmds]
|
||||
assert "status" in cmd_names
|
||||
assert "new" in cmd_names
|
||||
assert "help" in cmd_names
|
||||
|
||||
def test_web_mode_has_builtins(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
cmds = bot.list_commands()
|
||||
cmd_names = [c.name for c in cmds]
|
||||
assert "status" in cmd_names
|
||||
assert "new" in cmd_names
|
||||
assert "help" in cmd_names
|
||||
|
||||
def test_custom_command_in_web_mode(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
|
||||
async def my_cmd(msg):
|
||||
return "custom!"
|
||||
|
||||
bot.register_command("mycommand", my_cmd, description="Test")
|
||||
cmds = bot.list_commands()
|
||||
cmd_names = [c.name for c in cmds]
|
||||
assert "mycommand" in cmd_names
|
||||
|
||||
|
||||
# ── Web send helper ───────────────────────────────────────────────
|
||||
|
||||
class TestWebSend:
|
||||
"""Test _web_send helper method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_send_when_adapter_connected(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.is_connected = True
|
||||
bot._web_adapter = mock_adapter
|
||||
await bot._web_send("[email protected]", "hello")
|
||||
mock_adapter.send_message.assert_called_once_with("[email protected]", "hello")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_send_when_adapter_not_connected(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.is_connected = False
|
||||
bot._web_adapter = mock_adapter
|
||||
await bot._web_send("[email protected]", "hello")
|
||||
mock_adapter.send_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_send_when_no_adapter(self):
|
||||
from praisonai.bots.whatsapp import WhatsAppBot
|
||||
bot = WhatsAppBot(mode="web")
|
||||
# Should not raise
|
||||
await bot._web_send("[email protected]", "hello")
|
||||
Generated
+1
-1
@@ -2549,7 +2549,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.4.6"
|
||||
version = "1.5.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install praisonai with bot dependencies
|
||||
RUN pip install --no-cache-dir praisonai[bot]
|
||||
|
||||
# Copy config (user provides at runtime via volume or build arg)
|
||||
COPY bot.yaml /app/bot.yaml
|
||||
|
||||
# Run the bot
|
||||
CMD ["python", "-m", "praisonai", "bot", "start", "--config", "/app/bot.yaml"]
|
||||
@@ -0,0 +1,20 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.bot
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./bot.yaml:/app/bot.yaml:ro
|
||||
environment:
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
|
||||
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN:-}
|
||||
- SLACK_APP_TOKEN=${SLACK_APP_TOKEN:-}
|
||||
- WHATSAPP_ACCESS_TOKEN=${WHATSAPP_ACCESS_TOKEN:-}
|
||||
- WHATSAPP_PHONE_NUMBER_ID=${WHATSAPP_PHONE_NUMBER_ID:-}
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.4.7.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.4.7.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.2.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Bot implementations for PraisonAI.
|
||||
|
||||
Provides messaging bot runtimes for Telegram, Discord, and Slack.
|
||||
Provides messaging bot runtimes for Telegram, Discord, Slack, and WhatsApp,
|
||||
plus the user-friendly Bot and BotOS orchestrator classes.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -11,6 +12,13 @@ if TYPE_CHECKING:
|
||||
from .discord import DiscordBot
|
||||
from .slack import SlackBot
|
||||
from .whatsapp import WhatsAppBot
|
||||
from .bot import Bot
|
||||
from .botos import BotOS
|
||||
from ._slack_approval import SlackApproval
|
||||
from ._telegram_approval import TelegramApproval
|
||||
from ._discord_approval import DiscordApproval
|
||||
from ._webhook_approval import WebhookApproval
|
||||
from ._http_approval import HTTPApproval
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading of bot components."""
|
||||
@@ -26,6 +34,32 @@ def __getattr__(name: str):
|
||||
if name == "WhatsAppBot":
|
||||
from .whatsapp import WhatsAppBot
|
||||
return WhatsAppBot
|
||||
if name == "Bot":
|
||||
from .bot import Bot
|
||||
return Bot
|
||||
if name == "BotOS":
|
||||
from .botos import BotOS
|
||||
return BotOS
|
||||
if name == "SlackApproval":
|
||||
from ._slack_approval import SlackApproval
|
||||
return SlackApproval
|
||||
if name == "TelegramApproval":
|
||||
from ._telegram_approval import TelegramApproval
|
||||
return TelegramApproval
|
||||
if name == "DiscordApproval":
|
||||
from ._discord_approval import DiscordApproval
|
||||
return DiscordApproval
|
||||
if name == "WebhookApproval":
|
||||
from ._webhook_approval import WebhookApproval
|
||||
return WebhookApproval
|
||||
if name == "HTTPApproval":
|
||||
from ._http_approval import HTTPApproval
|
||||
return HTTPApproval
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = ["TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot"]
|
||||
__all__ = [
|
||||
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot",
|
||||
"Bot", "BotOS",
|
||||
"SlackApproval", "TelegramApproval", "DiscordApproval",
|
||||
"WebhookApproval", "HTTPApproval",
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Ack reaction helpers for PraisonAI bots.
|
||||
|
||||
Reacts to inbound messages with a configurable emoji (e.g. ⏳) to
|
||||
acknowledge receipt, then swaps to a "done" emoji (e.g. ✅) when
|
||||
the agent response is sent. Inspired by OpenClaw's ack-reactions.ts.
|
||||
|
||||
Usage::
|
||||
|
||||
ack = AckReactor(ack_emoji="⏳", done_emoji="✅")
|
||||
ctx = await ack.ack(channel_id, message_id, react_fn)
|
||||
# ... process message ...
|
||||
await ack.done(ctx, channel_id, message_id, react_fn, unreact_fn)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Coroutine, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AckContext:
|
||||
"""Tracks state for a single ack-reaction lifecycle."""
|
||||
acked: bool = False
|
||||
emoji: str = ""
|
||||
|
||||
|
||||
class AckReactor:
|
||||
"""Lightweight ack-reaction manager.
|
||||
|
||||
Args:
|
||||
ack_emoji: Emoji to react with on message receipt (empty = disabled).
|
||||
done_emoji: Emoji to react with on completion (default "✅").
|
||||
"""
|
||||
|
||||
def __init__(self, ack_emoji: str = "", done_emoji: str = "✅") -> None:
|
||||
self._ack_emoji = ack_emoji
|
||||
self._done_emoji = done_emoji
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._ack_emoji)
|
||||
|
||||
async def ack(
|
||||
self,
|
||||
react_fn: Callable[..., Coroutine],
|
||||
**kwargs: Any,
|
||||
) -> AckContext:
|
||||
"""React with ack emoji. Returns context for later ``done()``.
|
||||
|
||||
Args:
|
||||
react_fn: Platform-specific async function to add a reaction.
|
||||
Called as ``await react_fn(emoji=self._ack_emoji, **kwargs)``.
|
||||
**kwargs: Extra args forwarded to react_fn (chat_id, message_id, etc).
|
||||
"""
|
||||
ctx = AckContext()
|
||||
if not self.enabled:
|
||||
return ctx
|
||||
try:
|
||||
await react_fn(emoji=self._ack_emoji, **kwargs)
|
||||
ctx.acked = True
|
||||
ctx.emoji = self._ack_emoji
|
||||
except Exception as e:
|
||||
logger.debug(f"AckReactor: failed to ack: {e}")
|
||||
return ctx
|
||||
|
||||
async def done(
|
||||
self,
|
||||
ctx: AckContext,
|
||||
react_fn: Callable[..., Coroutine],
|
||||
unreact_fn: Optional[Callable[..., Coroutine]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Swap ack emoji for done emoji.
|
||||
|
||||
Args:
|
||||
ctx: AckContext returned from ``ack()``.
|
||||
react_fn: Async function to add a reaction.
|
||||
unreact_fn: Async function to remove a reaction (optional).
|
||||
**kwargs: Extra args forwarded to react/unreact fns.
|
||||
"""
|
||||
if not ctx.acked:
|
||||
return
|
||||
# Remove ack emoji
|
||||
if unreact_fn:
|
||||
try:
|
||||
await unreact_fn(emoji=ctx.emoji, **kwargs)
|
||||
except Exception as e:
|
||||
logger.debug(f"AckReactor: failed to remove ack: {e}")
|
||||
# Add done emoji
|
||||
if self._done_emoji:
|
||||
try:
|
||||
await react_fn(emoji=self._done_emoji, **kwargs)
|
||||
except Exception as e:
|
||||
logger.debug(f"AckReactor: failed to add done: {e}")
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Bot Approval Backend for PraisonAI.
|
||||
|
||||
Sends tool execution approval requests as in-platform messages
|
||||
(e.g., inline keyboard on Telegram) and waits for user reply.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default timeout for approval requests (seconds)
|
||||
DEFAULT_APPROVAL_TIMEOUT = 120
|
||||
|
||||
|
||||
class BotApprovalBackend:
|
||||
"""In-chat approval backend for bot tool execution.
|
||||
|
||||
Sends an approval request message to the user in-chat and waits
|
||||
for their reply (yes/no) with a configurable timeout.
|
||||
|
||||
Default behavior: deny on timeout (safe by default).
|
||||
|
||||
Usage:
|
||||
approval = BotApprovalBackend(bot_adapter, timeout=60)
|
||||
approved = await approval.request_approval(
|
||||
user_id="123",
|
||||
channel_id="456",
|
||||
tool_name="execute_command",
|
||||
tool_args={"command": "ls -la"},
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: Any = None,
|
||||
timeout: float = DEFAULT_APPROVAL_TIMEOUT,
|
||||
default_on_timeout: bool = False,
|
||||
):
|
||||
self._adapter = adapter
|
||||
self._timeout = timeout
|
||||
self._default_on_timeout = default_on_timeout
|
||||
self._pending: Dict[str, asyncio.Future] = {}
|
||||
|
||||
async def request_approval(
|
||||
self,
|
||||
user_id: str,
|
||||
channel_id: str,
|
||||
tool_name: str,
|
||||
tool_args: Dict[str, Any],
|
||||
description: str = "",
|
||||
) -> bool:
|
||||
"""Send an approval request and wait for user response.
|
||||
|
||||
Args:
|
||||
user_id: User to ask for approval
|
||||
channel_id: Channel to send the request in
|
||||
tool_name: Name of the tool requesting approval
|
||||
tool_args: Arguments the tool wants to execute with
|
||||
description: Human-readable description of the action
|
||||
|
||||
Returns:
|
||||
True if approved, False if denied or timed out
|
||||
"""
|
||||
request_id = f"approval_{user_id}_{int(time.time() * 1000)}"
|
||||
|
||||
# Format the approval message
|
||||
args_preview = str(tool_args)[:200]
|
||||
if description:
|
||||
msg = f"🔐 Approval required:\n\n{description}\n\nTool: {tool_name}\nArgs: {args_preview}\n\nReply 'yes' to approve or 'no' to deny."
|
||||
else:
|
||||
msg = f"🔐 Approval required:\n\nTool: {tool_name}\nArgs: {args_preview}\n\nReply 'yes' to approve or 'no' to deny."
|
||||
|
||||
# Create a future to wait for the response
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending[request_id] = future
|
||||
|
||||
# Send the message
|
||||
if self._adapter and hasattr(self._adapter, 'send_message'):
|
||||
try:
|
||||
await self._adapter.send_message(channel_id, msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send approval request: {e}")
|
||||
self._pending.pop(request_id, None)
|
||||
return self._default_on_timeout
|
||||
|
||||
# Wait for response with timeout
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=self._timeout)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(
|
||||
f"Approval request timed out for {tool_name} "
|
||||
f"(user={user_id}, default={'approve' if self._default_on_timeout else 'deny'})"
|
||||
)
|
||||
return self._default_on_timeout
|
||||
finally:
|
||||
self._pending.pop(request_id, None)
|
||||
|
||||
def resolve(self, request_id: str, approved: bool) -> bool:
|
||||
"""Resolve a pending approval request.
|
||||
|
||||
Args:
|
||||
request_id: The approval request ID
|
||||
approved: Whether the request is approved
|
||||
|
||||
Returns:
|
||||
True if the request was found and resolved
|
||||
"""
|
||||
future = self._pending.get(request_id)
|
||||
if future and not future.done():
|
||||
future.set_result(approved)
|
||||
return True
|
||||
return False
|
||||
|
||||
def resolve_by_user_reply(self, user_id: str, reply_text: str) -> bool:
|
||||
"""Resolve the most recent pending approval for a user based on reply text.
|
||||
|
||||
Args:
|
||||
user_id: The user who replied
|
||||
reply_text: The reply text (checked for yes/no)
|
||||
|
||||
Returns:
|
||||
True if a pending request was resolved
|
||||
"""
|
||||
reply_lower = reply_text.strip().lower()
|
||||
approved = reply_lower in ("yes", "y", "approve", "ok", "confirm", "1", "true")
|
||||
denied = reply_lower in ("no", "n", "deny", "reject", "cancel", "0", "false")
|
||||
|
||||
if not approved and not denied:
|
||||
return False # Not an approval response
|
||||
|
||||
# Find most recent pending request for this user
|
||||
for request_id in reversed(list(self._pending.keys())):
|
||||
if f"_{user_id}_" in request_id:
|
||||
return self.resolve(request_id, approved)
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
"""Number of pending approval requests."""
|
||||
return len(self._pending)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Shared base class for messaging-platform approval backends.
|
||||
|
||||
Extracts the common sync-wrapper, timeout, poll_interval, and keyword
|
||||
matching logic so that SlackApproval, TelegramApproval, and DiscordApproval
|
||||
only need to implement the platform-specific bits.
|
||||
|
||||
This is an internal module — end users import the concrete classes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APPROVE_KEYWORDS: Set[str] = {
|
||||
"yes", "y", "approve", "approved", "ok", "allow", "go", "proceed", "confirm",
|
||||
}
|
||||
DENY_KEYWORDS: Set[str] = {
|
||||
"no", "n", "deny", "denied", "reject", "block", "stop", "cancel", "refuse",
|
||||
}
|
||||
|
||||
|
||||
def classify_keyword(text: str) -> str | None:
|
||||
"""Classify *text* as ``'approve'``, ``'deny'``, or ``None``."""
|
||||
t = text.strip().lower()
|
||||
if t in APPROVE_KEYWORDS:
|
||||
return "approve"
|
||||
if t in DENY_KEYWORDS:
|
||||
return "deny"
|
||||
return None
|
||||
|
||||
|
||||
def sync_wrapper(async_fn, timeout: float):
|
||||
"""Run *async_fn* (a coroutine) synchronously, handling nested loops."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, async_fn)
|
||||
return future.result(timeout=timeout + 10)
|
||||
else:
|
||||
return asyncio.run(async_fn)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Markdown-aware message chunking for PraisonAI bots.
|
||||
|
||||
Splits long bot responses at paragraph boundaries while preserving
|
||||
code fences and markdown structure. Inspired by OpenClaw's chunk.ts
|
||||
but kept minimal (~120 lines).
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonai.bots._chunk import chunk_message
|
||||
|
||||
chunks = chunk_message(text, max_length=4096, preserve_fences=True)
|
||||
for chunk in chunks:
|
||||
await bot.send_message(channel_id, chunk)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
|
||||
def chunk_message(
|
||||
text: str,
|
||||
max_length: int = 4096,
|
||||
preserve_fences: bool = True,
|
||||
) -> List[str]:
|
||||
"""Split *text* into chunks of at most *max_length* characters.
|
||||
|
||||
Splitting strategy (in priority order):
|
||||
1. **Paragraph boundaries** (blank lines ``\\n\\n``)
|
||||
2. **Sentence boundaries** (``. `` followed by uppercase or newline)
|
||||
3. **Hard split** at *max_length* (last resort)
|
||||
|
||||
When *preserve_fences* is True, code fence blocks are never split
|
||||
even if they exceed *max_length*.
|
||||
|
||||
Args:
|
||||
text: The message text to split.
|
||||
max_length: Maximum characters per chunk.
|
||||
preserve_fences: Keep code fences intact (default True).
|
||||
|
||||
Returns:
|
||||
List of text chunks.
|
||||
"""
|
||||
if not text:
|
||||
return [""]
|
||||
|
||||
if len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
# Split into paragraphs (double newline)
|
||||
paragraphs = re.split(r"\n\n", text)
|
||||
|
||||
chunks: List[str] = []
|
||||
current = ""
|
||||
|
||||
for para in paragraphs:
|
||||
candidate = (current + "\n\n" + para) if current else para
|
||||
|
||||
# Check if this paragraph is inside a fence span
|
||||
# If the candidate is small enough, accumulate
|
||||
if len(candidate) <= max_length:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
# Candidate too long — flush current first
|
||||
if current:
|
||||
chunks.append(current.strip())
|
||||
current = ""
|
||||
|
||||
# Now handle the paragraph itself
|
||||
if len(para) <= max_length:
|
||||
current = para
|
||||
elif preserve_fences and _is_fence_block(para):
|
||||
# Code fence block — keep intact even if over limit
|
||||
chunks.append(para.strip())
|
||||
else:
|
||||
# Paragraph too long — split at sentences or hard-split
|
||||
sub_chunks = _split_long_paragraph(para, max_length)
|
||||
chunks.extend(sub_chunks[:-1])
|
||||
current = sub_chunks[-1] if sub_chunks else ""
|
||||
|
||||
if current.strip():
|
||||
chunks.append(current.strip())
|
||||
|
||||
return chunks if chunks else [""]
|
||||
|
||||
|
||||
def _is_fence_block(text: str) -> bool:
|
||||
"""Check if text is (or contains) a code fence block."""
|
||||
stripped = text.strip()
|
||||
return stripped.startswith("```")
|
||||
|
||||
|
||||
def _split_long_paragraph(text: str, max_length: int) -> List[str]:
|
||||
"""Split a long paragraph at sentence boundaries, then hard-split."""
|
||||
# Try sentence boundaries first
|
||||
sentences = re.split(r"(?<=[.!?])\s+", text)
|
||||
|
||||
if len(sentences) > 1:
|
||||
chunks: List[str] = []
|
||||
current = ""
|
||||
for sentence in sentences:
|
||||
candidate = (current + " " + sentence) if current else sentence
|
||||
if len(candidate) <= max_length:
|
||||
current = candidate
|
||||
else:
|
||||
if current:
|
||||
chunks.append(current.strip())
|
||||
if len(sentence) <= max_length:
|
||||
current = sentence
|
||||
else:
|
||||
# Sentence itself too long — hard split
|
||||
chunks.extend(_hard_split(sentence, max_length))
|
||||
current = ""
|
||||
if current.strip():
|
||||
chunks.append(current.strip())
|
||||
return chunks if chunks else [""]
|
||||
|
||||
# No sentence boundaries — hard split
|
||||
return _hard_split(text, max_length)
|
||||
|
||||
|
||||
def _hard_split(text: str, max_length: int) -> List[str]:
|
||||
"""Last-resort character-level split."""
|
||||
return [text[i:i + max_length] for i in range(0, len(text), max_length)]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Pydantic-based config schema validation for bot.yaml / gateway.yaml.
|
||||
|
||||
Provides clear, human-friendly error messages when configuration is invalid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class AgentConfigSchema(BaseModel):
|
||||
"""Schema for agent configuration in bot.yaml."""
|
||||
name: str = "assistant"
|
||||
instructions: str = ""
|
||||
model: str = "gpt-4o-mini"
|
||||
memory: bool = False
|
||||
tools: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChannelConfigSchema(BaseModel):
|
||||
"""Schema for a single channel configuration."""
|
||||
token: str = ""
|
||||
group_policy: str = "respond_all" # respond_all, mention_only, command_only
|
||||
allowlist: List[str] = Field(default_factory=list)
|
||||
blocklist: List[str] = Field(default_factory=list)
|
||||
webhook_url: Optional[str] = None
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
def resolve_env_var(cls, v: str) -> str:
|
||||
"""Resolve ${ENV_VAR} references in token."""
|
||||
if v.startswith("${") and v.endswith("}"):
|
||||
env_key = v[2:-1]
|
||||
resolved = os.environ.get(env_key, "")
|
||||
if not resolved:
|
||||
raise ValueError(
|
||||
f"Environment variable '{env_key}' not set. "
|
||||
f"Set it with: export {env_key}=your_token"
|
||||
)
|
||||
return resolved
|
||||
return v
|
||||
|
||||
@field_validator("group_policy")
|
||||
@classmethod
|
||||
def validate_group_policy(cls, v: str) -> str:
|
||||
allowed = {"respond_all", "mention_only", "command_only"}
|
||||
if v not in allowed:
|
||||
raise ValueError(
|
||||
f"Invalid group_policy '{v}'. Must be one of: {', '.join(sorted(allowed))}"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class RoutingConfigSchema(BaseModel):
|
||||
"""Schema for agent routing configuration."""
|
||||
default: str = "assistant"
|
||||
rules: Dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DaemonConfigSchema(BaseModel):
|
||||
"""Schema for daemon/service configuration."""
|
||||
enabled: bool = False
|
||||
restart: str = "always" # always, on_failure, never
|
||||
|
||||
@field_validator("restart")
|
||||
@classmethod
|
||||
def validate_restart(cls, v: str) -> str:
|
||||
allowed = {"always", "on_failure", "never"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"Invalid restart policy '{v}'. Must be one of: {', '.join(sorted(allowed))}")
|
||||
return v
|
||||
|
||||
|
||||
class BotYamlSchema(BaseModel):
|
||||
"""Full schema for bot.yaml configuration."""
|
||||
agent: Optional[AgentConfigSchema] = None
|
||||
channels: Dict[str, ChannelConfigSchema] = Field(default_factory=dict)
|
||||
routing: Optional[RoutingConfigSchema] = None
|
||||
daemon: Optional[DaemonConfigSchema] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_channels_exist(self):
|
||||
"""Ensure at least one channel is configured."""
|
||||
if not self.channels:
|
||||
raise ValueError(
|
||||
"No channels configured. Add at least one channel "
|
||||
"(telegram, discord, slack, whatsapp) to your bot.yaml"
|
||||
)
|
||||
# Validate channel names
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
|
||||
for name in self.channels:
|
||||
if name not in valid_platforms:
|
||||
raise ValueError(
|
||||
f"Unknown channel '{name}'. Supported: {', '.join(sorted(valid_platforms))}"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
def validate_bot_config(raw: Dict[str, Any]) -> BotYamlSchema:
|
||||
"""Validate a raw YAML dict against the bot config schema.
|
||||
|
||||
Args:
|
||||
raw: Parsed YAML dictionary
|
||||
|
||||
Returns:
|
||||
Validated BotYamlSchema
|
||||
|
||||
Raises:
|
||||
ValueError: With human-friendly error messages
|
||||
"""
|
||||
try:
|
||||
return BotYamlSchema(**raw)
|
||||
except Exception as e:
|
||||
# Re-raise with user-friendly message
|
||||
msg = str(e)
|
||||
# Strip pydantic internals for cleaner output
|
||||
if "validation error" in msg.lower():
|
||||
lines = msg.split("\n")
|
||||
clean_lines = [line for line in lines if line.strip() and not line.strip().startswith("For further")]
|
||||
msg = "\n".join(clean_lines)
|
||||
raise ValueError(f"Invalid bot.yaml configuration:\n{msg}") from None
|
||||
|
||||
|
||||
def load_and_validate_bot_yaml(path: str) -> BotYamlSchema:
|
||||
"""Load and validate a bot.yaml file.
|
||||
|
||||
Args:
|
||||
path: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
Validated config schema
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If file doesn't exist
|
||||
ValueError: If config is invalid
|
||||
"""
|
||||
import yaml
|
||||
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(
|
||||
f"Config file not found: {path}\n"
|
||||
f"Run 'praisonai onboard' to create one, or create it manually."
|
||||
)
|
||||
|
||||
with open(path) as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
return validate_bot_config(raw)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Inbound message debounce for PraisonAI bots.
|
||||
|
||||
Coalesces rapid messages from the same user into a single
|
||||
agent.chat() call, preventing duplicate processing and wasted tokens.
|
||||
|
||||
Usage::
|
||||
|
||||
debouncer = InboundDebouncer(debounce_ms=1500)
|
||||
coalesced = await debouncer.debounce("user123", "hello")
|
||||
# If user sends "world" within 1500ms, both are joined:
|
||||
# coalesced == "hello\\nworld"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InboundDebouncer:
|
||||
"""Per-user message debouncer.
|
||||
|
||||
Buffers rapid messages from the same user and flushes them as a
|
||||
single joined string after ``debounce_ms`` of silence.
|
||||
|
||||
Args:
|
||||
debounce_ms: Debounce window in milliseconds. 0 = disabled
|
||||
(immediate pass-through).
|
||||
separator: String used to join coalesced messages.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
debounce_ms: int = 0,
|
||||
separator: str = "\n",
|
||||
) -> None:
|
||||
self._debounce_s = max(0, debounce_ms) / 1000.0
|
||||
self._separator = separator
|
||||
self._buffers: Dict[str, List[str]] = {}
|
||||
self._timers: Dict[str, asyncio.TimerHandle] = {}
|
||||
self._futures: Dict[str, List[asyncio.Future]] = {}
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
def _get_lock(self, user_id: str) -> asyncio.Lock:
|
||||
if user_id not in self._locks:
|
||||
self._locks[user_id] = asyncio.Lock()
|
||||
return self._locks[user_id]
|
||||
|
||||
async def debounce(self, user_id: str, text: str) -> str:
|
||||
"""Buffer *text* for *user_id* and return coalesced result.
|
||||
|
||||
If ``debounce_ms == 0``, returns *text* immediately (no buffering).
|
||||
|
||||
Otherwise, waits for the debounce window to expire, then returns
|
||||
all buffered messages joined by ``separator``.
|
||||
|
||||
Returns:
|
||||
The (possibly coalesced) message text.
|
||||
"""
|
||||
if self._debounce_s <= 0:
|
||||
return text
|
||||
|
||||
lock = self._get_lock(user_id)
|
||||
async with lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
|
||||
# Add to buffer
|
||||
if user_id not in self._buffers:
|
||||
self._buffers[user_id] = []
|
||||
self._futures[user_id] = []
|
||||
|
||||
self._buffers[user_id].append(text)
|
||||
self._futures[user_id].append(future)
|
||||
|
||||
# Cancel existing timer and reschedule
|
||||
if user_id in self._timers:
|
||||
self._timers[user_id].cancel()
|
||||
|
||||
self._timers[user_id] = loop.call_later(
|
||||
self._debounce_s,
|
||||
self._flush,
|
||||
user_id,
|
||||
)
|
||||
|
||||
# Wait for flush (outside lock so other messages can enqueue)
|
||||
return await future
|
||||
|
||||
def _flush(self, user_id: str) -> None:
|
||||
"""Flush the buffer for *user_id*, resolving all pending futures."""
|
||||
messages = self._buffers.pop(user_id, [])
|
||||
futures = self._futures.pop(user_id, [])
|
||||
self._timers.pop(user_id, None)
|
||||
|
||||
if not messages:
|
||||
return
|
||||
|
||||
coalesced = self._separator.join(messages)
|
||||
for fut in futures:
|
||||
if not fut.done():
|
||||
fut.set_result(coalesced)
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
"""Number of users with buffered messages."""
|
||||
return len(self._buffers)
|
||||
|
||||
def cancel_all(self) -> int:
|
||||
"""Cancel all pending debounce timers. Returns count cancelled."""
|
||||
count = 0
|
||||
for user_id in list(self._timers.keys()):
|
||||
handle = self._timers.pop(user_id, None)
|
||||
if handle:
|
||||
handle.cancel()
|
||||
count += 1
|
||||
# Resolve futures with whatever is buffered
|
||||
self._flush(user_id)
|
||||
return count
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Discord Approval Backend for PraisonAI Agents.
|
||||
|
||||
Implements the ApprovalProtocol by sending embed messages to a Discord channel
|
||||
via REST API and polling for text-reply responses containing approval keywords.
|
||||
|
||||
Uses raw aiohttp HTTP calls to the Discord REST API — does NOT depend on
|
||||
``discord.py`` to avoid gateway conflicts with a running DiscordBot.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import DiscordApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
tools=[execute_command],
|
||||
approval=DiscordApproval(channel_id="123456789"), # token from DISCORD_BOT_TOKEN env
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ._approval_base import classify_keyword, sync_wrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||
|
||||
|
||||
class DiscordApproval:
|
||||
"""Approval backend that sends Discord embeds and polls for text replies.
|
||||
|
||||
Posts a rich embed message to a Discord channel, then polls the channel
|
||||
for text replies containing approval/denial keywords.
|
||||
|
||||
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
|
||||
|
||||
Args:
|
||||
token: Discord bot token. Falls back to ``DISCORD_BOT_TOKEN`` env var.
|
||||
channel_id: Discord channel ID to send approval requests to.
|
||||
timeout: Max seconds to wait for a response (default 300 = 5 min).
|
||||
poll_interval: Seconds between polls (default 3.0).
|
||||
|
||||
Example::
|
||||
|
||||
from praisonai.bots import DiscordApproval
|
||||
agent = Agent(name="bot", approval=DiscordApproval(channel_id="1234567890"))
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
channel_id: Optional[str] = None,
|
||||
timeout: float = 300,
|
||||
poll_interval: float = 3.0,
|
||||
):
|
||||
self._token = token or os.environ.get("DISCORD_BOT_TOKEN", "")
|
||||
if not self._token:
|
||||
raise ValueError(
|
||||
"Discord bot token is required. Pass token= or set DISCORD_BOT_TOKEN env var."
|
||||
)
|
||||
self._channel_id = channel_id or ""
|
||||
self._timeout = timeout
|
||||
self._poll_interval = poll_interval
|
||||
|
||||
def __repr__(self) -> str:
|
||||
masked = f"...{self._token[-4:]}" if len(self._token) > 4 else "***"
|
||||
return f"DiscordApproval(channel_id={self._channel_id!r}, token={masked!r})"
|
||||
|
||||
# ── Internal Discord API helper ────────────────────────────────────
|
||||
|
||||
async def _discord_api(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
session: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Call a Discord REST API endpoint. Override in tests for mocking."""
|
||||
import aiohttp
|
||||
|
||||
url = f"{_DISCORD_API_BASE}{path}"
|
||||
headers = {
|
||||
"Authorization": f"Bot {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _do_request(s):
|
||||
if method.upper() == "GET":
|
||||
async with s.get(
|
||||
url, headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
return await resp.json()
|
||||
else:
|
||||
async with s.post(
|
||||
url, headers=headers, json=payload or {},
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
return await resp.json()
|
||||
|
||||
if session is not None:
|
||||
return await _do_request(session)
|
||||
else:
|
||||
async with aiohttp.ClientSession() as _session:
|
||||
return await _do_request(_session)
|
||||
|
||||
# ── ApprovalProtocol implementation ─────────────────────────────────
|
||||
|
||||
async def request_approval(self, request) -> Any:
|
||||
"""Send a Discord embed and poll for approval/denial text reply."""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
import aiohttp
|
||||
|
||||
channel_id = self._channel_id
|
||||
if not channel_id:
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason="No Discord channel_id configured",
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 1. Post approval embed
|
||||
embed = self._build_embed(request)
|
||||
content = self._build_fallback_text(request)
|
||||
|
||||
try:
|
||||
post_data = await self._discord_api("POST",
|
||||
f"/channels/{channel_id}/messages",
|
||||
{"content": content, "embeds": [embed]},
|
||||
session=session,
|
||||
)
|
||||
|
||||
msg_id = post_data.get("id")
|
||||
if not msg_id:
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Failed to post Discord message: {post_data.get('message', 'unknown')}",
|
||||
)
|
||||
|
||||
# 2. Poll for text reply
|
||||
decision = await self._poll_for_response(
|
||||
channel_id, msg_id, session=session,
|
||||
)
|
||||
|
||||
# 3. Update original message with result
|
||||
await self._update_message(
|
||||
channel_id, msg_id, request, decision, session=session,
|
||||
)
|
||||
|
||||
return decision
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DiscordApproval error: {e}")
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Discord approval error: {e}",
|
||||
)
|
||||
|
||||
def request_approval_sync(self, request) -> Any:
|
||||
"""Synchronous wrapper — runs async method in a new event loop."""
|
||||
return sync_wrapper(self.request_approval(request), self._timeout)
|
||||
|
||||
# ── Embed Builder ──────────────────────────────────────────────────
|
||||
|
||||
def _build_embed(self, request) -> Dict[str, Any]:
|
||||
"""Build a Discord embed for the approval message."""
|
||||
risk_colors = {
|
||||
"critical": 0xFF0000,
|
||||
"high": 0xFF8C00,
|
||||
"medium": 0xFFD700,
|
||||
"low": 0x00FF00,
|
||||
}
|
||||
|
||||
args_lines = []
|
||||
for key, value in request.arguments.items():
|
||||
val_str = str(value)
|
||||
if len(val_str) > 100:
|
||||
val_str = val_str[:97] + "..."
|
||||
args_lines.append(f"`{key}`: `{val_str}`")
|
||||
args_text = "\n".join(args_lines) if args_lines else "_(none)_"
|
||||
|
||||
fields = [
|
||||
{"name": "Tool", "value": f"`{request.tool_name}`", "inline": True},
|
||||
{"name": "Risk", "value": request.risk_level.upper(), "inline": True},
|
||||
{"name": "Arguments", "value": args_text, "inline": False},
|
||||
]
|
||||
if request.agent_name:
|
||||
fields.insert(2, {"name": "Agent", "value": request.agent_name, "inline": True})
|
||||
|
||||
return {
|
||||
"title": "🔒 Tool Approval Required",
|
||||
"color": risk_colors.get(request.risk_level, 0x808080),
|
||||
"fields": fields,
|
||||
"footer": {"text": f"Reply yes to approve or no to deny (timeout: {int(self._timeout)}s)"},
|
||||
}
|
||||
|
||||
def _build_fallback_text(self, request) -> str:
|
||||
return (
|
||||
f"🔒 Tool Approval Required: `{request.tool_name}` "
|
||||
f"(risk: {request.risk_level}). "
|
||||
f"Reply yes to approve or no to deny."
|
||||
)
|
||||
|
||||
# ── Polling ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _poll_for_response(
|
||||
self,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
session: Optional[Any] = None,
|
||||
) -> Any:
|
||||
"""Poll channel messages for a reply after the approval message."""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
deadline = time.monotonic() + self._timeout
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
try:
|
||||
data = await self._discord_api("GET",
|
||||
f"/channels/{channel_id}/messages?after={message_id}&limit=10",
|
||||
session=session,
|
||||
)
|
||||
|
||||
if isinstance(data, dict) and data.get("message"):
|
||||
logger.warning(f"Discord poll error: {data.get('message')}")
|
||||
continue
|
||||
|
||||
if not isinstance(data, list):
|
||||
continue
|
||||
|
||||
for msg in data:
|
||||
# Skip bot messages
|
||||
author = msg.get("author", {})
|
||||
if author.get("bot"):
|
||||
continue
|
||||
|
||||
text = msg.get("content", "").strip()
|
||||
user_id = author.get("id", "unknown")
|
||||
username = author.get("username", user_id)
|
||||
kw = classify_keyword(text)
|
||||
|
||||
if kw == "approve":
|
||||
return ApprovalDecision(
|
||||
approved=True,
|
||||
reason=f"Approved via Discord by {username}",
|
||||
approver=user_id,
|
||||
metadata={"platform": "discord", "message_id": msg.get("id")},
|
||||
)
|
||||
if kw == "deny":
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Denied via Discord by {username}",
|
||||
approver=user_id,
|
||||
metadata={"platform": "discord", "message_id": msg.get("id")},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Discord poll exception: {e}")
|
||||
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Timed out waiting for Discord approval ({int(self._timeout)}s)",
|
||||
metadata={"platform": "discord", "timeout": True},
|
||||
)
|
||||
|
||||
# ── Message Update ──────────────────────────────────────────────────
|
||||
|
||||
async def _update_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
request,
|
||||
decision,
|
||||
session: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""Update the original approval message with the decision."""
|
||||
if decision.approved:
|
||||
status_emoji = "✅"
|
||||
status_text = "Approved"
|
||||
color = 0x00FF00
|
||||
else:
|
||||
status_emoji = "❌"
|
||||
status_text = "Denied"
|
||||
color = 0xFF0000
|
||||
|
||||
approver = decision.approver or "system"
|
||||
|
||||
embed = {
|
||||
"title": f"{status_emoji} Tool {status_text}",
|
||||
"color": color,
|
||||
"fields": [
|
||||
{"name": "Tool", "value": f"`{request.tool_name}`", "inline": True},
|
||||
{"name": "Decision", "value": status_text, "inline": True},
|
||||
],
|
||||
}
|
||||
if decision.approver:
|
||||
embed["fields"].append({"name": "By", "value": f"<@{approver}>", "inline": True})
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
url = f"{_DISCORD_API_BASE}/channels/{channel_id}/messages/{message_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bot {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"content": f"{status_emoji} Tool {status_text}: `{request.tool_name}`",
|
||||
"embeds": [embed],
|
||||
}
|
||||
if session is not None:
|
||||
async with session.patch(
|
||||
url, headers=headers, json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
else:
|
||||
async with aiohttp.ClientSession() as _session:
|
||||
async with _session.patch(
|
||||
url, headers=headers, json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
|
||||
if isinstance(data, dict) and data.get("message"):
|
||||
logger.warning(f"Failed to update Discord message: {data.get('message')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update Discord message: {e}")
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
HTTP Approval Backend for PraisonAI Agents.
|
||||
|
||||
Implements the ApprovalProtocol by starting a lightweight local HTTP server
|
||||
that serves a minimal approval dashboard. Approvers visit the URL in a
|
||||
browser and click Approve / Deny.
|
||||
|
||||
Uses aiohttp.web — no extra dependencies beyond what PraisonAI already ships.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import HTTPApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
tools=[execute_command],
|
||||
approval=HTTPApproval(host="0.0.0.0", port=8899),
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HTTPApproval:
|
||||
"""Approval backend that serves a local HTTP dashboard for approvals.
|
||||
|
||||
Starts an ephemeral aiohttp web server when the first approval is
|
||||
requested. Each approval gets a unique URL. The approver opens the
|
||||
URL in a browser and clicks Approve or Deny.
|
||||
|
||||
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
|
||||
|
||||
Args:
|
||||
host: Bind address (default ``127.0.0.1``).
|
||||
port: Port to listen on (default ``8899``).
|
||||
timeout: Max seconds to wait for a response (default 300).
|
||||
|
||||
Example::
|
||||
|
||||
from praisonai.bots import HTTPApproval
|
||||
agent = Agent(name="bot", approval=HTTPApproval(port=8899))
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8899,
|
||||
timeout: float = 300,
|
||||
):
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
self._pending: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||
self._server_started = False
|
||||
self._runner: Optional[Any] = None
|
||||
self._site: Optional[Any] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"HTTPApproval(host={self._host!r}, port={self._port})"
|
||||
|
||||
# ── Web server ──────────────────────────────────────────────────────
|
||||
|
||||
async def _ensure_server(self) -> None:
|
||||
"""Start the aiohttp web server if not already running."""
|
||||
if self._server_started:
|
||||
return
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/approve/{request_id}", self._handle_page)
|
||||
app.router.add_post("/approve/{request_id}/decide", self._handle_decide)
|
||||
|
||||
self._runner = web.AppRunner(app)
|
||||
await self._runner.setup()
|
||||
self._site = web.TCPSite(self._runner, self._host, self._port)
|
||||
await self._site.start()
|
||||
self._server_started = True
|
||||
logger.info(f"HTTPApproval server started on http://{self._host}:{self._port}")
|
||||
|
||||
async def _handle_page(self, request) -> Any:
|
||||
"""Serve the approval page HTML."""
|
||||
from aiohttp import web
|
||||
|
||||
request_id = request.match_info["request_id"]
|
||||
pending = self._pending.get(request_id)
|
||||
|
||||
if pending is None:
|
||||
return web.Response(text="Approval request not found or already decided.", status=404)
|
||||
|
||||
if pending.get("decided"):
|
||||
return web.Response(text="This approval has already been decided.", status=200)
|
||||
|
||||
info = pending.get("info", {})
|
||||
html = self._build_html(request_id, info)
|
||||
return web.Response(text=html, content_type="text/html")
|
||||
|
||||
async def _handle_decide(self, request) -> Any:
|
||||
"""Handle approve/deny POST."""
|
||||
from aiohttp import web
|
||||
|
||||
request_id = request.match_info["request_id"]
|
||||
pending = self._pending.get(request_id)
|
||||
|
||||
if pending is None:
|
||||
return web.Response(text="Not found", status=404)
|
||||
if pending.get("decided"):
|
||||
return web.Response(text="Already decided", status=200)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
decision = body.get("decision", "deny")
|
||||
pending["decided"] = True
|
||||
pending["approved"] = decision == "approve"
|
||||
pending["reason"] = body.get("reason", f"{'Approved' if pending['approved'] else 'Denied'} via HTTP dashboard")
|
||||
pending["approver"] = body.get("approver", "http_user")
|
||||
|
||||
return web.json_response({"ok": True, "decision": decision})
|
||||
|
||||
def _build_html(self, request_id: str, info: Dict[str, Any]) -> str:
|
||||
"""Build a minimal approval page."""
|
||||
tool_name = info.get("tool_name", "unknown")
|
||||
risk_level = info.get("risk_level", "unknown")
|
||||
agent_name = info.get("agent_name", "")
|
||||
arguments = info.get("arguments", {})
|
||||
|
||||
args_html = ""
|
||||
for k, v in arguments.items():
|
||||
val_str = str(v)
|
||||
if len(val_str) > 200:
|
||||
val_str = val_str[:197] + "..."
|
||||
args_html += f"<tr><td><code>{k}</code></td><td><code>{val_str}</code></td></tr>"
|
||||
|
||||
risk_colors = {
|
||||
"critical": "#FF0000", "high": "#FF8C00",
|
||||
"medium": "#FFD700", "low": "#00CC00",
|
||||
}
|
||||
risk_color = risk_colors.get(risk_level, "#888")
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Tool Approval Required</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }}
|
||||
h1 {{ color: #fff; }} table {{ width: 100%; border-collapse: collapse; margin: 16px 0; }}
|
||||
td {{ padding: 8px; border-bottom: 1px solid #333; }} code {{ background: #2a2a4a; padding: 2px 6px; border-radius: 3px; }}
|
||||
.risk {{ display: inline-block; padding: 4px 12px; border-radius: 4px; font-weight: bold; color: #fff; background: {risk_color}; }}
|
||||
.buttons {{ display: flex; gap: 12px; margin-top: 24px; }}
|
||||
.btn {{ padding: 12px 32px; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; font-weight: bold; }}
|
||||
.approve {{ background: #00CC00; color: #000; }} .deny {{ background: #FF4444; color: #fff; }}
|
||||
.done {{ text-align: center; margin-top: 24px; font-size: 18px; }}
|
||||
</style></head><body>
|
||||
<h1>🔒 Tool Approval Required</h1>
|
||||
<p><strong>Tool:</strong> <code>{tool_name}</code></p>
|
||||
<p><strong>Risk:</strong> <span class="risk">{risk_level.upper()}</span></p>
|
||||
{"<p><strong>Agent:</strong> " + agent_name + "</p>" if agent_name else ""}
|
||||
<h3>Arguments</h3>
|
||||
<table>{args_html if args_html else "<tr><td><em>none</em></td></tr>"}</table>
|
||||
<div class="buttons">
|
||||
<button class="btn approve" onclick="decide('approve')">✅ Approve</button>
|
||||
<button class="btn deny" onclick="decide('deny')">❌ Deny</button>
|
||||
</div>
|
||||
<div class="done" id="result" style="display:none"></div>
|
||||
<script>
|
||||
async function decide(d) {{
|
||||
const res = await fetch('/approve/{request_id}/decide', {{
|
||||
method: 'POST', headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{decision: d}})
|
||||
}});
|
||||
document.querySelector('.buttons').style.display = 'none';
|
||||
document.getElementById('result').style.display = 'block';
|
||||
document.getElementById('result').textContent = d === 'approve' ? '✅ Approved' : '❌ Denied';
|
||||
}}
|
||||
</script></body></html>"""
|
||||
|
||||
# ── ApprovalProtocol implementation ─────────────────────────────────
|
||||
|
||||
async def request_approval(self, request) -> Any:
|
||||
"""Start server, register request, poll for decision."""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
await self._ensure_server()
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
self._pending[request_id] = {
|
||||
"decided": False,
|
||||
"approved": False,
|
||||
"info": {
|
||||
"tool_name": request.tool_name,
|
||||
"arguments": request.arguments,
|
||||
"risk_level": request.risk_level,
|
||||
"agent_name": request.agent_name,
|
||||
},
|
||||
}
|
||||
|
||||
url = f"http://{self._host}:{self._port}/approve/{request_id}"
|
||||
logger.info(f"HTTPApproval: Waiting for decision at {url}")
|
||||
|
||||
# Poll for decision
|
||||
deadline = time.monotonic() + self._timeout
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
pending = self._pending.get(request_id, {})
|
||||
if pending.get("decided"):
|
||||
# Cleanup
|
||||
del self._pending[request_id]
|
||||
return ApprovalDecision(
|
||||
approved=pending["approved"],
|
||||
reason=pending.get("reason", ""),
|
||||
approver=pending.get("approver"),
|
||||
metadata={"platform": "http", "request_id": request_id, "url": url},
|
||||
)
|
||||
|
||||
# Timeout — cleanup
|
||||
self._pending.pop(request_id, None)
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Timed out waiting for HTTP approval ({int(self._timeout)}s)",
|
||||
metadata={"platform": "http", "request_id": request_id, "timeout": True},
|
||||
)
|
||||
|
||||
def request_approval_sync(self, request) -> Any:
|
||||
"""Synchronous wrapper — runs async method in a new event loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, self.request_approval(request))
|
||||
return future.result(timeout=self._timeout + 10)
|
||||
else:
|
||||
return asyncio.run(self.request_approval(request))
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Stop the HTTP server gracefully."""
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
self._server_started = False
|
||||
logger.info("HTTPApproval server stopped")
|
||||
@@ -1,19 +1,21 @@
|
||||
"""
|
||||
Mixin for ChatCommandProtocol implementation.
|
||||
Mixins for bot protocol implementations.
|
||||
|
||||
Provides ``register_command`` and ``list_commands`` so all bot
|
||||
implementations share a single copy of the logic (DRY).
|
||||
|
||||
Also provides channel-specific command filtering via
|
||||
``register_command(..., channels=[...])``.
|
||||
ChatCommandMixin — ``register_command`` / ``list_commands`` (DRY).
|
||||
MessageHookMixin — fire MESSAGE_RECEIVED / MESSAGE_SENDING / MESSAGE_SENT hooks (DRY).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, List, Optional, Set
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
from praisonaiagents.bots import ChatCommandInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatCommandMixin:
|
||||
"""Mixin that satisfies the ChatCommandProtocol interface.
|
||||
@@ -95,3 +97,139 @@ class ChatCommandMixin:
|
||||
if not platform:
|
||||
return True
|
||||
return platform.lower() in channels_map[name]
|
||||
|
||||
|
||||
class MessageHookMixin:
|
||||
"""Mixin that fires MESSAGE_RECEIVED / MESSAGE_SENDING / MESSAGE_SENT hooks.
|
||||
|
||||
All bot adapters inherit this so hook wiring is DRY.
|
||||
Expects the host class to have:
|
||||
- ``platform`` (str property)
|
||||
- ``_agent`` (optional Agent with hook_runner)
|
||||
|
||||
Zero overhead when no hooks are registered — all attribute access
|
||||
is guarded by ``getattr`` checks.
|
||||
"""
|
||||
|
||||
def _get_hook_runner(self) -> Any:
|
||||
"""Resolve the HookRunner from the agent, if available."""
|
||||
agent = getattr(self, '_agent', None)
|
||||
if agent is None:
|
||||
return None
|
||||
return getattr(agent, '_hook_runner', None)
|
||||
|
||||
def fire_message_received(self, message: Any) -> None:
|
||||
"""Fire MESSAGE_RECEIVED hook when an incoming message arrives.
|
||||
|
||||
Args:
|
||||
message: A BotMessage instance.
|
||||
"""
|
||||
runner = self._get_hook_runner()
|
||||
if runner is None:
|
||||
return
|
||||
try:
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
from praisonaiagents.hooks.events import MessageReceivedInput
|
||||
|
||||
platform = getattr(self, 'platform', 'unknown')
|
||||
sender = getattr(message, 'sender', None)
|
||||
channel = getattr(message, 'channel', None)
|
||||
content = getattr(message, 'content', '')
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
event_input = MessageReceivedInput(
|
||||
session_id="",
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.MESSAGE_RECEIVED,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=getattr(getattr(self, '_agent', None), 'agent_name', 'bot'),
|
||||
platform=platform,
|
||||
content=content,
|
||||
sender_id=getattr(sender, 'user_id', '') if sender else '',
|
||||
channel_id=getattr(channel, 'channel_id', '') if channel else '',
|
||||
channel_type=getattr(channel, 'channel_type', '') if channel else '',
|
||||
message_id=getattr(message, 'message_id', ''),
|
||||
)
|
||||
runner.execute_sync(HookEvent.MESSAGE_RECEIVED, event_input)
|
||||
except Exception as e:
|
||||
logger.debug(f"MESSAGE_RECEIVED hook error (non-fatal): {e}")
|
||||
|
||||
def fire_message_sending(
|
||||
self, channel_id: str, content: str, reply_to: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Fire MESSAGE_SENDING hook before sending a message.
|
||||
|
||||
Hooks can modify content or cancel the send.
|
||||
|
||||
Returns:
|
||||
dict with keys ``content`` (possibly modified) and ``cancel`` (bool).
|
||||
"""
|
||||
result: Dict[str, Any] = {"content": content, "cancel": False}
|
||||
runner = self._get_hook_runner()
|
||||
if runner is None:
|
||||
return result
|
||||
try:
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
from praisonaiagents.hooks.events import MessageSendingInput
|
||||
|
||||
platform = getattr(self, 'platform', 'unknown')
|
||||
event_input = MessageSendingInput(
|
||||
session_id="",
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.MESSAGE_SENDING,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=getattr(getattr(self, '_agent', None), 'agent_name', 'bot'),
|
||||
platform=platform,
|
||||
content=content,
|
||||
channel_id=channel_id,
|
||||
reply_to=reply_to,
|
||||
)
|
||||
hook_results = runner.execute_sync(HookEvent.MESSAGE_SENDING, event_input)
|
||||
if runner.is_blocked(hook_results):
|
||||
result["cancel"] = True
|
||||
return result
|
||||
# Check for modified content in results
|
||||
if hook_results:
|
||||
for hr in hook_results:
|
||||
output = getattr(hr, 'output', None)
|
||||
if output is not None:
|
||||
modified = getattr(output, 'modified_data', None)
|
||||
if isinstance(modified, dict) and 'content' in modified:
|
||||
result["content"] = modified["content"]
|
||||
except Exception as e:
|
||||
logger.debug(f"MESSAGE_SENDING hook error (non-fatal): {e}")
|
||||
return result
|
||||
|
||||
def fire_message_sent(
|
||||
self, channel_id: str, content: str, message_id: str = ""
|
||||
) -> None:
|
||||
"""Fire MESSAGE_SENT hook after a message was successfully sent.
|
||||
|
||||
Args:
|
||||
channel_id: The channel the message was sent to.
|
||||
content: The sent content.
|
||||
message_id: Platform message ID of the sent message.
|
||||
"""
|
||||
runner = self._get_hook_runner()
|
||||
if runner is None:
|
||||
return
|
||||
try:
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
from praisonaiagents.hooks.events import MessageSentInput
|
||||
|
||||
platform = getattr(self, 'platform', 'unknown')
|
||||
event_input = MessageSentInput(
|
||||
session_id="",
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.MESSAGE_SENT,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=getattr(getattr(self, '_agent', None), 'agent_name', 'bot'),
|
||||
platform=platform,
|
||||
content=content,
|
||||
channel_id=channel_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
runner.execute_sync(HookEvent.MESSAGE_SENT, event_input)
|
||||
except Exception as e:
|
||||
logger.debug(f"MESSAGE_SENT hook error (non-fatal): {e}")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Platform registry for PraisonAI BotOS.
|
||||
|
||||
Maps platform names to their bot adapter classes (lazy-loaded).
|
||||
Extensible: third-party platforms can register via ``register_platform()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Type
|
||||
|
||||
# Lazy references: (module_path, class_name)
|
||||
_BUILTIN_PLATFORMS: Dict[str, tuple] = {
|
||||
"telegram": ("praisonai.bots.telegram", "TelegramBot"),
|
||||
"discord": ("praisonai.bots.discord", "DiscordBot"),
|
||||
"slack": ("praisonai.bots.slack", "SlackBot"),
|
||||
"whatsapp": ("praisonai.bots.whatsapp", "WhatsAppBot"),
|
||||
}
|
||||
|
||||
# Custom platforms registered at runtime
|
||||
_custom_platforms: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def get_platform_registry() -> Dict[str, Any]:
|
||||
"""Return the combined registry of all known platforms.
|
||||
|
||||
Keys are platform names, values are either:
|
||||
- A class (custom platforms, already resolved)
|
||||
- A (module, classname) tuple (builtins, lazy-resolved on use)
|
||||
"""
|
||||
combined: Dict[str, Any] = {}
|
||||
combined.update(_BUILTIN_PLATFORMS)
|
||||
combined.update(_custom_platforms)
|
||||
return combined
|
||||
|
||||
|
||||
def register_platform(name: str, adapter_class: Type) -> None:
|
||||
"""Register a custom platform adapter.
|
||||
|
||||
Args:
|
||||
name: Platform identifier (lowercase).
|
||||
adapter_class: The bot adapter class.
|
||||
"""
|
||||
_custom_platforms[name.lower()] = adapter_class
|
||||
|
||||
|
||||
def list_platforms() -> List[str]:
|
||||
"""List all registered platform names."""
|
||||
return sorted(set(list(_BUILTIN_PLATFORMS.keys()) + list(_custom_platforms.keys())))
|
||||
|
||||
|
||||
def resolve_adapter(name: str) -> Type:
|
||||
"""Resolve a platform name to its adapter class (lazy import).
|
||||
|
||||
Args:
|
||||
name: Platform identifier.
|
||||
|
||||
Returns:
|
||||
The adapter class.
|
||||
|
||||
Raises:
|
||||
ValueError: If the platform is not registered.
|
||||
"""
|
||||
key = name.lower()
|
||||
|
||||
# Custom platforms are already classes
|
||||
if key in _custom_platforms:
|
||||
cls = _custom_platforms[key]
|
||||
if isinstance(cls, type):
|
||||
return cls
|
||||
|
||||
# Builtins are lazy (module, classname) tuples
|
||||
if key in _BUILTIN_PLATFORMS:
|
||||
ref = _BUILTIN_PLATFORMS[key]
|
||||
if isinstance(ref, tuple):
|
||||
module_path, class_name = ref
|
||||
import importlib
|
||||
mod = importlib.import_module(module_path)
|
||||
return getattr(mod, class_name)
|
||||
|
||||
raise ValueError(
|
||||
f"Unknown platform: {name!r}. "
|
||||
f"Available: {', '.join(list_platforms())}"
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Resilience utilities for PraisonAI bots.
|
||||
|
||||
Provides exponential backoff, recoverable error detection,
|
||||
and connection monitoring primitives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackoffPolicy:
|
||||
"""Exponential backoff configuration.
|
||||
|
||||
Attributes:
|
||||
initial_ms: Initial delay in milliseconds
|
||||
max_ms: Maximum delay in milliseconds
|
||||
factor: Multiplicative factor per attempt
|
||||
jitter: Random jitter fraction (0.0 - 1.0)
|
||||
max_attempts: Max retry attempts (0 = unlimited)
|
||||
"""
|
||||
|
||||
initial_ms: float = 2000.0
|
||||
max_ms: float = 30000.0
|
||||
factor: float = 1.8
|
||||
jitter: float = 0.25
|
||||
max_attempts: int = 0
|
||||
|
||||
|
||||
# Default policies
|
||||
TELEGRAM_BACKOFF = BackoffPolicy(initial_ms=2000, max_ms=30000, factor=1.8, jitter=0.25)
|
||||
DISCORD_BACKOFF = BackoffPolicy(initial_ms=3000, max_ms=60000, factor=2.0, jitter=0.2)
|
||||
SLACK_BACKOFF = BackoffPolicy(initial_ms=2000, max_ms=30000, factor=1.5, jitter=0.3)
|
||||
WHATSAPP_BACKOFF = BackoffPolicy(initial_ms=5000, max_ms=60000, factor=2.0, jitter=0.2)
|
||||
|
||||
|
||||
def compute_backoff(policy: BackoffPolicy, attempt: int) -> float:
|
||||
"""Compute delay for a given attempt number.
|
||||
|
||||
Args:
|
||||
policy: Backoff configuration
|
||||
attempt: Attempt number (1-based)
|
||||
|
||||
Returns:
|
||||
Delay in seconds
|
||||
"""
|
||||
base_ms = policy.initial_ms * (policy.factor ** (attempt - 1))
|
||||
capped_ms = min(base_ms, policy.max_ms)
|
||||
|
||||
if policy.jitter > 0:
|
||||
jitter_range = capped_ms * policy.jitter
|
||||
capped_ms += random.uniform(-jitter_range, jitter_range)
|
||||
|
||||
return max(0, capped_ms / 1000.0)
|
||||
|
||||
|
||||
# Common recoverable error patterns across platforms
|
||||
_RECOVERABLE_PATTERNS: Set[str] = {
|
||||
"timeout",
|
||||
"timed out",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"connection aborted",
|
||||
"network unreachable",
|
||||
"network is unreachable",
|
||||
"temporary failure",
|
||||
"service unavailable",
|
||||
"bad gateway",
|
||||
"gateway timeout",
|
||||
"too many requests",
|
||||
"rate limit",
|
||||
"rate_limited",
|
||||
"internal server error",
|
||||
"server error",
|
||||
"eof occurred",
|
||||
"broken pipe",
|
||||
"reset by peer",
|
||||
"name resolution",
|
||||
"dns resolution",
|
||||
"ssl",
|
||||
"certificate",
|
||||
}
|
||||
|
||||
# Telegram-specific patterns
|
||||
_TELEGRAM_RECOVERABLE: Set[str] = {
|
||||
"getupdates",
|
||||
"conflict",
|
||||
"terminated by other",
|
||||
"restart",
|
||||
"flood",
|
||||
"retry after",
|
||||
"webhook",
|
||||
}
|
||||
|
||||
|
||||
def is_recoverable_error(err: BaseException, platform: str = "") -> bool:
|
||||
"""Check if an error is likely transient/recoverable.
|
||||
|
||||
Args:
|
||||
err: The exception to check
|
||||
platform: Optional platform name for platform-specific checks
|
||||
|
||||
Returns:
|
||||
True if the error is likely transient
|
||||
"""
|
||||
msg = str(err).lower()
|
||||
err_type = type(err).__name__.lower()
|
||||
|
||||
# Check common patterns
|
||||
for pattern in _RECOVERABLE_PATTERNS:
|
||||
if pattern in msg or pattern in err_type:
|
||||
return True
|
||||
|
||||
# Platform-specific checks
|
||||
if platform == "telegram":
|
||||
for pattern in _TELEGRAM_RECOVERABLE:
|
||||
if pattern in msg:
|
||||
return True
|
||||
# HTTP 409 = getUpdates conflict (another bot instance)
|
||||
if hasattr(err, 'error_code') and getattr(err, 'error_code', None) == 409:
|
||||
return True
|
||||
|
||||
# Check for common HTTP status codes
|
||||
status = getattr(err, 'status', None) or getattr(err, 'status_code', None)
|
||||
if isinstance(status, int) and status in (408, 429, 500, 502, 503, 504):
|
||||
return True
|
||||
|
||||
# asyncio timeouts
|
||||
if isinstance(err, (asyncio.TimeoutError, TimeoutError, ConnectionError, OSError)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_conflict_error(err: BaseException) -> bool:
|
||||
"""Check if error is a Telegram getUpdates 409 conflict.
|
||||
|
||||
This happens when another bot instance is using the same token.
|
||||
"""
|
||||
msg = str(err).lower()
|
||||
status = getattr(err, 'error_code', None) or getattr(err, 'status_code', None)
|
||||
|
||||
if status == 409:
|
||||
return True
|
||||
if "409" in msg and "conflict" in msg:
|
||||
return True
|
||||
if "getupdates" in msg and ("conflict" in msg or "terminated" in msg):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def sleep_with_abort(seconds: float, abort_signal: Optional[asyncio.Event] = None) -> bool:
|
||||
"""Sleep for a duration, but wake early if abort is signaled.
|
||||
|
||||
Args:
|
||||
seconds: Duration to sleep
|
||||
abort_signal: Optional event that cancels the sleep
|
||||
|
||||
Returns:
|
||||
True if sleep completed normally, False if aborted
|
||||
"""
|
||||
if abort_signal is None:
|
||||
await asyncio.sleep(seconds)
|
||||
return True
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(abort_signal.wait(), timeout=seconds)
|
||||
return False # Aborted
|
||||
except asyncio.TimeoutError:
|
||||
return True # Normal completion
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionMonitor:
|
||||
"""Tracks connection health with automatic backoff.
|
||||
|
||||
Attributes:
|
||||
platform: Platform name for error classification
|
||||
policy: Backoff policy for retries
|
||||
attempt: Current retry attempt count
|
||||
last_error: Last error encountered
|
||||
last_error_time: Timestamp of last error
|
||||
total_recoveries: Total successful recoveries
|
||||
"""
|
||||
|
||||
platform: str = ""
|
||||
policy: BackoffPolicy = field(default_factory=BackoffPolicy)
|
||||
attempt: int = 0
|
||||
last_error: Optional[str] = None
|
||||
last_error_time: Optional[float] = None
|
||||
total_recoveries: int = 0
|
||||
|
||||
def record_success(self) -> None:
|
||||
"""Record a successful operation, resetting backoff."""
|
||||
if self.attempt > 0:
|
||||
self.total_recoveries += 1
|
||||
logger.info(
|
||||
f"[{self.platform}] Connection recovered after "
|
||||
f"{self.attempt} attempt(s)"
|
||||
)
|
||||
self.attempt = 0
|
||||
self.last_error = None
|
||||
self.last_error_time = None
|
||||
|
||||
def record_error(self, err: BaseException) -> float:
|
||||
"""Record an error and compute next delay.
|
||||
|
||||
Args:
|
||||
err: The error that occurred
|
||||
|
||||
Returns:
|
||||
Delay in seconds before next retry
|
||||
"""
|
||||
self.attempt += 1
|
||||
self.last_error = str(err)
|
||||
self.last_error_time = time.time()
|
||||
|
||||
delay = compute_backoff(self.policy, self.attempt)
|
||||
|
||||
logger.warning(
|
||||
f"[{self.platform}] Error (attempt {self.attempt}): "
|
||||
f"{self.last_error}; retrying in {delay:.1f}s"
|
||||
)
|
||||
|
||||
return delay
|
||||
|
||||
def should_retry(self) -> bool:
|
||||
"""Check if we should retry based on policy."""
|
||||
if self.policy.max_attempts <= 0:
|
||||
return True # Unlimited
|
||||
return self.attempt < self.policy.max_attempts
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize for status/health reporting."""
|
||||
return {
|
||||
"platform": self.platform,
|
||||
"attempt": self.attempt,
|
||||
"last_error": self.last_error,
|
||||
"last_error_time": self.last_error_time,
|
||||
"total_recoveries": self.total_recoveries,
|
||||
"max_attempts": self.policy.max_attempts,
|
||||
}
|
||||
@@ -5,6 +5,10 @@ Bots share a single Agent instance but each user needs independent
|
||||
chat history. BotSessionManager swaps the agent's ``chat_history``
|
||||
before and after every call so conversations never leak between users.
|
||||
|
||||
When a ``store`` (any ``SessionStoreProtocol`` implementation) is
|
||||
provided, sessions are **persisted to disk** and survive restarts.
|
||||
Without a store, behaviour is identical to the original in-memory mode.
|
||||
|
||||
Thread-safety: an ``asyncio.Lock`` per user serialises concurrent
|
||||
calls from the same user; different users run in parallel.
|
||||
"""
|
||||
@@ -13,7 +17,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, List, TYPE_CHECKING
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
@@ -26,14 +31,23 @@ class BotSessionManager:
|
||||
|
||||
Usage inside a bot message handler::
|
||||
|
||||
# In-memory only (backward compatible)
|
||||
session_mgr = BotSessionManager()
|
||||
|
||||
# With persistent store (recommended)
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
session_mgr = BotSessionManager(
|
||||
store=get_default_session_store(),
|
||||
platform="telegram",
|
||||
)
|
||||
|
||||
response = await session_mgr.chat(agent, user_id, text)
|
||||
|
||||
The manager:
|
||||
1. Saves the agent's current ``chat_history``.
|
||||
2. Loads the user's history (or ``[]`` for a new user).
|
||||
2. Loads the user's history from the persistent store (or in-memory cache).
|
||||
3. Calls ``agent.chat(prompt)`` (via ``run_in_executor``).
|
||||
4. Saves the updated history back to the user store.
|
||||
4. Persists the updated history back to the store.
|
||||
5. Restores the agent's original history.
|
||||
|
||||
``/new`` command → call ``session_mgr.reset(user_id)``.
|
||||
@@ -43,11 +57,24 @@ class BotSessionManager:
|
||||
(e.g. gateway multi-bot mode).
|
||||
"""
|
||||
|
||||
def __init__(self, max_history: int = 100) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
max_history: int = 100,
|
||||
store: Optional[Any] = None,
|
||||
platform: str = "",
|
||||
) -> None:
|
||||
self._histories: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
self._agent_locks: Dict[int, asyncio.Lock] = {}
|
||||
self._max_history = max_history
|
||||
self._store = store
|
||||
self._platform = platform
|
||||
self._last_active: Dict[str, float] = {}
|
||||
|
||||
def _session_key(self, user_id: str) -> str:
|
||||
"""Generate a deterministic session key for persistent storage."""
|
||||
prefix = f"bot_{self._platform}" if self._platform else "bot"
|
||||
return f"{prefix}_{user_id}"
|
||||
|
||||
def _get_lock(self, user_id: str) -> asyncio.Lock:
|
||||
"""Get or create an asyncio.Lock for *user_id*."""
|
||||
@@ -62,6 +89,43 @@ class BotSessionManager:
|
||||
self._agent_locks[agent_id] = asyncio.Lock()
|
||||
return self._agent_locks[agent_id]
|
||||
|
||||
def _load_history(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
"""Load user history from store (if available) or in-memory cache."""
|
||||
if self._store is not None:
|
||||
key = self._session_key(user_id)
|
||||
try:
|
||||
history = self._store.get_chat_history(key)
|
||||
if history:
|
||||
return list(history)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load session from store: %s", e)
|
||||
return list(self._histories.get(user_id, []))
|
||||
|
||||
def _save_history(
|
||||
self, user_id: str, history: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Save user history to store (if available) and in-memory cache."""
|
||||
if self._max_history > 0 and len(history) > self._max_history:
|
||||
history = history[-self._max_history:]
|
||||
|
||||
# Always update in-memory cache
|
||||
self._histories[user_id] = history
|
||||
|
||||
if self._store is not None:
|
||||
key = self._session_key(user_id)
|
||||
try:
|
||||
# Clear existing and re-write (atomic update)
|
||||
self._store.clear_session(key)
|
||||
for msg in history:
|
||||
self._store.add_message(
|
||||
key,
|
||||
msg.get("role", "user"),
|
||||
msg.get("content", ""),
|
||||
msg.get("metadata"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist session to store: %s", e)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
agent: "Agent",
|
||||
@@ -76,37 +140,90 @@ class BotSessionManager:
|
||||
Uses both a per-user lock (serialise same user) and a per-agent
|
||||
lock (prevent concurrent history swaps on a shared Agent).
|
||||
"""
|
||||
self._last_active[user_id] = time.monotonic()
|
||||
user_lock = self._get_lock(user_id)
|
||||
agent_lock = self._get_agent_lock(agent)
|
||||
async with user_lock:
|
||||
# Load history (may hit disk via run_in_executor for async safety)
|
||||
loop = asyncio.get_event_loop()
|
||||
user_history = await loop.run_in_executor(
|
||||
None, self._load_history, user_id
|
||||
)
|
||||
|
||||
async with agent_lock:
|
||||
# Swap histories
|
||||
saved_history = agent.chat_history
|
||||
agent.chat_history = list(self._histories.get(user_id, []))
|
||||
agent.chat_history = user_history
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, agent.chat, prompt)
|
||||
finally:
|
||||
async with agent_lock:
|
||||
# Persist user history and restore agent's original
|
||||
user_history = agent.chat_history
|
||||
if self._max_history > 0 and len(user_history) > self._max_history:
|
||||
user_history = user_history[-self._max_history:]
|
||||
self._histories[user_id] = user_history
|
||||
# Capture updated history and restore agent's original
|
||||
updated_history = agent.chat_history
|
||||
agent.chat_history = saved_history
|
||||
|
||||
# Persist (may hit disk via run_in_executor)
|
||||
await loop.run_in_executor(
|
||||
None, self._save_history, user_id, updated_history
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def reap_stale(self, max_age_seconds: int) -> int:
|
||||
"""Remove sessions older than *max_age_seconds*. Returns count reaped.
|
||||
|
||||
Call this periodically or lazily (e.g. on each chat() call) to
|
||||
auto-clean inactive sessions and free memory.
|
||||
"""
|
||||
if max_age_seconds <= 0:
|
||||
return 0
|
||||
now = time.monotonic()
|
||||
stale = [
|
||||
uid for uid, ts in self._last_active.items()
|
||||
if (now - ts) > max_age_seconds
|
||||
]
|
||||
for uid in stale:
|
||||
self._histories.pop(uid, None)
|
||||
self._last_active.pop(uid, None)
|
||||
self._locks.pop(uid, None)
|
||||
if self._store is not None:
|
||||
key = self._session_key(uid)
|
||||
try:
|
||||
self._store.clear_session(key)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to reap session %s: %s", key, e)
|
||||
if stale:
|
||||
logger.debug("BotSessionManager: reaped %d stale sessions", len(stale))
|
||||
return len(stale)
|
||||
|
||||
def reset(self, user_id: str) -> bool:
|
||||
"""Clear a user's session history. Returns True if it existed."""
|
||||
existed = user_id in self._histories
|
||||
self._histories.pop(user_id, None)
|
||||
self._last_active.pop(user_id, None)
|
||||
|
||||
if self._store is not None:
|
||||
key = self._session_key(user_id)
|
||||
try:
|
||||
self._store.clear_session(key)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to clear session in store: %s", e)
|
||||
|
||||
return existed
|
||||
|
||||
def reset_all(self) -> int:
|
||||
"""Clear all user sessions. Returns the count cleared."""
|
||||
count = len(self._histories)
|
||||
|
||||
if self._store is not None:
|
||||
for user_id in list(self._histories.keys()):
|
||||
key = self._session_key(user_id)
|
||||
try:
|
||||
self._store.clear_session(key)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to clear session %s: %s", key, e)
|
||||
|
||||
self._histories.clear()
|
||||
return count
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Slack Approval Backend for PraisonAI Agents.
|
||||
|
||||
Implements the ApprovalProtocol by sending Block Kit messages to Slack
|
||||
and polling for user replies (yes/no) to approve or deny tool executions.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import SlackApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
tools=[execute_command],
|
||||
approval=SlackApproval(channel="#approvals"), # token from SLACK_BOT_TOKEN env
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ._approval_base import classify_keyword, sync_wrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SlackApproval:
|
||||
"""Approval backend that sends Slack messages and polls for responses.
|
||||
|
||||
Sends a rich Block Kit message with tool details to a Slack channel or DM,
|
||||
then polls ``conversations.history`` for a reply containing an approval
|
||||
or denial keyword.
|
||||
|
||||
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
|
||||
|
||||
Args:
|
||||
token: Slack bot token (xoxb-...). Falls back to ``SLACK_BOT_TOKEN`` env var.
|
||||
channel: Slack channel ID, channel name, or user ID for DMs.
|
||||
timeout: Max seconds to wait for a response (default 300 = 5 min).
|
||||
poll_interval: Seconds between polls (default 3.0).
|
||||
|
||||
Example::
|
||||
|
||||
from praisonai.bots import SlackApproval
|
||||
agent = Agent(name="bot", approval=SlackApproval(channel="U0ABPEV1HK8"))
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
timeout: float = 300,
|
||||
poll_interval: float = 3.0,
|
||||
):
|
||||
self._token = token or os.environ.get("SLACK_BOT_TOKEN", "")
|
||||
if not self._token:
|
||||
raise ValueError(
|
||||
"Slack bot token is required. Pass token= or set SLACK_BOT_TOKEN env var."
|
||||
)
|
||||
self._channel = channel or ""
|
||||
self._timeout = timeout
|
||||
self._poll_interval = poll_interval
|
||||
|
||||
def __repr__(self) -> str:
|
||||
masked = f"xoxb-...{self._token[-4:]}" if len(self._token) > 4 else "***"
|
||||
return f"SlackApproval(channel={self._channel!r}, token={masked!r})"
|
||||
|
||||
# ── Internal Slack API helper ──────────────────────────────────────
|
||||
|
||||
async def _slack_api(
|
||||
self,
|
||||
method: str,
|
||||
payload: Dict[str, Any],
|
||||
session: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Call a Slack Web API method. Override in tests for mocking.
|
||||
|
||||
Args:
|
||||
method: Slack API method name (e.g. ``chat.postMessage``).
|
||||
payload: JSON payload.
|
||||
session: Optional pre-existing ``aiohttp.ClientSession`` for reuse.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
url = f"https://slack.com/api/{method}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
if session is not None:
|
||||
async with session.post(
|
||||
url, headers=headers, json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
return await resp.json()
|
||||
else:
|
||||
async with aiohttp.ClientSession() as _session:
|
||||
async with _session.post(
|
||||
url, headers=headers, json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
return await resp.json()
|
||||
|
||||
# ── ApprovalProtocol implementation ─────────────────────────────────
|
||||
|
||||
async def request_approval(self, request) -> Any:
|
||||
"""Send a Slack message and poll for approval/denial.
|
||||
|
||||
Args:
|
||||
request: ApprovalRequest with tool_name, arguments, risk_level, etc.
|
||||
|
||||
Returns:
|
||||
ApprovalDecision with approved=True/False and metadata.
|
||||
"""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
import aiohttp
|
||||
|
||||
channel = self._channel
|
||||
async with aiohttp.ClientSession() as session:
|
||||
if not channel:
|
||||
try:
|
||||
data = await self._slack_api("auth.test", {}, session=session)
|
||||
if data.get("ok"):
|
||||
channel = data["user_id"]
|
||||
except Exception:
|
||||
pass
|
||||
if not channel:
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason="No Slack channel configured and could not resolve bot user",
|
||||
)
|
||||
|
||||
# 1. Post approval message
|
||||
blocks = self._build_blocks(request)
|
||||
fallback = self._build_fallback_text(request)
|
||||
|
||||
try:
|
||||
post_data = await self._slack_api("chat.postMessage", {
|
||||
"channel": channel,
|
||||
"text": fallback,
|
||||
"blocks": blocks,
|
||||
}, session=session)
|
||||
|
||||
if not post_data.get("ok"):
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Failed to post Slack message: {post_data.get('error', 'unknown')}",
|
||||
)
|
||||
|
||||
msg_ts = post_data["ts"]
|
||||
msg_channel = post_data["channel"]
|
||||
|
||||
# 2. Poll for response (thread-scoped for multi-agent isolation)
|
||||
decision = await self._poll_for_response(
|
||||
msg_channel, msg_ts, session=session,
|
||||
)
|
||||
|
||||
# 3. Update original message with result
|
||||
await self._update_message(
|
||||
msg_channel, msg_ts, request, decision, session=session,
|
||||
)
|
||||
|
||||
return decision
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SlackApproval error: {e}")
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Slack approval error: {e}",
|
||||
)
|
||||
|
||||
def request_approval_sync(self, request) -> Any:
|
||||
"""Synchronous wrapper — runs async method in a new event loop."""
|
||||
return sync_wrapper(self.request_approval(request), self._timeout)
|
||||
|
||||
# ── Block Kit Builder ───────────────────────────────────────────────
|
||||
|
||||
def _build_blocks(self, request) -> List[Dict[str, Any]]:
|
||||
"""Build Slack Block Kit blocks for the approval message."""
|
||||
risk_emoji = {
|
||||
"critical": "🔴",
|
||||
"high": "🟠",
|
||||
"medium": "🟡",
|
||||
"low": "🟢",
|
||||
}
|
||||
emoji = risk_emoji.get(request.risk_level, "⚪")
|
||||
risk_upper = request.risk_level.upper()
|
||||
|
||||
# Format arguments
|
||||
args_lines = []
|
||||
for key, value in request.arguments.items():
|
||||
val_str = str(value)
|
||||
if len(val_str) > 100:
|
||||
val_str = val_str[:97] + "..."
|
||||
args_lines.append(f" `{key}`: `{val_str}`")
|
||||
args_text = "\n".join(args_lines) if args_lines else " _(none)_"
|
||||
|
||||
agent_line = f"\n*Agent:* {request.agent_name}" if request.agent_name else ""
|
||||
|
||||
header_text = (
|
||||
f"🔒 *Tool Approval Required*\n\n"
|
||||
f"*Tool:* `{request.tool_name}`\n"
|
||||
f"*Risk:* {emoji} {risk_upper}{agent_line}\n"
|
||||
f"*Arguments:*\n{args_text}"
|
||||
)
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": header_text},
|
||||
},
|
||||
{"type": "divider"},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f"Reply *yes* to approve or *no* to deny "
|
||||
f"(timeout: {int(self._timeout)}s)"
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
return blocks
|
||||
|
||||
def _build_fallback_text(self, request) -> str:
|
||||
"""Build plain-text fallback for notifications."""
|
||||
return (
|
||||
f"🔒 Tool Approval Required: `{request.tool_name}` "
|
||||
f"(risk: {request.risk_level}). "
|
||||
f"Reply yes to approve or no to deny."
|
||||
)
|
||||
|
||||
# ── Polling ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _poll_for_response(
|
||||
self,
|
||||
channel: str,
|
||||
message_ts: str,
|
||||
session: Optional[Any] = None,
|
||||
) -> Any:
|
||||
"""Poll for replies scoped to the approval message thread.
|
||||
|
||||
Uses ``conversations.replies`` when available so concurrent approval
|
||||
requests on the same channel don't cross-talk. Falls back to
|
||||
``conversations.history`` if replies returns an error (e.g. DMs).
|
||||
"""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
deadline = time.monotonic() + self._timeout
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
try:
|
||||
# Try thread-scoped replies first (best isolation)
|
||||
data = await self._slack_api("conversations.replies", {
|
||||
"channel": channel,
|
||||
"ts": message_ts,
|
||||
"limit": 20,
|
||||
}, session=session)
|
||||
|
||||
# Fallback to history if replies fails (some DM contexts)
|
||||
if not data.get("ok"):
|
||||
data = await self._slack_api("conversations.history", {
|
||||
"channel": channel,
|
||||
"oldest": message_ts,
|
||||
"limit": 10,
|
||||
}, session=session)
|
||||
|
||||
if not data.get("ok"):
|
||||
logger.warning(f"Slack poll error: {data.get('error')}")
|
||||
continue
|
||||
|
||||
for msg in data.get("messages", []):
|
||||
# Skip the bot's own approval message
|
||||
if msg.get("ts") == message_ts:
|
||||
continue
|
||||
# Skip bot messages
|
||||
if msg.get("bot_id"):
|
||||
continue
|
||||
|
||||
text = msg.get("text", "").strip()
|
||||
user = msg.get("user", "unknown")
|
||||
kw = classify_keyword(text)
|
||||
|
||||
if kw == "approve":
|
||||
return ApprovalDecision(
|
||||
approved=True,
|
||||
reason=f"Approved via Slack by <@{user}>",
|
||||
approver=user,
|
||||
metadata={"platform": "slack", "message_ts": msg.get("ts")},
|
||||
)
|
||||
if kw == "deny":
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Denied via Slack by <@{user}>",
|
||||
approver=user,
|
||||
metadata={"platform": "slack", "message_ts": msg.get("ts")},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Slack poll exception: {e}")
|
||||
|
||||
# Timeout
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Timed out waiting for Slack approval ({int(self._timeout)}s)",
|
||||
metadata={"platform": "slack", "timeout": True},
|
||||
)
|
||||
|
||||
# ── Message Update ──────────────────────────────────────────────────
|
||||
|
||||
async def _update_message(
|
||||
self,
|
||||
channel: str,
|
||||
message_ts: str,
|
||||
request,
|
||||
decision,
|
||||
session: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""Update the original approval message with the decision."""
|
||||
if decision.approved:
|
||||
status_emoji = "✅"
|
||||
status_text = "Approved"
|
||||
else:
|
||||
status_emoji = "❌"
|
||||
status_text = "Denied"
|
||||
|
||||
approver = decision.approver or "system"
|
||||
reason = decision.reason or ""
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f"{status_emoji} *Tool {status_text}*\n\n"
|
||||
f"*Tool:* `{request.tool_name}`\n"
|
||||
f"*Decision:* {status_text}"
|
||||
+ (f" by <@{approver}>" if decision.approver else "")
|
||||
+ (f"\n*Reason:* {reason}" if reason else "")
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
try:
|
||||
data = await self._slack_api("chat.update", {
|
||||
"channel": channel,
|
||||
"ts": message_ts,
|
||||
"text": f"{status_emoji} Tool {status_text}: {request.tool_name}",
|
||||
"blocks": blocks,
|
||||
}, session=session)
|
||||
if not data.get("ok"):
|
||||
logger.warning(f"Failed to update Slack message: {data.get('error')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update Slack message: {e}")
|
||||
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
Telegram Approval Backend for PraisonAI Agents.
|
||||
|
||||
Implements the ApprovalProtocol by sending messages with inline keyboard
|
||||
buttons to Telegram and polling ``getUpdates`` for callback_query responses.
|
||||
|
||||
Uses raw aiohttp HTTP calls to the Telegram Bot API — does NOT depend on
|
||||
``python-telegram-bot`` to avoid getUpdates conflicts with a running TelegramBot.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import TelegramApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
tools=[execute_command],
|
||||
approval=TelegramApproval(chat_id="123456789"), # token from TELEGRAM_BOT_TOKEN env
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ._approval_base import classify_keyword, sync_wrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramApproval:
|
||||
"""Approval backend that sends Telegram messages with inline buttons.
|
||||
|
||||
Posts a formatted message with Approve/Deny inline keyboard buttons,
|
||||
then polls ``getUpdates`` for ``callback_query`` matching the message.
|
||||
|
||||
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
|
||||
|
||||
Args:
|
||||
token: Telegram bot token. Falls back to ``TELEGRAM_BOT_TOKEN`` env var.
|
||||
chat_id: Telegram chat ID (user or group) to send approval requests to.
|
||||
timeout: Max seconds to wait for a response (default 300 = 5 min).
|
||||
poll_interval: Seconds between polls (default 2.0).
|
||||
|
||||
Example::
|
||||
|
||||
from praisonai.bots import TelegramApproval
|
||||
agent = Agent(name="bot", approval=TelegramApproval(chat_id="123456789"))
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
chat_id: Optional[str] = None,
|
||||
timeout: float = 300,
|
||||
poll_interval: float = 2.0,
|
||||
):
|
||||
self._token = token or os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
if not self._token:
|
||||
raise ValueError(
|
||||
"Telegram bot token is required. Pass token= or set TELEGRAM_BOT_TOKEN env var."
|
||||
)
|
||||
self._chat_id = chat_id or ""
|
||||
self._timeout = timeout
|
||||
self._poll_interval = poll_interval
|
||||
|
||||
def __repr__(self) -> str:
|
||||
masked = f"...{self._token[-4:]}" if len(self._token) > 4 else "***"
|
||||
return f"TelegramApproval(chat_id={self._chat_id!r}, token={masked!r})"
|
||||
|
||||
# ── Internal Telegram API helper ───────────────────────────────────
|
||||
|
||||
async def _telegram_api(
|
||||
self,
|
||||
method: str,
|
||||
payload: Dict[str, Any],
|
||||
session: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Call a Telegram Bot API method. Override in tests for mocking."""
|
||||
import aiohttp
|
||||
|
||||
url = f"https://api.telegram.org/bot{self._token}/{method}"
|
||||
if session is not None:
|
||||
async with session.post(
|
||||
url, json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
return await resp.json()
|
||||
else:
|
||||
async with aiohttp.ClientSession() as _session:
|
||||
async with _session.post(
|
||||
url, json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
return await resp.json()
|
||||
|
||||
# ── ApprovalProtocol implementation ─────────────────────────────────
|
||||
|
||||
async def request_approval(self, request) -> Any:
|
||||
"""Send a Telegram message with inline buttons and poll for response."""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
import aiohttp
|
||||
|
||||
chat_id = self._chat_id
|
||||
if not chat_id:
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason="No Telegram chat_id configured",
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 1. Send approval message with inline keyboard
|
||||
text = self._build_message_text(request)
|
||||
keyboard = self._build_inline_keyboard(request)
|
||||
|
||||
try:
|
||||
post_data = await self._telegram_api("sendMessage", {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
"reply_markup": keyboard,
|
||||
}, session=session)
|
||||
|
||||
if not post_data.get("ok"):
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Failed to send Telegram message: {post_data.get('description', 'unknown')}",
|
||||
)
|
||||
|
||||
message_id = post_data["result"]["message_id"]
|
||||
|
||||
# 2. Poll for callback_query response
|
||||
decision = await self._poll_for_callback(
|
||||
chat_id, message_id, session=session,
|
||||
)
|
||||
|
||||
# 3. Update original message with result
|
||||
await self._update_message(
|
||||
chat_id, message_id, request, decision, session=session,
|
||||
)
|
||||
|
||||
return decision
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"TelegramApproval error: {e}")
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Telegram approval error: {e}",
|
||||
)
|
||||
|
||||
def request_approval_sync(self, request) -> Any:
|
||||
"""Synchronous wrapper — runs async method in a new event loop."""
|
||||
return sync_wrapper(self.request_approval(request), self._timeout)
|
||||
|
||||
# ── Message Builder ────────────────────────────────────────────────
|
||||
|
||||
def _build_message_text(self, request) -> str:
|
||||
"""Build formatted message text for Telegram."""
|
||||
risk_emoji = {
|
||||
"critical": "🔴",
|
||||
"high": "🟠",
|
||||
"medium": "🟡",
|
||||
"low": "🟢",
|
||||
}
|
||||
emoji = risk_emoji.get(request.risk_level, "⚪")
|
||||
|
||||
args_lines = []
|
||||
for key, value in request.arguments.items():
|
||||
val_str = str(value)
|
||||
if len(val_str) > 100:
|
||||
val_str = val_str[:97] + "..."
|
||||
args_lines.append(f" `{key}`: `{val_str}`")
|
||||
args_text = "\n".join(args_lines) if args_lines else " _(none)_"
|
||||
|
||||
agent_line = f"\n*Agent:* {request.agent_name}" if request.agent_name else ""
|
||||
|
||||
return (
|
||||
f"🔒 *Tool Approval Required*\n\n"
|
||||
f"*Tool:* `{request.tool_name}`\n"
|
||||
f"*Risk:* {emoji} {request.risk_level.upper()}{agent_line}\n"
|
||||
f"*Arguments:*\n{args_text}\n\n"
|
||||
f"_Timeout: {int(self._timeout)}s_"
|
||||
)
|
||||
|
||||
def _build_inline_keyboard(self, request) -> Dict[str, Any]:
|
||||
"""Build Telegram InlineKeyboardMarkup JSON."""
|
||||
return {
|
||||
"inline_keyboard": [
|
||||
[
|
||||
{"text": "✅ Approve", "callback_data": "approve"},
|
||||
{"text": "❌ Deny", "callback_data": "deny"},
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
# ── Polling ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _poll_for_callback(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: int,
|
||||
session: Optional[Any] = None,
|
||||
) -> Any:
|
||||
"""Poll getUpdates for callback_query matching our message."""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
deadline = time.monotonic() + self._timeout
|
||||
offset = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
try:
|
||||
data = await self._telegram_api("getUpdates", {
|
||||
"offset": offset,
|
||||
"timeout": 1,
|
||||
"allowed_updates": ["callback_query", "message"],
|
||||
}, session=session)
|
||||
|
||||
if not data.get("ok"):
|
||||
logger.warning(f"Telegram poll error: {data.get('description')}")
|
||||
continue
|
||||
|
||||
for update in data.get("result", []):
|
||||
update_id = update.get("update_id", 0)
|
||||
if update_id >= offset:
|
||||
offset = update_id + 1
|
||||
|
||||
# Check callback_query (button press)
|
||||
cb = update.get("callback_query")
|
||||
if cb:
|
||||
cb_msg = cb.get("message", {})
|
||||
if cb_msg.get("message_id") == message_id:
|
||||
cb_data = cb.get("data", "")
|
||||
user = cb.get("from", {})
|
||||
user_id = str(user.get("id", "unknown"))
|
||||
username = user.get("username", user_id)
|
||||
|
||||
# Answer the callback to remove loading state
|
||||
await self._telegram_api("answerCallbackQuery", {
|
||||
"callback_query_id": cb.get("id"),
|
||||
}, session=session)
|
||||
|
||||
if cb_data == "approve":
|
||||
return ApprovalDecision(
|
||||
approved=True,
|
||||
reason=f"Approved via Telegram by @{username}",
|
||||
approver=user_id,
|
||||
metadata={"platform": "telegram", "message_id": message_id},
|
||||
)
|
||||
else:
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Denied via Telegram by @{username}",
|
||||
approver=user_id,
|
||||
metadata={"platform": "telegram", "message_id": message_id},
|
||||
)
|
||||
|
||||
# Check text reply as fallback
|
||||
msg = update.get("message")
|
||||
if msg:
|
||||
reply_to = msg.get("reply_to_message", {})
|
||||
msg_chat_id = str(msg.get("chat", {}).get("id", ""))
|
||||
if (
|
||||
msg_chat_id == str(chat_id)
|
||||
and reply_to.get("message_id") == message_id
|
||||
):
|
||||
text = msg.get("text", "").strip()
|
||||
user = msg.get("from", {})
|
||||
user_id = str(user.get("id", "unknown"))
|
||||
kw = classify_keyword(text)
|
||||
|
||||
if kw == "approve":
|
||||
return ApprovalDecision(
|
||||
approved=True,
|
||||
reason=f"Approved via Telegram by user {user_id}",
|
||||
approver=user_id,
|
||||
metadata={"platform": "telegram", "message_id": message_id},
|
||||
)
|
||||
if kw == "deny":
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Denied via Telegram by user {user_id}",
|
||||
approver=user_id,
|
||||
metadata={"platform": "telegram", "message_id": message_id},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Telegram poll exception: {e}")
|
||||
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Timed out waiting for Telegram approval ({int(self._timeout)}s)",
|
||||
metadata={"platform": "telegram", "timeout": True},
|
||||
)
|
||||
|
||||
# ── Message Update ──────────────────────────────────────────────────
|
||||
|
||||
async def _update_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: int,
|
||||
request,
|
||||
decision,
|
||||
session: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""Update the original approval message with the decision."""
|
||||
if decision.approved:
|
||||
status_emoji = "✅"
|
||||
status_text = "Approved"
|
||||
else:
|
||||
status_emoji = "❌"
|
||||
status_text = "Denied"
|
||||
|
||||
approver = decision.approver or "system"
|
||||
reason = decision.reason or ""
|
||||
|
||||
new_text = (
|
||||
f"{status_emoji} *Tool {status_text}*\n\n"
|
||||
f"*Tool:* `{request.tool_name}`\n"
|
||||
f"*Decision:* {status_text}"
|
||||
+ (f" by user {approver}" if decision.approver else "")
|
||||
+ (f"\n*Reason:* {reason}" if reason else "")
|
||||
)
|
||||
|
||||
try:
|
||||
data = await self._telegram_api("editMessageText", {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"text": new_text,
|
||||
"parse_mode": "Markdown",
|
||||
}, session=session)
|
||||
if not data.get("ok"):
|
||||
logger.warning(f"Failed to update Telegram message: {data.get('description')}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update Telegram message: {e}")
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Webhook Approval Backend for PraisonAI Agents.
|
||||
|
||||
Implements the ApprovalProtocol by POSTing approval requests to an external
|
||||
webhook URL and polling a status endpoint (or the same URL with GET) for the
|
||||
decision. Ideal for enterprise integrations, custom dashboards, and CI/CD
|
||||
pipelines.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import WebhookApproval
|
||||
|
||||
agent = Agent(
|
||||
name="assistant",
|
||||
tools=[execute_command],
|
||||
approval=WebhookApproval(
|
||||
webhook_url="https://my-app.com/api/approvals",
|
||||
),
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookApproval:
|
||||
"""Approval backend that sends HTTP webhook requests and polls for decisions.
|
||||
|
||||
Posts a JSON payload to ``webhook_url`` with the approval request details,
|
||||
then polls ``status_url`` (defaults to ``webhook_url/{request_id}``) for
|
||||
the decision.
|
||||
|
||||
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
|
||||
|
||||
Args:
|
||||
webhook_url: URL to POST approval requests to. Falls back to
|
||||
``APPROVAL_WEBHOOK_URL`` env var.
|
||||
status_url: URL template to GET decision status. Use ``{request_id}``
|
||||
placeholder. Defaults to ``webhook_url/{request_id}``.
|
||||
headers: Extra HTTP headers (e.g. auth tokens).
|
||||
timeout: Max seconds to wait for a response (default 300).
|
||||
poll_interval: Seconds between status polls (default 5.0).
|
||||
|
||||
Example::
|
||||
|
||||
from praisonai.bots import WebhookApproval
|
||||
agent = Agent(
|
||||
name="bot",
|
||||
approval=WebhookApproval(
|
||||
webhook_url="https://hooks.example.com/approve",
|
||||
headers={"Authorization": "Bearer sk-xxx"},
|
||||
),
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
webhook_url: Optional[str] = None,
|
||||
status_url: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: float = 300,
|
||||
poll_interval: float = 5.0,
|
||||
):
|
||||
self._webhook_url = webhook_url or os.environ.get("APPROVAL_WEBHOOK_URL", "")
|
||||
if not self._webhook_url:
|
||||
raise ValueError(
|
||||
"Webhook URL is required. Pass webhook_url= or set APPROVAL_WEBHOOK_URL env var."
|
||||
)
|
||||
self._status_url = status_url
|
||||
self._headers = headers or {}
|
||||
self._timeout = timeout
|
||||
self._poll_interval = poll_interval
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"WebhookApproval(webhook_url={self._webhook_url!r})"
|
||||
|
||||
# ── Internal HTTP helper ───────────────────────────────────────────
|
||||
|
||||
async def _http_request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
session: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make an HTTP request. Override in tests for mocking."""
|
||||
import aiohttp
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers.update(self._headers)
|
||||
|
||||
async def _do(s):
|
||||
if method.upper() == "GET":
|
||||
async with s.get(
|
||||
url, headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
if resp.content_type == "application/json":
|
||||
return await resp.json()
|
||||
text = await resp.text()
|
||||
return {"status": resp.status, "body": text}
|
||||
else:
|
||||
async with s.post(
|
||||
url, headers=headers, json=payload or {},
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
if resp.content_type == "application/json":
|
||||
return await resp.json()
|
||||
text = await resp.text()
|
||||
return {"status": resp.status, "body": text}
|
||||
|
||||
if session is not None:
|
||||
return await _do(session)
|
||||
else:
|
||||
async with aiohttp.ClientSession() as _session:
|
||||
return await _do(_session)
|
||||
|
||||
# ── ApprovalProtocol implementation ─────────────────────────────────
|
||||
|
||||
async def request_approval(self, request) -> Any:
|
||||
"""POST approval request to webhook and poll for decision."""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
import aiohttp
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
payload = {
|
||||
"request_id": request_id,
|
||||
"tool_name": request.tool_name,
|
||||
"arguments": request.arguments,
|
||||
"risk_level": request.risk_level,
|
||||
"agent_name": request.agent_name,
|
||||
"session_id": request.session_id,
|
||||
"context": request.context,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
# 1. POST the approval request
|
||||
post_data = await self._http_request(
|
||||
"POST", self._webhook_url, payload, session=session,
|
||||
)
|
||||
|
||||
# Check for immediate decision
|
||||
if isinstance(post_data, dict):
|
||||
if "approved" in post_data:
|
||||
return ApprovalDecision(
|
||||
approved=bool(post_data["approved"]),
|
||||
reason=post_data.get("reason", "Webhook immediate response"),
|
||||
approver=post_data.get("approver"),
|
||||
metadata={"platform": "webhook", "request_id": request_id},
|
||||
)
|
||||
|
||||
# 2. Poll for decision
|
||||
status_url = self._status_url or f"{self._webhook_url}/{request_id}"
|
||||
if "{request_id}" in status_url:
|
||||
status_url = status_url.replace("{request_id}", request_id)
|
||||
|
||||
decision = await self._poll_for_decision(
|
||||
status_url, request_id, session=session,
|
||||
)
|
||||
return decision
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebhookApproval error: {e}")
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Webhook approval error: {e}",
|
||||
)
|
||||
|
||||
def request_approval_sync(self, request) -> Any:
|
||||
"""Synchronous wrapper — runs async method in a new event loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, self.request_approval(request))
|
||||
return future.result(timeout=self._timeout + 10)
|
||||
else:
|
||||
return asyncio.run(self.request_approval(request))
|
||||
|
||||
# ── Polling ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _poll_for_decision(
|
||||
self,
|
||||
status_url: str,
|
||||
request_id: str,
|
||||
session: Optional[Any] = None,
|
||||
) -> Any:
|
||||
"""Poll the status endpoint for a decision."""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
deadline = time.monotonic() + self._timeout
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
try:
|
||||
data = await self._http_request("GET", status_url, session=session)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
|
||||
# Check if decision is available
|
||||
status = data.get("status", "").lower()
|
||||
if status == "pending":
|
||||
continue
|
||||
|
||||
if "approved" in data:
|
||||
return ApprovalDecision(
|
||||
approved=bool(data["approved"]),
|
||||
reason=data.get("reason", f"Webhook decision for {request_id}"),
|
||||
approver=data.get("approver"),
|
||||
metadata={"platform": "webhook", "request_id": request_id},
|
||||
)
|
||||
|
||||
if status in ("approved", "approve", "yes"):
|
||||
return ApprovalDecision(
|
||||
approved=True,
|
||||
reason=data.get("reason", "Approved via webhook"),
|
||||
approver=data.get("approver"),
|
||||
metadata={"platform": "webhook", "request_id": request_id},
|
||||
)
|
||||
if status in ("denied", "deny", "rejected", "no"):
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=data.get("reason", "Denied via webhook"),
|
||||
approver=data.get("approver"),
|
||||
metadata={"platform": "webhook", "request_id": request_id},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Webhook poll exception: {e}")
|
||||
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=f"Timed out waiting for webhook approval ({int(self._timeout)}s)",
|
||||
metadata={"platform": "webhook", "request_id": request_id, "timeout": True},
|
||||
)
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
WhatsApp Web adapter using neonize (whatsmeow/Go backend).
|
||||
|
||||
Provides token-free WhatsApp connectivity via QR code scan and
|
||||
WhatsApp Linked Devices. Credentials persist locally in SQLite.
|
||||
|
||||
This is an OPTIONAL adapter — neonize is lazily imported and only
|
||||
required when mode="web" is used.
|
||||
|
||||
⚠️ EXPERIMENTAL: Uses reverse-engineered WhatsApp Web protocol.
|
||||
Your number may be banned by Meta. Use Cloud API for production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default credential storage path
|
||||
DEFAULT_CREDS_DIR = os.path.join(str(Path.home()), ".praisonai", "whatsapp")
|
||||
|
||||
|
||||
class WhatsAppWebAdapter:
|
||||
"""Adapter wrapping neonize's async WhatsApp Web client.
|
||||
|
||||
Isolates neonize's API surface behind a stable interface so the
|
||||
rest of PraisonAI is shielded from neonize version changes.
|
||||
|
||||
Key design decisions:
|
||||
- neonize is imported lazily in connect() only
|
||||
- Go FFI callbacks are bridged to asyncio via loop.call_soon_threadsafe()
|
||||
- Credentials stored in SQLite (neonize default) at creds_dir
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
creds_dir: Optional[str] = None,
|
||||
bot_name: str = "praisonai_whatsapp",
|
||||
on_message: Optional[Callable] = None,
|
||||
on_qr: Optional[Callable] = None,
|
||||
on_connected: Optional[Callable] = None,
|
||||
on_disconnected: Optional[Callable] = None,
|
||||
):
|
||||
self._creds_dir = creds_dir or os.environ.get(
|
||||
"WHATSAPP_CREDS_DIR", DEFAULT_CREDS_DIR
|
||||
)
|
||||
self._bot_name = bot_name
|
||||
self._on_message = on_message
|
||||
self._on_qr = on_qr
|
||||
self._on_connected = on_connected
|
||||
self._on_disconnected = on_disconnected
|
||||
|
||||
self._client: Any = None
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._is_connected = False
|
||||
self._self_jid: Optional[str] = None
|
||||
self._started_at: Optional[float] = None
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._is_connected
|
||||
|
||||
@property
|
||||
def connected_at(self) -> Optional[float]:
|
||||
"""Epoch seconds when the adapter received ConnectedEv."""
|
||||
return self._started_at
|
||||
|
||||
@property
|
||||
def self_jid(self) -> Optional[str]:
|
||||
return self._self_jid
|
||||
|
||||
@property
|
||||
def creds_dir(self) -> str:
|
||||
return self._creds_dir
|
||||
|
||||
def _ensure_creds_dir(self) -> None:
|
||||
"""Create credentials directory if it doesn't exist."""
|
||||
os.makedirs(self._creds_dir, exist_ok=True)
|
||||
|
||||
def _get_db_path(self) -> str:
|
||||
"""Get SQLite database path for neonize credentials."""
|
||||
self._ensure_creds_dir()
|
||||
return os.path.join(self._creds_dir, f"{self._bot_name}.db")
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to WhatsApp via neonize.
|
||||
|
||||
Displays QR code if no saved session exists.
|
||||
Uses saved credentials if available.
|
||||
"""
|
||||
try:
|
||||
from neonize.aioze.client import NewAClient
|
||||
from neonize.aioze.events import ConnectedEv, MessageEv
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"neonize is required for WhatsApp Web mode. "
|
||||
"Install with: pip install 'praisonai[bot-whatsapp-web]' "
|
||||
"or: pip install neonize"
|
||||
)
|
||||
|
||||
self._loop = asyncio.get_running_loop()
|
||||
db_path = self._get_db_path()
|
||||
|
||||
logger.info(f"WhatsApp Web: connecting (creds: {db_path})")
|
||||
|
||||
# ── Suppress noisy Go-backend warnings ─────────────────────
|
||||
# whatsmeow emits non-actionable warnings (websocket EOF on
|
||||
# close, LTHash mismatches during state sync, duplicate
|
||||
# contacts, missing MAC values). These are protocol-level
|
||||
# quirks, not bugs in our code — suppress to reduce noise.
|
||||
_known_noise = (
|
||||
"failed to close WebSocket",
|
||||
"duplicate contacts found",
|
||||
"mismatching LTHash",
|
||||
"missing value MAC",
|
||||
)
|
||||
|
||||
class _WhatsmeowNoiseFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
return not any(pat in msg for pat in _known_noise)
|
||||
|
||||
for _logger_name in ("whatsmeow.Client", "whatsmeow.Database", "whatsmeow"):
|
||||
_wm_logger = logging.getLogger(_logger_name)
|
||||
_wm_logger.addFilter(_WhatsmeowNoiseFilter())
|
||||
|
||||
# ── Critical: bridge neonize's event loop to ours ──────────
|
||||
# neonize creates event_global_loop = asyncio.new_event_loop()
|
||||
# at import time but NEVER starts it. Go-thread callbacks
|
||||
# (QR, messages, connected) are posted to that dead loop via
|
||||
# asyncio.run_coroutine_threadsafe(..., event_global_loop).
|
||||
# Fix: replace it with the currently running loop so callbacks
|
||||
# actually execute.
|
||||
import neonize.aioze.events as _nz_events
|
||||
import neonize.aioze.client as _nz_client
|
||||
_nz_events.event_global_loop = self._loop
|
||||
_nz_client.event_global_loop = self._loop
|
||||
|
||||
# neonize uses `name` as the DB file path directly
|
||||
self._client = NewAClient(db_path)
|
||||
# Also patch the client instance's loop reference
|
||||
self._client.loop = self._loop
|
||||
|
||||
# Register event handlers via neonize's Event system
|
||||
# client.event is an Event instance; calling it with an event type
|
||||
# returns a decorator that registers the handler.
|
||||
@self._client.event(ConnectedEv)
|
||||
async def on_connected(client: Any, event: ConnectedEv) -> None:
|
||||
self._is_connected = True
|
||||
self._started_at = time.time()
|
||||
# Extract self JID from client.me (set by neonize on Device event)
|
||||
try:
|
||||
if client.me is not None:
|
||||
self._self_jid = str(client.me)
|
||||
except Exception:
|
||||
self._self_jid = None
|
||||
logger.info("WhatsApp Web: connected")
|
||||
if self._on_connected:
|
||||
await self._safe_callback(self._on_connected)
|
||||
|
||||
@self._client.event(MessageEv)
|
||||
async def on_message(client: Any, event: MessageEv) -> None:
|
||||
if self._on_message:
|
||||
await self._safe_callback(self._on_message, event)
|
||||
|
||||
# QR handler uses the dedicated event.qr() registration
|
||||
@self._client.event.qr
|
||||
async def on_qr(client: Any, qr_bytes: bytes) -> None:
|
||||
qr_data = qr_bytes.decode() if isinstance(qr_bytes, bytes) else str(qr_bytes)
|
||||
logger.info("WhatsApp Web: QR code received — scan with your phone")
|
||||
if self._on_qr:
|
||||
await self._safe_callback(self._on_qr, qr_data)
|
||||
else:
|
||||
# Default: print QR to terminal
|
||||
self._print_qr_terminal(qr_data)
|
||||
|
||||
try:
|
||||
await self._client.connect()
|
||||
except Exception as e:
|
||||
self._is_connected = False
|
||||
logger.error(f"WhatsApp Web connection failed: {e}")
|
||||
raise
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from WhatsApp."""
|
||||
self._is_connected = False
|
||||
if self._client:
|
||||
try:
|
||||
await self._client.disconnect()
|
||||
except Exception as e:
|
||||
logger.warning(f"WhatsApp Web disconnect error: {e}")
|
||||
self._client = None
|
||||
logger.info("WhatsApp Web: disconnected")
|
||||
if self._on_disconnected:
|
||||
await self._safe_callback(self._on_disconnected)
|
||||
|
||||
async def send_message(self, to: Any, text: str) -> Optional[str]:
|
||||
"""Send a text message via WhatsApp Web.
|
||||
|
||||
Args:
|
||||
to: Recipient — a native neonize JID protobuf object (preferred,
|
||||
preserves LID routing info) OR a JID string / phone number
|
||||
(e.g., "[email protected]") as fallback.
|
||||
text: Message text
|
||||
|
||||
Returns:
|
||||
Message ID if sent successfully, None otherwise.
|
||||
"""
|
||||
if not self._client or not self._is_connected:
|
||||
logger.error("WhatsApp Web: not connected, cannot send message")
|
||||
return None
|
||||
|
||||
try:
|
||||
# If *to* is already a neonize JID protobuf, use it directly.
|
||||
# This preserves LID routing info and avoids "no LID found" errors.
|
||||
target_jid = to
|
||||
if isinstance(to, str):
|
||||
jid_str = self._normalize_jid(to)
|
||||
from neonize.utils import build_jid
|
||||
parts = jid_str.split("@", 1)
|
||||
user = parts[0]
|
||||
server = parts[1] if len(parts) > 1 else "s.whatsapp.net"
|
||||
target_jid = build_jid(user, server)
|
||||
|
||||
resp = await self._client.send_message(target_jid, text)
|
||||
msg_id = getattr(resp, 'ID', None) or str(resp)
|
||||
return str(msg_id)
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp Web send error: {e}")
|
||||
return None
|
||||
|
||||
async def send_reaction(self, to: str, message_id: str, emoji: str) -> None:
|
||||
"""Send a reaction to a message."""
|
||||
if not self._client or not self._is_connected:
|
||||
return
|
||||
try:
|
||||
jid = self._normalize_jid(to)
|
||||
await self._client.send_reaction(jid, message_id, emoji)
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp Web reaction error: {e}")
|
||||
|
||||
async def mark_as_read(self, to: str, message_id: str) -> None:
|
||||
"""Mark a message as read."""
|
||||
if not self._client or not self._is_connected:
|
||||
return
|
||||
try:
|
||||
jid = self._normalize_jid(to)
|
||||
await self._client.mark_read(jid, message_id)
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp Web mark_read error: {e}")
|
||||
|
||||
def has_saved_session(self) -> bool:
|
||||
"""Check if saved credentials exist."""
|
||||
db_path = self._get_db_path()
|
||||
return os.path.exists(db_path) and os.path.getsize(db_path) > 0
|
||||
|
||||
async def logout(self) -> None:
|
||||
"""Logout and clear saved credentials."""
|
||||
await self.disconnect()
|
||||
db_path = self._get_db_path()
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
logger.info(f"WhatsApp Web: cleared credentials at {db_path}")
|
||||
# Also remove WAL/SHM files
|
||||
for suffix in (".db-wal", ".db-shm"):
|
||||
p = db_path.replace(".db", suffix)
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
|
||||
# ── Internal helpers ─────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _normalize_jid(phone_or_jid: str) -> str:
|
||||
"""Normalize a phone number to WhatsApp JID format."""
|
||||
jid = phone_or_jid.strip()
|
||||
if "@" in jid:
|
||||
return jid
|
||||
# Remove + prefix and non-digits
|
||||
digits = "".join(c for c in jid if c.isdigit())
|
||||
return f"{digits}@s.whatsapp.net"
|
||||
|
||||
@staticmethod
|
||||
def _print_qr_terminal(qr_data: str) -> None:
|
||||
"""Print QR code to terminal. Uses segno (neonize dep) or qrcode."""
|
||||
try:
|
||||
import segno # type: ignore — installed with neonize
|
||||
print("\n" + "=" * 50)
|
||||
print("Scan this QR code in WhatsApp → Linked Devices:")
|
||||
print("=" * 50)
|
||||
segno.make_qr(qr_data).terminal(compact=True)
|
||||
print("=" * 50 + "\n")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import qrcode # type: ignore
|
||||
qr = qrcode.QRCode(border=1)
|
||||
qr.add_data(qr_data)
|
||||
qr.print_ascii(tty=True)
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
# Final fallback: raw data
|
||||
print(f"\n{'='*50}")
|
||||
print("Scan this QR code in WhatsApp → Linked Devices:")
|
||||
print(f"QR Data: {qr_data[:80]}...")
|
||||
print(f"{'='*50}")
|
||||
print("(Install 'segno' or 'qrcode' for visual QR display)")
|
||||
|
||||
async def _safe_callback(self, callback: Callable, *args: Any) -> None:
|
||||
"""Safely invoke a callback, handling both sync and async."""
|
||||
try:
|
||||
result = callback(*args)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp Web callback error: {e}")
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Bot — single-platform, agent-centric bot wrapper.
|
||||
|
||||
Provides a user-friendly API that delegates to platform-specific adapters
|
||||
(TelegramBot, DiscordBot, SlackBot, WhatsAppBot).
|
||||
|
||||
Hierarchy::
|
||||
|
||||
BotOS (multi-platform orchestrator)
|
||||
└── Bot (single platform) ← this class
|
||||
└── Agent / AgentTeam / AgentFlow (AI brain)
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="assistant", instructions="Be helpful")
|
||||
|
||||
# Minimal — token from env (TELEGRAM_BOT_TOKEN)
|
||||
bot = Bot("telegram", agent=agent)
|
||||
await bot.start()
|
||||
|
||||
# Explicit token
|
||||
bot = Bot("discord", agent=agent, token="YOUR_TOKEN")
|
||||
|
||||
# Platform-specific kwargs
|
||||
bot = Bot("whatsapp", agent=agent, mode="web")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Env-var convention: {PLATFORM}_BOT_TOKEN
|
||||
_TOKEN_ENV_MAP = {
|
||||
"telegram": "TELEGRAM_BOT_TOKEN",
|
||||
"discord": "DISCORD_BOT_TOKEN",
|
||||
"slack": "SLACK_BOT_TOKEN",
|
||||
"whatsapp": "WHATSAPP_ACCESS_TOKEN",
|
||||
}
|
||||
|
||||
# Slack also needs an app token
|
||||
_EXTRA_ENV_MAP = {
|
||||
"slack": {"app_token": "SLACK_APP_TOKEN"},
|
||||
"whatsapp": {"phone_number_id": "WHATSAPP_PHONE_NUMBER_ID"},
|
||||
}
|
||||
|
||||
|
||||
class Bot:
|
||||
"""Single-platform bot wrapper — the user-facing API.
|
||||
|
||||
Resolves the correct platform adapter at ``start()`` time (lazy),
|
||||
so no heavy imports happen at construction.
|
||||
|
||||
Args:
|
||||
platform: Platform name ("telegram", "discord", "slack", "whatsapp").
|
||||
agent: Agent, AgentTeam, or AgentFlow to power the bot.
|
||||
token: Explicit token. Falls back to env var ``{PLATFORM}_BOT_TOKEN``.
|
||||
config: Optional BotConfig override.
|
||||
**kwargs: Platform-specific arguments passed to the adapter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform: str,
|
||||
agent: Optional[Any] = None,
|
||||
token: Optional[str] = None,
|
||||
config: Optional[Any] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._platform = platform.lower().strip()
|
||||
self._agent = agent
|
||||
self._explicit_token = token
|
||||
self._config = config
|
||||
self._kwargs = kwargs
|
||||
|
||||
self._adapter: Optional[Any] = None
|
||||
self._is_running = False
|
||||
|
||||
# ── Properties ──────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def platform(self) -> str:
|
||||
return self._platform
|
||||
|
||||
@property
|
||||
def agent(self) -> Optional[Any]:
|
||||
return self._agent
|
||||
|
||||
@agent.setter
|
||||
def agent(self, value: Any) -> None:
|
||||
self._agent = value
|
||||
if self._adapter and hasattr(self._adapter, 'set_agent'):
|
||||
self._adapter.set_agent(value)
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
"""Resolve token: explicit > env var > empty."""
|
||||
if self._explicit_token:
|
||||
return self._explicit_token
|
||||
env_key = _TOKEN_ENV_MAP.get(self._platform, f"{self._platform.upper()}_BOT_TOKEN")
|
||||
return os.environ.get(env_key, "")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
if self._adapter and hasattr(self._adapter, 'is_running'):
|
||||
return self._adapter.is_running
|
||||
return self._is_running
|
||||
|
||||
@property
|
||||
def adapter(self) -> Optional[Any]:
|
||||
"""The underlying platform adapter (available after start)."""
|
||||
return self._adapter
|
||||
|
||||
# ── Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
def _apply_smart_defaults(self, agent: Any) -> Any:
|
||||
"""Enhance agent with sensible bot defaults if not already configured.
|
||||
|
||||
Smart defaults are applied automatically:
|
||||
- Safe tools (search_web, schedule_add/list/remove) if agent has no tools
|
||||
- Memory enabled if not already set
|
||||
|
||||
These defaults make Bot() immediately useful without extra configuration.
|
||||
Users who want full control can pre-configure their agent.
|
||||
"""
|
||||
if agent is None:
|
||||
return agent
|
||||
|
||||
# Only enhance Agent instances (not AgentTeam/AgentFlow)
|
||||
agent_cls_name = type(agent).__name__
|
||||
if agent_cls_name not in ("Agent",):
|
||||
return agent
|
||||
|
||||
# Wire BotConfig.auto_approve_tools → Agent(approval=True)
|
||||
if self._config and getattr(self._config, 'auto_approve_tools', False):
|
||||
if getattr(agent, '_approval_backend', None) is None:
|
||||
from praisonaiagents.approval.backends import AutoApproveBackend
|
||||
agent._approval_backend = AutoApproveBackend()
|
||||
logger.debug(f"Bot: auto_approve_tools enabled for agent '{getattr(agent, 'name', '?')}'")
|
||||
|
||||
# Inject session history if agent has no memory configured (zero-dep).
|
||||
# NOTE: No session_id here — BotSessionManager handles per-user
|
||||
# isolation by swapping chat_history before/after each agent.chat().
|
||||
current_memory = getattr(agent, 'memory', None)
|
||||
if current_memory is None:
|
||||
agent.memory = {
|
||||
"history": True,
|
||||
"history_limit": 20,
|
||||
}
|
||||
logger.debug(f"Bot: injected session history for agent '{getattr(agent, 'name', '?')}'")
|
||||
|
||||
# Add default tools if agent has none
|
||||
current_tools = getattr(agent, 'tools', None) or []
|
||||
if not current_tools:
|
||||
try:
|
||||
from praisonaiagents.tools import (
|
||||
schedule_add, schedule_list, schedule_remove,
|
||||
)
|
||||
default_tools = [schedule_add, schedule_list, schedule_remove]
|
||||
|
||||
# Try to add search_web if available
|
||||
try:
|
||||
from praisonaiagents.tools import search_web
|
||||
default_tools.insert(0, search_web)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
agent.tools = default_tools
|
||||
logger.debug(f"Bot: applied default tools to agent '{getattr(agent, 'name', '?')}'")
|
||||
except ImportError:
|
||||
pass # Tools not available, skip
|
||||
|
||||
return agent
|
||||
|
||||
def _build_adapter(self) -> Any:
|
||||
"""Lazy-resolve and instantiate the platform adapter."""
|
||||
from ._registry import resolve_adapter
|
||||
|
||||
adapter_cls = resolve_adapter(self._platform)
|
||||
|
||||
# Apply smart defaults to agent before passing to adapter
|
||||
agent = self._apply_smart_defaults(self._agent)
|
||||
|
||||
# Build init kwargs for the adapter
|
||||
init_kwargs: Dict[str, Any] = {}
|
||||
init_kwargs["token"] = self.token
|
||||
|
||||
if agent is not None:
|
||||
init_kwargs["agent"] = agent
|
||||
if self._config is not None:
|
||||
init_kwargs["config"] = self._config
|
||||
|
||||
# Resolve extra env vars (e.g. SLACK_APP_TOKEN)
|
||||
extras = _EXTRA_ENV_MAP.get(self._platform, {})
|
||||
for param, env_key in extras.items():
|
||||
if param not in self._kwargs:
|
||||
env_val = os.environ.get(env_key, "")
|
||||
if env_val:
|
||||
init_kwargs[param] = env_val
|
||||
|
||||
# Merge user kwargs (override defaults)
|
||||
init_kwargs.update(self._kwargs)
|
||||
|
||||
return adapter_cls(**init_kwargs)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Build the adapter and start the bot."""
|
||||
if self._adapter and self.is_running:
|
||||
logger.warning(f"Bot({self._platform}) already running")
|
||||
return
|
||||
|
||||
self._adapter = self._build_adapter()
|
||||
self._is_running = True
|
||||
await self._adapter.start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the bot."""
|
||||
if self._adapter:
|
||||
await self._adapter.stop()
|
||||
self._is_running = False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Synchronous entry point — starts the bot.
|
||||
|
||||
Convenience wrapper so users don't need ``asyncio.run()``.
|
||||
|
||||
Usage::
|
||||
|
||||
bot = Bot("telegram", agent=agent)
|
||||
bot.run() # blocks until Ctrl+C
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
asyncio.run(self.start())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
# ── Delegation to adapter ──────────────────────────────────────
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Send a message via the underlying adapter."""
|
||||
if not self._adapter:
|
||||
raise RuntimeError("Bot not started")
|
||||
return await self._adapter.send_message(
|
||||
channel_id, content, reply_to=reply_to, thread_id=thread_id
|
||||
)
|
||||
|
||||
def on_message(self, handler: Callable) -> Callable:
|
||||
"""Register a message handler (pre-start decorator)."""
|
||||
if self._adapter:
|
||||
return self._adapter.on_message(handler)
|
||||
# Store for later registration
|
||||
if not hasattr(self, '_pending_message_handlers'):
|
||||
self._pending_message_handlers: List[Callable] = []
|
||||
self._pending_message_handlers.append(handler)
|
||||
return handler
|
||||
|
||||
def on_command(self, command: str) -> Callable:
|
||||
"""Decorator to register a command handler."""
|
||||
if self._adapter:
|
||||
return self._adapter.on_command(command)
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
if not hasattr(self, '_pending_command_handlers'):
|
||||
self._pending_command_handlers: Dict[str, Callable] = {}
|
||||
self._pending_command_handlers[command] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
async def probe(self) -> Any:
|
||||
"""Test channel connectivity (builds adapter lazily if needed)."""
|
||||
if not self._adapter:
|
||||
self._adapter = self._build_adapter()
|
||||
if hasattr(self._adapter, 'probe'):
|
||||
return await self._adapter.probe()
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
return ProbeResult(ok=False, platform=self._platform, error="Adapter does not support probe()")
|
||||
|
||||
async def health(self) -> Any:
|
||||
"""Get detailed health status."""
|
||||
if not self._adapter:
|
||||
self._adapter = self._build_adapter()
|
||||
if hasattr(self._adapter, 'health'):
|
||||
return await self._adapter.health()
|
||||
from praisonaiagents.bots import HealthResult
|
||||
return HealthResult(ok=False, platform=self._platform, error="Adapter does not support health()")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
agent_name = getattr(self._agent, 'name', None) if self._agent else None
|
||||
return f"Bot(platform={self._platform!r}, agent={agent_name!r}, running={self.is_running})"
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
BotOS — multi-platform bot orchestrator.
|
||||
|
||||
Manages multiple Bot instances across different messaging platforms
|
||||
with a single unified lifecycle.
|
||||
|
||||
Hierarchy::
|
||||
|
||||
BotOS (multi-platform orchestrator) ← this class
|
||||
└── Bot (single platform)
|
||||
└── Agent / AgentTeam / AgentFlow (AI brain)
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonai.bots import BotOS, Bot
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="assistant", instructions="Be helpful")
|
||||
|
||||
# Approach 1: Explicit bots
|
||||
botos = BotOS(bots=[
|
||||
Bot("telegram", agent=agent),
|
||||
Bot("discord", agent=agent),
|
||||
])
|
||||
await botos.start()
|
||||
|
||||
# Approach 2: Shortcut — same agent, multiple platforms
|
||||
botos = BotOS(agent=agent, platforms=["telegram", "discord"])
|
||||
await botos.start()
|
||||
|
||||
# Approach 3: Build incrementally
|
||||
botos = BotOS()
|
||||
botos.add_bot(Bot("telegram", agent=agent))
|
||||
botos.add_bot(Bot("slack", agent=agent, app_token="xapp-..."))
|
||||
await botos.start()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from .bot import Bot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BotOS:
|
||||
"""Multi-platform bot orchestrator.
|
||||
|
||||
Starts and stops all registered Bot instances concurrently.
|
||||
Satisfies ``BotOSProtocol`` from the core SDK.
|
||||
|
||||
Args:
|
||||
bots: Pre-built Bot instances.
|
||||
agent: Shared agent for auto-created bots (used with *platforms*).
|
||||
platforms: List of platform names; creates a Bot per platform
|
||||
using the shared *agent*.
|
||||
config: Optional BotOSConfig.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bots: Optional[List[Bot]] = None,
|
||||
agent: Optional[Any] = None,
|
||||
platforms: Optional[List[str]] = None,
|
||||
config: Optional[Any] = None,
|
||||
):
|
||||
self._bots: Dict[str, Bot] = {}
|
||||
self._is_running = False
|
||||
self._config = config
|
||||
self._tasks: List[asyncio.Task] = []
|
||||
|
||||
# Register explicit bots
|
||||
if bots:
|
||||
for bot in bots:
|
||||
self.add_bot(bot)
|
||||
|
||||
# Shortcut: agent + platforms → auto-create Bot per platform
|
||||
if agent and platforms:
|
||||
for plat in platforms:
|
||||
if plat not in self._bots:
|
||||
self.add_bot(Bot(plat, agent=agent))
|
||||
|
||||
# ── Properties ──────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
|
||||
# ── Bot management ──────────────────────────────────────────────
|
||||
|
||||
def add_bot(self, bot: Bot) -> None:
|
||||
"""Register a Bot for orchestration.
|
||||
|
||||
Args:
|
||||
bot: A Bot instance.
|
||||
"""
|
||||
self._bots[bot.platform] = bot
|
||||
|
||||
def list_bots(self) -> List[str]:
|
||||
"""List platform names of all registered bots."""
|
||||
return list(self._bots.keys())
|
||||
|
||||
def get_bot(self, platform: str) -> Optional[Bot]:
|
||||
"""Get a registered bot by platform name."""
|
||||
return self._bots.get(platform.lower())
|
||||
|
||||
# ── Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start all registered bots concurrently.
|
||||
|
||||
Each bot runs in its own asyncio task. BotOS waits for all
|
||||
bots (they block until stopped).
|
||||
"""
|
||||
if self._is_running:
|
||||
logger.warning("BotOS already running")
|
||||
return
|
||||
|
||||
if not self._bots:
|
||||
logger.warning("BotOS: no bots registered, nothing to start")
|
||||
return
|
||||
|
||||
self._is_running = True
|
||||
logger.info(f"BotOS starting {len(self._bots)} bot(s): {', '.join(self._bots.keys())}")
|
||||
|
||||
self._tasks = []
|
||||
for platform, bot in self._bots.items():
|
||||
task = asyncio.create_task(
|
||||
self._run_bot(platform, bot),
|
||||
name=f"botos-{platform}",
|
||||
)
|
||||
self._tasks.append(task)
|
||||
|
||||
try:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self._is_running = False
|
||||
|
||||
async def _run_bot(self, platform: str, bot: Bot) -> None:
|
||||
"""Run a single bot with error isolation."""
|
||||
try:
|
||||
logger.info(f"BotOS: starting {platform}")
|
||||
await bot.start()
|
||||
except Exception as e:
|
||||
logger.error(f"BotOS: {platform} failed: {e}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Gracefully stop all running bots."""
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
logger.info("BotOS stopping all bots...")
|
||||
|
||||
# Stop each bot
|
||||
for platform, bot in self._bots.items():
|
||||
try:
|
||||
await bot.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"BotOS: error stopping {platform}: {e}")
|
||||
|
||||
# Cancel tasks
|
||||
for task in self._tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
self._is_running = False
|
||||
logger.info("BotOS stopped")
|
||||
|
||||
def remove_bot(self, platform: str) -> bool:
|
||||
"""Remove a registered bot by platform name.
|
||||
|
||||
Args:
|
||||
platform: Platform identifier to remove.
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found.
|
||||
"""
|
||||
key = platform.lower()
|
||||
if key in self._bots:
|
||||
del self._bots[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Synchronous entry point — starts all bots.
|
||||
|
||||
Convenience wrapper so users don't need ``asyncio.run()``.
|
||||
|
||||
Usage::
|
||||
|
||||
botos = BotOS(agent=agent, platforms=["telegram", "discord"])
|
||||
botos.run() # blocks until Ctrl+C
|
||||
"""
|
||||
try:
|
||||
asyncio.run(self.start())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("BotOS interrupted")
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, path: str) -> "BotOS":
|
||||
"""Create a BotOS instance from a YAML config file.
|
||||
|
||||
YAML format::
|
||||
|
||||
name: My BotOS
|
||||
agent:
|
||||
name: assistant
|
||||
instructions: Be helpful
|
||||
llm: gpt-4o-mini
|
||||
platforms:
|
||||
telegram:
|
||||
token: ${TELEGRAM_BOT_TOKEN}
|
||||
discord:
|
||||
token: ${DISCORD_BOT_TOKEN}
|
||||
|
||||
Args:
|
||||
path: Path to YAML config file.
|
||||
|
||||
Returns:
|
||||
Configured BotOS instance.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
raise ImportError("PyYAML required: pip install pyyaml")
|
||||
|
||||
with open(path, "r") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if not raw or not isinstance(raw, dict):
|
||||
raise ValueError(f"Invalid BotOS config: {path}")
|
||||
|
||||
def _resolve_env(val):
|
||||
if isinstance(val, str):
|
||||
return re.sub(
|
||||
r'\$\{([^}]+)\}',
|
||||
lambda m: os.environ.get(m.group(1), m.group(0)),
|
||||
val,
|
||||
)
|
||||
return val
|
||||
|
||||
# Build agent from config
|
||||
agent = None
|
||||
agent_cfg = raw.get("agent")
|
||||
if agent_cfg and isinstance(agent_cfg, dict):
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Core params always supported
|
||||
agent_kwargs: Dict[str, Any] = {
|
||||
"name": agent_cfg.get("name", "assistant"),
|
||||
"instructions": agent_cfg.get("instructions", "You are a helpful assistant."),
|
||||
}
|
||||
if agent_cfg.get("llm"):
|
||||
agent_kwargs["llm"] = agent_cfg["llm"]
|
||||
|
||||
# Extended params — memory, tools, verbose, knowledge, guardrails
|
||||
if "memory" in agent_cfg:
|
||||
agent_kwargs["memory"] = agent_cfg["memory"]
|
||||
|
||||
# Pass through known Agent params
|
||||
for key in ("role", "goal", "backstory", "planning", "reflection"):
|
||||
if key in agent_cfg:
|
||||
agent_kwargs[key] = agent_cfg[key]
|
||||
|
||||
# Resolve tool names to real functions
|
||||
tool_names = agent_cfg.get("tools")
|
||||
if tool_names and isinstance(tool_names, list):
|
||||
resolved_tools = []
|
||||
for tname in tool_names:
|
||||
if isinstance(tname, str):
|
||||
try:
|
||||
import importlib
|
||||
mod = importlib.import_module("praisonaiagents.tools")
|
||||
fn = getattr(mod, tname, None)
|
||||
if fn:
|
||||
resolved_tools.append(fn)
|
||||
else:
|
||||
logger.warning(f"BotOS: tool '{tname}' not found in praisonaiagents.tools")
|
||||
except ImportError:
|
||||
logger.warning(f"BotOS: could not import tools module for '{tname}'")
|
||||
else:
|
||||
resolved_tools.append(tname) # Already a callable
|
||||
if resolved_tools:
|
||||
agent_kwargs["tools"] = resolved_tools
|
||||
|
||||
if "knowledge" in agent_cfg:
|
||||
agent_kwargs["knowledge"] = agent_cfg["knowledge"]
|
||||
|
||||
if "guardrail" in agent_cfg:
|
||||
agent_kwargs["guardrail"] = agent_cfg["guardrail"]
|
||||
|
||||
agent = Agent(**agent_kwargs)
|
||||
|
||||
# Build bots per platform
|
||||
bots = []
|
||||
platforms_cfg = raw.get("platforms", {})
|
||||
for plat_name, plat_cfg in platforms_cfg.items():
|
||||
if not isinstance(plat_cfg, dict):
|
||||
plat_cfg = {}
|
||||
# Resolve env vars in values
|
||||
resolved = {k: _resolve_env(v) for k, v in plat_cfg.items()}
|
||||
token = resolved.pop("token", None)
|
||||
bots.append(Bot(plat_name, agent=agent, token=token, **resolved))
|
||||
|
||||
return cls(bots=bots)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
platforms = list(self._bots.keys())
|
||||
return f"BotOS(platforms={platforms!r}, running={self._is_running})"
|
||||
@@ -15,7 +15,7 @@ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
|
||||
from praisonaiagents.bots import (
|
||||
BotConfig,
|
||||
BotMessage,
|
||||
@@ -26,11 +26,12 @@ from praisonaiagents.bots import (
|
||||
|
||||
from ._commands import format_status, format_help
|
||||
from ._session import BotSessionManager
|
||||
from ._debounce import InboundDebouncer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiscordBot(ChatCommandMixin):
|
||||
class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""Discord bot runtime for PraisonAI agents.
|
||||
|
||||
Connects an agent to Discord, handling messages, slash commands,
|
||||
@@ -77,6 +78,9 @@ class DiscordBot(ChatCommandMixin):
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._session: BotSessionManager = BotSessionManager()
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -133,6 +137,8 @@ class DiscordBot(ChatCommandMixin):
|
||||
|
||||
bot_message = self._convert_message(message)
|
||||
|
||||
self.fire_message_received(bot_message)
|
||||
|
||||
if not self.config.is_user_allowed(bot_message.sender.user_id if bot_message.sender else ""):
|
||||
return
|
||||
if not self.config.is_channel_allowed(bot_message.channel.channel_id if bot_message.channel else ""):
|
||||
@@ -180,18 +186,27 @@ class DiscordBot(ChatCommandMixin):
|
||||
|
||||
if self._agent:
|
||||
user_id = str(message.author.id)
|
||||
async def _send_agent_response():
|
||||
text_to_send = await self._debouncer.debounce(user_id, bot_message.text)
|
||||
response = await self._session.chat(self._agent, user_id, text_to_send)
|
||||
send_result = self.fire_message_sending(
|
||||
str(message.channel.id), str(response),
|
||||
)
|
||||
if send_result["cancel"]:
|
||||
return
|
||||
await self._send_long_message(message.channel, send_result["content"], reference=message)
|
||||
self.fire_message_sent(str(message.channel.id), send_result["content"])
|
||||
|
||||
if self.config.typing_indicator:
|
||||
async with message.channel.typing():
|
||||
try:
|
||||
response = await self._session.chat(self._agent, user_id, bot_message.text)
|
||||
await self._send_long_message(message.channel, response, reference=message)
|
||||
await _send_agent_response()
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
await message.reply(f"Error: {str(e)}")
|
||||
else:
|
||||
try:
|
||||
response = await self._session.chat(self._agent, user_id, bot_message.text)
|
||||
await self._send_long_message(message.channel, response, reference=message)
|
||||
await _send_agent_response()
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
await message.reply(f"Error: {str(e)}")
|
||||
@@ -204,6 +219,7 @@ class DiscordBot(ChatCommandMixin):
|
||||
return
|
||||
|
||||
self._is_running = False
|
||||
self._debouncer.cancel_all()
|
||||
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
@@ -250,7 +266,9 @@ class DiscordBot(ChatCommandMixin):
|
||||
)
|
||||
|
||||
async def _send_long_message(self, channel, text: str, reference=None) -> None:
|
||||
"""Send a long message, splitting if necessary."""
|
||||
"""Send a long message, splitting with markdown-aware chunking."""
|
||||
from ._chunk import chunk_message
|
||||
|
||||
max_len = min(self.config.max_message_length, 2000)
|
||||
|
||||
if len(text) <= max_len:
|
||||
@@ -259,7 +277,7 @@ class DiscordBot(ChatCommandMixin):
|
||||
else:
|
||||
await channel.send(text)
|
||||
else:
|
||||
chunks = [text[i:i+max_len] for i in range(0, len(text), max_len)]
|
||||
chunks = chunk_message(text, max_length=max_len, preserve_fences=True)
|
||||
for i, chunk in enumerate(chunks):
|
||||
if i == 0 and reference:
|
||||
await channel.send(chunk, reference=reference)
|
||||
@@ -364,6 +382,43 @@ class DiscordBot(ChatCommandMixin):
|
||||
pass
|
||||
return None
|
||||
|
||||
async def probe(self):
|
||||
"""Test Discord API connectivity without starting the bot."""
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
started = time.time()
|
||||
try:
|
||||
import aiohttp
|
||||
url = "https://discord.com/api/v10/users/@me"
|
||||
headers = {"Authorization": f"Bot {self._token}"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
elapsed = (time.time() - started) * 1000
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return ProbeResult(
|
||||
ok=True, platform="discord", elapsed_ms=elapsed,
|
||||
bot_username=data.get("username"),
|
||||
details={"bot_id": data.get("id"), "discriminator": data.get("discriminator")},
|
||||
)
|
||||
else:
|
||||
text = await resp.text()
|
||||
return ProbeResult(ok=False, platform="discord", elapsed_ms=elapsed, error=f"HTTP {resp.status}: {text[:200]}")
|
||||
except Exception as e:
|
||||
return ProbeResult(ok=False, platform="discord", elapsed_ms=(time.time() - started) * 1000, error=str(e))
|
||||
|
||||
async def health(self):
|
||||
"""Get detailed health status of the Discord bot."""
|
||||
from praisonaiagents.bots import HealthResult
|
||||
probe_result = await self.probe()
|
||||
uptime = (time.time() - self._started_at) if self._started_at else None
|
||||
session_count = len(self._session._histories) if hasattr(self._session, '_histories') else 0
|
||||
return HealthResult(
|
||||
ok=self._is_running and probe_result.ok, platform="discord",
|
||||
is_running=self._is_running, uptime_seconds=uptime,
|
||||
probe=probe_result, sessions=session_count,
|
||||
error=probe_result.error if not probe_result.ok else None,
|
||||
)
|
||||
|
||||
def _format_status(self) -> str:
|
||||
"""Format /status response."""
|
||||
return format_status(self._agent, self.platform, self._started_at, self._is_running)
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
|
||||
from praisonaiagents.bots import (
|
||||
BotConfig,
|
||||
BotMessage,
|
||||
@@ -28,11 +28,12 @@ from praisonaiagents.bots import (
|
||||
from .media import split_media_from_output, is_audio_file
|
||||
from ._commands import format_status, format_help
|
||||
from ._session import BotSessionManager
|
||||
from ._debounce import InboundDebouncer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SlackBot(ChatCommandMixin):
|
||||
class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""Slack bot runtime for PraisonAI agents.
|
||||
|
||||
Connects an agent to Slack, handling messages, slash commands,
|
||||
@@ -90,6 +91,9 @@ class SlackBot(ChatCommandMixin):
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._session: BotSessionManager = BotSessionManager()
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
|
||||
# Audio capabilities
|
||||
self._stt_enabled: bool = False
|
||||
@@ -148,6 +152,8 @@ class SlackBot(ChatCommandMixin):
|
||||
|
||||
bot_message = self._convert_event_to_message(event)
|
||||
|
||||
self.fire_message_received(bot_message)
|
||||
|
||||
if not self.config.is_user_allowed(bot_message.sender.user_id if bot_message.sender else ""):
|
||||
return
|
||||
if not self.config.is_channel_allowed(bot_message.channel.channel_id if bot_message.channel else ""):
|
||||
@@ -191,9 +197,16 @@ class SlackBot(ChatCommandMixin):
|
||||
try:
|
||||
user_id = event.get("user", "unknown")
|
||||
logger.info(f"Message received: {text[:100]}...")
|
||||
text = await self._debouncer.debounce(user_id, text)
|
||||
response = await self._session.chat(self._agent, user_id, text)
|
||||
logger.info(f"Response sent: {response[:100]}...")
|
||||
|
||||
channel_id = event.get("channel", "")
|
||||
send_result = self.fire_message_sending(channel_id, str(response))
|
||||
if send_result["cancel"]:
|
||||
return
|
||||
response = send_result["content"]
|
||||
|
||||
# Determine if we should reply in thread
|
||||
thread_ts = None
|
||||
if self.config.reply_in_thread:
|
||||
@@ -203,8 +216,9 @@ class SlackBot(ChatCommandMixin):
|
||||
thread_ts = event.get("thread_ts") or event.get("ts")
|
||||
|
||||
await self._send_response_with_media(
|
||||
event.get("channel"), say, response, thread_ts=thread_ts
|
||||
channel_id, say, response, thread_ts=thread_ts
|
||||
)
|
||||
self.fire_message_sent(channel_id, response)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
error_msg = str(e)
|
||||
@@ -285,6 +299,7 @@ class SlackBot(ChatCommandMixin):
|
||||
return
|
||||
|
||||
self._is_running = False
|
||||
self._debouncer.cancel_all()
|
||||
logger.info("Slack bot stopped")
|
||||
|
||||
def set_agent(self, agent: "Agent") -> None:
|
||||
@@ -322,7 +337,9 @@ class SlackBot(ChatCommandMixin):
|
||||
)
|
||||
|
||||
async def _send_long_message(self, say, text: str, thread_ts: Optional[str] = None) -> None:
|
||||
"""Send a long message, splitting if necessary."""
|
||||
"""Send a long message, splitting with markdown-aware chunking."""
|
||||
from ._chunk import chunk_message
|
||||
|
||||
max_len = min(self.config.max_message_length, 4000)
|
||||
|
||||
if not text or not text.strip():
|
||||
@@ -332,7 +349,7 @@ class SlackBot(ChatCommandMixin):
|
||||
if len(text) <= max_len:
|
||||
await say(text=text.strip(), thread_ts=thread_ts)
|
||||
else:
|
||||
chunks = [text[i:i+max_len] for i in range(0, len(text), max_len)]
|
||||
chunks = chunk_message(text, max_length=max_len, preserve_fences=True)
|
||||
for chunk in chunks:
|
||||
if chunk and chunk.strip():
|
||||
await say(text=chunk.strip(), thread_ts=thread_ts)
|
||||
@@ -466,6 +483,42 @@ class SlackBot(ChatCommandMixin):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def probe(self):
|
||||
"""Test Slack API connectivity without starting the bot."""
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
started = time.time()
|
||||
try:
|
||||
import aiohttp
|
||||
url = "https://slack.com/api/auth.test"
|
||||
headers = {"Authorization": f"Bearer {self._token}"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
data = await resp.json()
|
||||
elapsed = (time.time() - started) * 1000
|
||||
if data.get("ok"):
|
||||
return ProbeResult(
|
||||
ok=True, platform="slack", elapsed_ms=elapsed,
|
||||
bot_username=data.get("user"),
|
||||
details={"team": data.get("team"), "team_id": data.get("team_id"), "user_id": data.get("user_id")},
|
||||
)
|
||||
else:
|
||||
return ProbeResult(ok=False, platform="slack", elapsed_ms=elapsed, error=data.get("error", "Unknown error"))
|
||||
except Exception as e:
|
||||
return ProbeResult(ok=False, platform="slack", elapsed_ms=(time.time() - started) * 1000, error=str(e))
|
||||
|
||||
async def health(self):
|
||||
"""Get detailed health status of the Slack bot."""
|
||||
from praisonaiagents.bots import HealthResult
|
||||
probe_result = await self.probe()
|
||||
uptime = (time.time() - self._started_at) if self._started_at else None
|
||||
session_count = len(self._session._histories) if hasattr(self._session, '_histories') else 0
|
||||
return HealthResult(
|
||||
ok=self._is_running and probe_result.ok, platform="slack",
|
||||
is_running=self._is_running, uptime_seconds=uptime,
|
||||
probe=probe_result, sessions=session_count,
|
||||
error=probe_result.error if not probe_result.ok else None,
|
||||
)
|
||||
|
||||
def _format_status(self) -> str:
|
||||
"""Format /status response."""
|
||||
return format_status(self._agent, self.platform, self._started_at, self._is_running)
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
|
||||
from praisonaiagents.bots import (
|
||||
BotConfig,
|
||||
BotMessage,
|
||||
@@ -29,11 +29,13 @@ from praisonaiagents.bots import (
|
||||
from .media import split_media_from_output, is_audio_file
|
||||
from ._commands import format_status, format_help
|
||||
from ._session import BotSessionManager
|
||||
from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramBot(ChatCommandMixin):
|
||||
class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""Telegram bot runtime for PraisonAI agents.
|
||||
|
||||
Connects an agent to Telegram, handling messages, commands,
|
||||
@@ -80,10 +82,21 @@ class TelegramBot(ChatCommandMixin):
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._session: BotSessionManager = BotSessionManager()
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
)
|
||||
|
||||
# Audio capabilities (set by BotCapabilities)
|
||||
self._stt_enabled: bool = False
|
||||
self._auto_tts: bool = False
|
||||
|
||||
# Resilience
|
||||
self._monitor = None # Lazy: ConnectionMonitor
|
||||
self._stop_event: Optional[asyncio.Event] = None
|
||||
|
||||
def enable_stt(self, enabled: bool = True) -> None:
|
||||
"""Enable STT for voice message transcription."""
|
||||
@@ -153,6 +166,8 @@ class TelegramBot(ChatCommandMixin):
|
||||
|
||||
message = self._convert_update_to_message(update, override_text=message_text)
|
||||
|
||||
self.fire_message_received(message)
|
||||
|
||||
if not self.config.is_user_allowed(message.sender.user_id if message.sender else ""):
|
||||
return
|
||||
if not self.config.is_channel_allowed(message.channel.channel_id if message.channel else ""):
|
||||
@@ -171,14 +186,51 @@ class TelegramBot(ChatCommandMixin):
|
||||
if self.config.typing_indicator:
|
||||
await update.message.chat.send_action("typing")
|
||||
|
||||
# Ack reaction
|
||||
ack_ctx = None
|
||||
if self._ack.enabled:
|
||||
async def _tg_react(emoji, **kw):
|
||||
try:
|
||||
from telegram import ReactionTypeEmoji
|
||||
await self._application.bot.set_message_reaction(
|
||||
chat_id=update.message.chat_id,
|
||||
message_id=update.message.message_id,
|
||||
reaction=[ReactionTypeEmoji(emoji=emoji)],
|
||||
)
|
||||
except Exception:
|
||||
pass # Reactions may not be supported in all chats
|
||||
async def _tg_unreact(emoji, **kw):
|
||||
try:
|
||||
await self._application.bot.set_message_reaction(
|
||||
chat_id=update.message.chat_id,
|
||||
message_id=update.message.message_id,
|
||||
reaction=[],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
ack_ctx = await self._ack.ack(react_fn=_tg_react)
|
||||
|
||||
user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
|
||||
try:
|
||||
message_text = await self._debouncer.debounce(user_id, message_text)
|
||||
response = await self._session.chat(self._agent, user_id, message_text)
|
||||
send_result = self.fire_message_sending(
|
||||
str(update.message.chat_id), str(response),
|
||||
reply_to=str(update.message.message_id),
|
||||
)
|
||||
if send_result["cancel"]:
|
||||
return
|
||||
await self._send_response_with_media(
|
||||
update.message.chat_id,
|
||||
response,
|
||||
send_result["content"],
|
||||
reply_to=update.message.message_id,
|
||||
)
|
||||
self.fire_message_sent(
|
||||
str(update.message.chat_id), send_result["content"],
|
||||
)
|
||||
# Done reaction
|
||||
if ack_ctx:
|
||||
await self._ack.done(ack_ctx, react_fn=_tg_react, unreact_fn=_tg_unreact)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
await update.message.reply_text(f"Error: {str(e)}")
|
||||
@@ -240,6 +292,10 @@ class TelegramBot(ChatCommandMixin):
|
||||
self._is_running = True
|
||||
logger.info(f"Telegram bot started: @{self._bot_user.username}")
|
||||
|
||||
# Initialize connection monitor for resilience
|
||||
from ._resilience import ConnectionMonitor, TELEGRAM_BACKOFF, is_recoverable_error, is_conflict_error, sleep_with_abort
|
||||
self._monitor = ConnectionMonitor(platform="telegram", policy=TELEGRAM_BACKOFF)
|
||||
|
||||
if self.config.is_webhook_mode:
|
||||
await self._application.run_webhook(
|
||||
listen="0.0.0.0",
|
||||
@@ -248,32 +304,66 @@ class TelegramBot(ChatCommandMixin):
|
||||
webhook_url=f"{self.config.webhook_url}{self.config.webhook_path}",
|
||||
)
|
||||
else:
|
||||
# Use low-level API to avoid event-loop conflicts with run_polling().
|
||||
# run_polling() tries to manage its own event loop lifecycle which
|
||||
# conflicts with asyncio.run() or any shared event loop (e.g. gateway).
|
||||
await self._application.initialize()
|
||||
await self._application.start()
|
||||
await self._application.updater.start_polling(
|
||||
poll_interval=self.config.polling_interval,
|
||||
)
|
||||
|
||||
# Block until stop() is called
|
||||
# Resilient polling loop with exponential backoff
|
||||
self._stop_event = asyncio.Event()
|
||||
try:
|
||||
await self._stop_event.wait()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
await self._application.updater.stop()
|
||||
await self._application.stop()
|
||||
await self._application.shutdown()
|
||||
self._is_running = False
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
await self._application.initialize()
|
||||
await self._application.start()
|
||||
await self._application.updater.start_polling(
|
||||
poll_interval=self.config.polling_interval,
|
||||
)
|
||||
self._monitor.record_success()
|
||||
|
||||
# Block until stop() is called
|
||||
await self._stop_event.wait()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
if is_conflict_error(e):
|
||||
logger.error(
|
||||
"[telegram] Another bot instance is running with this token. "
|
||||
"Stop the other instance first."
|
||||
)
|
||||
break # Don't retry conflicts
|
||||
|
||||
if is_recoverable_error(e, "telegram") and self._monitor.should_retry():
|
||||
delay = self._monitor.record_error(e)
|
||||
completed = await sleep_with_abort(delay, self._stop_event)
|
||||
if not completed:
|
||||
break # Abort signaled during sleep
|
||||
# Attempt reconnect
|
||||
try:
|
||||
await self._application.updater.stop()
|
||||
await self._application.stop()
|
||||
await self._application.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
# Rebuild application for clean reconnect
|
||||
from telegram.ext import Application
|
||||
self._application = Application.builder().token(self._token).build()
|
||||
continue
|
||||
else:
|
||||
logger.error(f"[telegram] Unrecoverable error: {e}")
|
||||
break
|
||||
finally:
|
||||
try:
|
||||
await self._application.updater.stop()
|
||||
await self._application.stop()
|
||||
await self._application.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self._is_running = False
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Telegram bot."""
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
# Cancel pending debounce timers
|
||||
self._debouncer.cancel_all()
|
||||
|
||||
# Signal the stop event so the start() loop exits cleanly
|
||||
if hasattr(self, '_stop_event') and self._stop_event:
|
||||
self._stop_event.set()
|
||||
@@ -331,16 +421,18 @@ class TelegramBot(ChatCommandMixin):
|
||||
text: str,
|
||||
reply_to: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send a long message, splitting if necessary."""
|
||||
"""Send a long message, splitting with markdown-aware chunking."""
|
||||
from ._chunk import chunk_message
|
||||
|
||||
max_len = self.config.max_message_length
|
||||
|
||||
|
||||
if len(text) <= max_len:
|
||||
kwargs = {"chat_id": chat_id, "text": text}
|
||||
if reply_to:
|
||||
kwargs["reply_to_message_id"] = reply_to
|
||||
await self._application.bot.send_message(**kwargs)
|
||||
else:
|
||||
chunks = [text[i:i+max_len] for i in range(0, len(text), max_len)]
|
||||
chunks = chunk_message(text, max_length=max_len, preserve_fences=True)
|
||||
for i, chunk in enumerate(chunks):
|
||||
kwargs = {"chat_id": chat_id, "text": chunk}
|
||||
if i == 0 and reply_to:
|
||||
@@ -519,6 +611,63 @@ class TelegramBot(ChatCommandMixin):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def probe(self):
|
||||
"""Test Telegram API connectivity without starting the bot."""
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
started = time.time()
|
||||
try:
|
||||
import aiohttp
|
||||
url = f"https://api.telegram.org/bot{self._token}/getMe"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
data = await resp.json()
|
||||
elapsed = (time.time() - started) * 1000
|
||||
if resp.status == 200 and data.get("ok"):
|
||||
result = data.get("result", {})
|
||||
return ProbeResult(
|
||||
ok=True,
|
||||
platform="telegram",
|
||||
elapsed_ms=elapsed,
|
||||
bot_username=result.get("username"),
|
||||
details={
|
||||
"bot_id": result.get("id"),
|
||||
"can_join_groups": result.get("can_join_groups"),
|
||||
"can_read_all_group_messages": result.get("can_read_all_group_messages"),
|
||||
"supports_inline_queries": result.get("supports_inline_queries"),
|
||||
},
|
||||
)
|
||||
else:
|
||||
return ProbeResult(
|
||||
ok=False,
|
||||
platform="telegram",
|
||||
elapsed_ms=elapsed,
|
||||
error=data.get("description", f"HTTP {resp.status}"),
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = (time.time() - started) * 1000
|
||||
return ProbeResult(
|
||||
ok=False,
|
||||
platform="telegram",
|
||||
elapsed_ms=elapsed,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def health(self):
|
||||
"""Get detailed health status of the Telegram bot."""
|
||||
from praisonaiagents.bots import HealthResult
|
||||
probe_result = await self.probe()
|
||||
uptime = (time.time() - self._started_at) if self._started_at else None
|
||||
session_count = len(self._session._histories) if hasattr(self._session, '_histories') else 0
|
||||
return HealthResult(
|
||||
ok=self._is_running and probe_result.ok,
|
||||
platform="telegram",
|
||||
is_running=self._is_running,
|
||||
uptime_seconds=uptime,
|
||||
probe=probe_result,
|
||||
sessions=session_count,
|
||||
error=probe_result.error if not probe_result.ok else None,
|
||||
)
|
||||
|
||||
def _format_status(self) -> str:
|
||||
"""Format /status response."""
|
||||
return format_status(self._agent, self.platform, self._started_at, self._is_running)
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
"""
|
||||
WhatsApp Bot implementation for PraisonAI.
|
||||
|
||||
Provides a full WhatsApp bot runtime using the WhatsApp Cloud API (Meta Graph API).
|
||||
Receives messages via an aiohttp webhook server and replies via REST API.
|
||||
Supports two connection modes:
|
||||
|
||||
Requirements:
|
||||
- aiohttp (already a core dependency)
|
||||
- WHATSAPP_ACCESS_TOKEN: Meta Cloud API access token
|
||||
- WHATSAPP_PHONE_NUMBER_ID: Phone number ID from Meta
|
||||
- WHATSAPP_VERIFY_TOKEN: Custom string for webhook verification
|
||||
**Cloud API mode** (default, stable, recommended):
|
||||
- Uses Meta Graph API with webhook server
|
||||
- Requires: WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_VERIFY_TOKEN
|
||||
|
||||
**Web mode** (experimental, token-free):
|
||||
- Uses neonize (whatsmeow) for WhatsApp Web protocol
|
||||
- QR code scan via Linked Devices — no Meta developer account needed
|
||||
- ⚠️ Uses reverse-engineered protocol; your number may be banned
|
||||
|
||||
Usage:
|
||||
# Cloud API (default)
|
||||
bot = WhatsAppBot(token="EAAx...", phone_number_id="123", agent=agent)
|
||||
|
||||
# Web mode (no tokens needed)
|
||||
bot = WhatsAppBot(mode="web", agent=agent)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +34,7 @@ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
|
||||
from praisonaiagents.bots import (
|
||||
BotConfig,
|
||||
BotMessage,
|
||||
@@ -44,7 +53,7 @@ GRAPH_API_VERSION = "v21.0"
|
||||
GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}"
|
||||
|
||||
|
||||
class WhatsAppBot(ChatCommandMixin):
|
||||
class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""WhatsApp bot runtime for PraisonAI agents.
|
||||
|
||||
Connects an agent to WhatsApp via the Cloud API, handling messages,
|
||||
@@ -67,7 +76,7 @@ class WhatsAppBot(ChatCommandMixin):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
token: str = "",
|
||||
phone_number_id: str = "",
|
||||
agent: Optional["Agent"] = None,
|
||||
config: Optional[BotConfig] = None,
|
||||
@@ -75,15 +84,43 @@ class WhatsAppBot(ChatCommandMixin):
|
||||
app_secret: str = "",
|
||||
webhook_port: int = 8080,
|
||||
webhook_path: str = "/webhook",
|
||||
mode: str = "cloud",
|
||||
creds_dir: Optional[str] = None,
|
||||
allowed_numbers: Optional[List[str]] = None,
|
||||
allowed_groups: Optional[List[str]] = None,
|
||||
respond_to_all: bool = False,
|
||||
):
|
||||
self._token = token
|
||||
# Mode: "cloud" (default, Meta Cloud API) or "web" (neonize, experimental)
|
||||
self._mode = mode.lower().strip()
|
||||
if self._mode not in ("cloud", "web"):
|
||||
raise ValueError(f"Invalid mode '{self._mode}'. Use 'cloud' or 'web'.")
|
||||
|
||||
self._token = token or os.environ.get("WHATSAPP_ACCESS_TOKEN", "")
|
||||
self._phone_number_id = phone_number_id or os.environ.get("WHATSAPP_PHONE_NUMBER_ID", "")
|
||||
self._agent = agent
|
||||
self.config = config or BotConfig(token=token)
|
||||
self.config = config or BotConfig(token=self._token)
|
||||
self._verify_token = verify_token or os.environ.get("WHATSAPP_VERIFY_TOKEN", "")
|
||||
self._app_secret = app_secret or os.environ.get("WHATSAPP_APP_SECRET", "")
|
||||
self._webhook_port = webhook_port
|
||||
self._webhook_path = webhook_path
|
||||
self._creds_dir = creds_dir
|
||||
|
||||
# Message filtering (Web mode).
|
||||
# Default: respond only to self-messages (IsFromMe).
|
||||
# --respond-to-all opens to everyone.
|
||||
# --respond-to / --respond-to-groups whitelist specific senders.
|
||||
self._respond_to_all = respond_to_all
|
||||
self._allowed_numbers: set[str] = set()
|
||||
self._allowed_groups: set[str] = set()
|
||||
if allowed_numbers:
|
||||
for n in allowed_numbers:
|
||||
# Normalise: strip +, keep digits only
|
||||
digits = "".join(c for c in n if c.isdigit())
|
||||
if digits:
|
||||
self._allowed_numbers.add(digits)
|
||||
if allowed_groups:
|
||||
for g in allowed_groups:
|
||||
self._allowed_groups.add(g.strip())
|
||||
|
||||
self._is_running = False
|
||||
self._started_at: Optional[float] = None
|
||||
@@ -93,6 +130,9 @@ class WhatsAppBot(ChatCommandMixin):
|
||||
self._runner: Any = None
|
||||
self._site: Any = None
|
||||
|
||||
# Web mode adapter (lazy initialized)
|
||||
self._web_adapter: Any = None
|
||||
|
||||
# ChatCommandMixin setup
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._command_info: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -133,6 +173,11 @@ class WhatsAppBot(ChatCommandMixin):
|
||||
def platform(self) -> str:
|
||||
return "whatsapp"
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
"""Connection mode: 'cloud' (Meta Cloud API) or 'web' (neonize)."""
|
||||
return self._mode
|
||||
|
||||
@property
|
||||
def bot_user(self) -> Optional[BotUser]:
|
||||
return self._bot_user
|
||||
@@ -162,7 +207,14 @@ class WhatsAppBot(ChatCommandMixin):
|
||||
# ── Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the webhook server to receive WhatsApp messages."""
|
||||
"""Start the bot in the configured mode (cloud or web)."""
|
||||
if self._mode == "web":
|
||||
await self._start_web_mode()
|
||||
else:
|
||||
await self._start_cloud_mode()
|
||||
|
||||
async def _start_cloud_mode(self) -> None:
|
||||
"""Start the Cloud API webhook server."""
|
||||
try:
|
||||
from aiohttp import web
|
||||
except ImportError:
|
||||
@@ -200,13 +252,242 @@ class WhatsAppBot(ChatCommandMixin):
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def _start_web_mode(self) -> None:
|
||||
"""Start in Web mode using neonize (WhatsApp Web protocol).
|
||||
|
||||
⚠️ EXPERIMENTAL: Uses reverse-engineered protocol.
|
||||
"""
|
||||
from ._whatsapp_web_adapter import WhatsAppWebAdapter
|
||||
|
||||
async def _on_web_message(event: Any) -> None:
|
||||
"""Bridge neonize message events to bot message handling."""
|
||||
try:
|
||||
# Extract message info from neonize event
|
||||
info = getattr(event, 'Info', None) or getattr(event, 'info', None)
|
||||
msg = getattr(event, 'Message', None) or getattr(event, 'message', None)
|
||||
if not info or not msg:
|
||||
return
|
||||
|
||||
# Extract message source fields
|
||||
msg_source = getattr(info, 'MessageSource', None) or info
|
||||
sender_jid = str(getattr(msg_source, 'Sender', '') or getattr(info, 'sender', ''))
|
||||
# Keep the original neonize JID protobuf for Chat — this
|
||||
# preserves LID routing info needed by send_message.
|
||||
chat_jid_obj = getattr(msg_source, 'Chat', None) or getattr(info, 'chat', None)
|
||||
chat_jid = str(chat_jid_obj) if chat_jid_obj else ''
|
||||
msg_id = str(getattr(info, 'ID', '') or getattr(info, 'id', ''))
|
||||
raw_ts = getattr(info, 'Timestamp', None)
|
||||
if hasattr(raw_ts, 'timestamp'):
|
||||
timestamp = raw_ts.timestamp()
|
||||
elif isinstance(raw_ts, (int, float)) and raw_ts > 0:
|
||||
# neonize/Go encodes timestamps as milliseconds (UnixMilli).
|
||||
# Detect: any value > 1e12 is milliseconds, convert to seconds.
|
||||
timestamp = raw_ts / 1000.0 if raw_ts > 1e12 else float(raw_ts)
|
||||
else:
|
||||
timestamp = time.time()
|
||||
|
||||
# ── Stale-message guard ────────────────────────────────
|
||||
# whatsmeow delivers offline/historical messages as regular
|
||||
# MessageEv upon reconnection. Drop anything older than
|
||||
# the moment the adapter actually connected so the bot
|
||||
# never replies to old messages.
|
||||
connected_at = (
|
||||
self._web_adapter.connected_at
|
||||
if self._web_adapter else self._started_at
|
||||
) or self._started_at or 0
|
||||
if timestamp and connected_at and timestamp < connected_at:
|
||||
logger.debug(
|
||||
"Skipping stale message %s (ts=%.1f < connected=%.1f)",
|
||||
msg_id, timestamp, connected_at,
|
||||
)
|
||||
return
|
||||
|
||||
# ── Message filtering ────────────────────────────────
|
||||
# Default: self-chat only (user messaging their own number).
|
||||
# Expand via allowed_numbers / groups / respond_to_all.
|
||||
is_from_me = bool(getattr(msg_source, 'IsFromMe', False))
|
||||
is_group = bool(getattr(msg_source, 'IsGroup', False)) or "@g.us" in chat_jid
|
||||
|
||||
# Determine if this is a true self-chat (user→own number).
|
||||
# IsFromMe is True for ANY message the user sent in any chat,
|
||||
# but self-messaging means sender and chat are the same JID.
|
||||
def _jid_user(jid_str: str) -> str:
|
||||
"""Extract bare user part from a JID string."""
|
||||
return jid_str.split("@")[0].split(":")[0] if jid_str else ""
|
||||
|
||||
is_self_chat = (
|
||||
is_from_me
|
||||
and not is_group
|
||||
and _jid_user(sender_jid) == _jid_user(chat_jid)
|
||||
)
|
||||
|
||||
if not self._respond_to_all:
|
||||
if is_self_chat:
|
||||
pass # true self-chat — always allow
|
||||
elif is_group and self._allowed_groups:
|
||||
# Check if group JID is in the allowlist
|
||||
chat_jid_str = chat_jid.split("@")[0] if "@" in chat_jid else chat_jid
|
||||
if not (chat_jid in self._allowed_groups
|
||||
or chat_jid_str in self._allowed_groups):
|
||||
logger.debug("Filtered: group %s not in allowlist", chat_jid)
|
||||
return
|
||||
elif not is_group and self._allowed_numbers:
|
||||
# Check if sender number is in the allowlist
|
||||
sender_num = _jid_user(sender_jid)
|
||||
if sender_num not in self._allowed_numbers:
|
||||
logger.debug("Filtered: sender %s not in allowlist", sender_num)
|
||||
return
|
||||
else:
|
||||
# Not self-chat, not in any allowlist → ignore
|
||||
logger.debug(
|
||||
"Filtered: msg %s (from_me=%s, self_chat=%s, group=%s)",
|
||||
msg_id, is_from_me, is_self_chat, is_group,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract text content
|
||||
content = ""
|
||||
conversation = getattr(msg, 'conversation', None)
|
||||
ext_text = getattr(msg, 'extendedTextMessage', None)
|
||||
if conversation:
|
||||
content = str(conversation)
|
||||
elif ext_text:
|
||||
content = str(getattr(ext_text, 'text', ''))
|
||||
else:
|
||||
content = "[Non-text message received]"
|
||||
|
||||
if not content:
|
||||
return
|
||||
|
||||
# Determine if group
|
||||
is_group = "@g.us" in chat_jid
|
||||
sender_name = sender_jid.split("@")[0] if sender_jid else "unknown"
|
||||
|
||||
sender = BotUser(
|
||||
user_id=sender_jid,
|
||||
username=sender_name,
|
||||
display_name=sender_name,
|
||||
is_bot=False,
|
||||
)
|
||||
channel = BotChannel(
|
||||
channel_id=chat_jid,
|
||||
name=f"whatsapp:{chat_jid}",
|
||||
channel_type="group" if is_group else "dm",
|
||||
)
|
||||
|
||||
bot_message = BotMessage(
|
||||
message_id=msg_id,
|
||||
content=content,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=sender,
|
||||
channel=channel,
|
||||
timestamp=float(timestamp) if timestamp else time.time(),
|
||||
)
|
||||
|
||||
self.fire_message_received(bot_message)
|
||||
|
||||
# Fire registered message handlers
|
||||
for handler in self._message_handlers:
|
||||
try:
|
||||
await handler(bot_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Message handler error: {e}")
|
||||
|
||||
# Check for commands
|
||||
text = content.strip()
|
||||
if text.startswith("/"):
|
||||
cmd_name = text.split()[0][1:].lower()
|
||||
cmd_handler = self._command_handlers.get(cmd_name)
|
||||
if cmd_handler:
|
||||
try:
|
||||
response = await cmd_handler(bot_message)
|
||||
if response:
|
||||
await self._web_send(chat_jid_obj or chat_jid, response)
|
||||
except Exception as e:
|
||||
logger.error(f"Command '{cmd_name}' error: {e}")
|
||||
await self._web_send(chat_jid, f"Error: {e}")
|
||||
return
|
||||
|
||||
# Route to agent
|
||||
if self._agent and content:
|
||||
try:
|
||||
response = await self._session_mgr.chat(
|
||||
self._agent, sender_jid, content
|
||||
)
|
||||
if response:
|
||||
send_result = self.fire_message_sending(chat_jid, str(response))
|
||||
if not send_result["cancel"]:
|
||||
await self._web_send(chat_jid_obj or chat_jid, send_result["content"])
|
||||
self.fire_message_sent(chat_jid, send_result["content"])
|
||||
except Exception as e:
|
||||
logger.error(f"Agent chat error: {e}")
|
||||
await self._web_send(chat_jid, "Sorry, I encountered an error.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Web mode message processing error: {e}")
|
||||
|
||||
async def _on_connected() -> None:
|
||||
logger.info("WhatsApp Web: connected and ready")
|
||||
|
||||
self._web_adapter = WhatsAppWebAdapter(
|
||||
creds_dir=self._creds_dir,
|
||||
on_message=_on_web_message,
|
||||
on_connected=_on_connected,
|
||||
)
|
||||
|
||||
self._is_running = True
|
||||
self._started_at = time.time()
|
||||
self._bot_user = BotUser(
|
||||
user_id="web_user",
|
||||
username="whatsapp_web_bot",
|
||||
display_name="WhatsApp Web Bot",
|
||||
is_bot=True,
|
||||
)
|
||||
|
||||
has_session = self._web_adapter.has_saved_session()
|
||||
if has_session:
|
||||
logger.info("WhatsApp Web: using saved session")
|
||||
else:
|
||||
logger.info("WhatsApp Web: no saved session — QR code will be displayed")
|
||||
|
||||
try:
|
||||
await self._web_adapter.connect()
|
||||
# Keep running
|
||||
while self._is_running:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp Web mode error: {e}")
|
||||
raise
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def _web_send(self, to: Any, text: str) -> None:
|
||||
"""Send message via Web mode adapter.
|
||||
|
||||
Args:
|
||||
to: Recipient — a native neonize JID protobuf (preferred) or
|
||||
a JID string as fallback.
|
||||
text: Message text.
|
||||
"""
|
||||
if self._web_adapter and self._web_adapter.is_connected:
|
||||
await self._web_adapter.send_message(to, text)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the webhook server."""
|
||||
"""Stop the bot (both Cloud and Web modes)."""
|
||||
self._is_running = False
|
||||
# Cloud mode cleanup
|
||||
if self._site:
|
||||
await self._site.stop()
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
# Web mode cleanup
|
||||
if self._web_adapter:
|
||||
try:
|
||||
await self._web_adapter.disconnect()
|
||||
except Exception as e:
|
||||
logger.warning(f"Web adapter disconnect error: {e}")
|
||||
logger.info("WhatsApp bot stopped")
|
||||
|
||||
# ── Webhook handlers ────────────────────────────────────────────
|
||||
@@ -355,6 +636,8 @@ class WhatsAppBot(ChatCommandMixin):
|
||||
timestamp=float(timestamp) if timestamp else time.time(),
|
||||
)
|
||||
|
||||
self.fire_message_received(bot_message)
|
||||
|
||||
# Fire registered message handlers (e.g., gateway routing)
|
||||
for handler in self._message_handlers:
|
||||
try:
|
||||
@@ -384,7 +667,10 @@ class WhatsAppBot(ChatCommandMixin):
|
||||
self._agent, sender_id, content
|
||||
)
|
||||
if response:
|
||||
await self.send_message(sender_id, str(response))
|
||||
send_result = self.fire_message_sending(sender_id, str(response))
|
||||
if not send_result["cancel"]:
|
||||
await self.send_message(sender_id, send_result["content"])
|
||||
self.fire_message_sent(sender_id, send_result["content"])
|
||||
except Exception as e:
|
||||
logger.error(f"Agent chat error: {e}")
|
||||
await self.send_message(sender_id, "Sorry, I encountered an error processing your message.")
|
||||
@@ -567,3 +853,42 @@ class WhatsAppBot(ChatCommandMixin):
|
||||
async def get_channel(self, channel_id: str) -> Optional[BotChannel]:
|
||||
"""Get channel info (WhatsApp is DM-only)."""
|
||||
return BotChannel(channel_id=channel_id, channel_type="dm")
|
||||
|
||||
async def probe(self):
|
||||
"""Test WhatsApp Cloud API connectivity."""
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
started = time.time()
|
||||
if self._mode == "web":
|
||||
return ProbeResult(ok=True, platform="whatsapp", elapsed_ms=0.0, details={"mode": "web"})
|
||||
try:
|
||||
import aiohttp
|
||||
url = f"{GRAPH_API_BASE}/{self._phone_number_id}"
|
||||
headers = {"Authorization": f"Bearer {self._token}"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
elapsed = (time.time() - started) * 1000
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return ProbeResult(
|
||||
ok=True, platform="whatsapp", elapsed_ms=elapsed,
|
||||
bot_username=data.get("display_phone_number", self._phone_number_id),
|
||||
details={"phone_number_id": self._phone_number_id, "verified_name": data.get("verified_name")},
|
||||
)
|
||||
else:
|
||||
text = await resp.text()
|
||||
return ProbeResult(ok=False, platform="whatsapp", elapsed_ms=elapsed, error=f"HTTP {resp.status}: {text[:200]}")
|
||||
except Exception as e:
|
||||
return ProbeResult(ok=False, platform="whatsapp", elapsed_ms=(time.time() - started) * 1000, error=str(e))
|
||||
|
||||
async def health(self):
|
||||
"""Get detailed health status of the WhatsApp bot."""
|
||||
from praisonaiagents.bots import HealthResult
|
||||
probe_result = await self.probe()
|
||||
uptime = (time.time() - self._started_at) if self._started_at else None
|
||||
session_count = len(self._session._histories) if hasattr(self._session, '_histories') else 0
|
||||
return HealthResult(
|
||||
ok=self._is_running and probe_result.ok, platform="whatsapp",
|
||||
is_running=self._is_running, uptime_seconds=uptime,
|
||||
probe=probe_result, sessions=session_count,
|
||||
error=probe_result.error if not probe_result.ok else None,
|
||||
)
|
||||
@@ -176,6 +176,7 @@ def register_commands():
|
||||
from .commands.lsp import app as lsp_app
|
||||
from .commands.diag import app as diag_app
|
||||
from .commands.doctor import app as doctor_app
|
||||
from .commands.obs import app as obs_app
|
||||
from .commands.acp import app as acp_app
|
||||
from .commands.mcp import app as mcp_app
|
||||
from .commands.serve import app as serve_app
|
||||
@@ -241,6 +242,7 @@ def register_commands():
|
||||
app.add_typer(lsp_app, name="lsp", help="LSP service lifecycle")
|
||||
app.add_typer(diag_app, name="diag", help="Diagnostics export")
|
||||
app.add_typer(doctor_app, name="doctor", help="Health checks and diagnostics")
|
||||
app.add_typer(obs_app, name="obs", help="Observability diagnostics and management")
|
||||
app.add_typer(acp_app, name="acp", help="Agent Client Protocol server")
|
||||
app.add_typer(mcp_app, name="mcp", help="MCP server management")
|
||||
app.add_typer(serve_app, name="serve", help="API server management")
|
||||
|
||||
@@ -254,10 +254,12 @@ def bot_slack(
|
||||
|
||||
@app.command("whatsapp")
|
||||
def bot_whatsapp(
|
||||
token: Optional[str] = typer.Option(None, "--token", "-t", help="WhatsApp access token", envvar="WHATSAPP_ACCESS_TOKEN"),
|
||||
phone_id: Optional[str] = typer.Option(None, "--phone-id", help="WhatsApp phone number ID", envvar="WHATSAPP_PHONE_NUMBER_ID"),
|
||||
verify_token: Optional[str] = typer.Option(None, "--verify-token", help="Webhook verification token", envvar="WHATSAPP_VERIFY_TOKEN"),
|
||||
port: int = typer.Option(8080, "--port", "-p", help="Webhook server port"),
|
||||
token: Optional[str] = typer.Option(None, "--token", "-t", help="WhatsApp access token (Cloud mode)", envvar="WHATSAPP_ACCESS_TOKEN"),
|
||||
phone_id: Optional[str] = typer.Option(None, "--phone-id", help="WhatsApp phone number ID (Cloud mode)", envvar="WHATSAPP_PHONE_NUMBER_ID"),
|
||||
verify_token: Optional[str] = typer.Option(None, "--verify-token", help="Webhook verification token (Cloud mode)", envvar="WHATSAPP_VERIFY_TOKEN"),
|
||||
port: int = typer.Option(8080, "--port", "-p", help="Webhook server port (Cloud mode)"),
|
||||
mode: str = typer.Option("cloud", "--mode", help="Connection mode: 'cloud' (Meta API) or 'web' (QR scan, experimental)"),
|
||||
creds_dir: Optional[str] = typer.Option(None, "--creds-dir", help="Credentials directory for Web mode"),
|
||||
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
browser: bool = typer.Option(False, "--browser", help="Enable browser control"),
|
||||
@@ -287,13 +289,14 @@ def bot_whatsapp(
|
||||
):
|
||||
"""Start a WhatsApp bot with full agent capabilities.
|
||||
|
||||
Runs a webhook server that receives messages from WhatsApp Cloud API.
|
||||
Requires a publicly accessible HTTPS URL (use ngrok for local dev).
|
||||
Cloud mode (default): Webhook server for Meta Cloud API.
|
||||
Web mode (experimental): QR code scan, no tokens needed.
|
||||
|
||||
Examples:
|
||||
praisonai bot whatsapp --token $WHATSAPP_ACCESS_TOKEN --phone-id $WHATSAPP_PHONE_NUMBER_ID
|
||||
praisonai bot whatsapp --mode web
|
||||
praisonai bot whatsapp --mode web --creds-dir ~/.myapp/wa-creds
|
||||
praisonai bot whatsapp --agent agents.yaml --memory --web
|
||||
praisonai bot whatsapp --port 8080 --verify-token my-secret
|
||||
"""
|
||||
from ..features.bots_cli import BotHandler, BotCapabilities
|
||||
|
||||
@@ -333,6 +336,8 @@ def bot_whatsapp(
|
||||
webhook_port=port,
|
||||
agent_file=agent,
|
||||
capabilities=capabilities,
|
||||
mode=mode,
|
||||
creds_dir=creds_dir,
|
||||
)
|
||||
|
||||
|
||||
@@ -349,7 +354,7 @@ Start a bot on any platform with: praisonai bot <platform>
|
||||
[green]telegram[/green] Telegram Bot API
|
||||
[green]discord[/green] Discord Bot API
|
||||
[green]slack[/green] Slack Socket Mode
|
||||
[green]whatsapp[/green] WhatsApp Cloud API (webhook)
|
||||
[green]whatsapp[/green] WhatsApp Cloud API or Web mode (QR scan)
|
||||
|
||||
[bold]Capability Options (all platforms):[/bold]
|
||||
[yellow]--agent FILE[/yellow] Agent YAML configuration
|
||||
@@ -360,6 +365,7 @@ Start a bot on any platform with: praisonai bot <platform>
|
||||
[yellow]--knowledge[/yellow] Enable knowledge/RAG
|
||||
[yellow]--tools TOOL...[/yellow] Enable specific tools
|
||||
[yellow]--sandbox[/yellow] Enable sandbox mode
|
||||
[yellow]--auto-approve[/yellow] Auto-approve all tool executions
|
||||
[yellow]--tts[/yellow] Enable TTS tool
|
||||
[yellow]--stt[/yellow] Enable STT tool
|
||||
[yellow]--auto-tts[/yellow] Auto-convert responses to speech
|
||||
@@ -372,6 +378,7 @@ Start a bot on any platform with: praisonai bot <platform>
|
||||
praisonai bot slack --agent agents.yaml --browser --web
|
||||
praisonai bot discord --tools DuckDuckGoTool --memory --model gpt-4o
|
||||
praisonai bot whatsapp --token $WHATSAPP_ACCESS_TOKEN --phone-id $WHATSAPP_PHONE_NUMBER_ID
|
||||
praisonai bot whatsapp --mode web
|
||||
"""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Obs command group for PraisonAI CLI.
|
||||
|
||||
Thin wrapper that re-exports the CLI from praisonai_tools.
|
||||
"""
|
||||
|
||||
try:
|
||||
from praisonai_tools.observability.cli import app
|
||||
except ImportError:
|
||||
import typer
|
||||
app = typer.Typer(help="Observability diagnostics and management")
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def obs_fallback(ctx: typer.Context):
|
||||
"""Observability diagnostics and management."""
|
||||
from rich.console import Console
|
||||
Console(stderr=True).print("[red]✗ praisonai-tools not installed.[/red]")
|
||||
Console(stderr=True).print("[dim]Install with: pip install praisonai-tools[/dim]")
|
||||
raise typer.Exit(1)
|
||||
@@ -101,6 +101,49 @@ def ui_realtime(
|
||||
_launch_chainlit_ui("realtime", port, host, public)
|
||||
|
||||
|
||||
@app.command("bot")
|
||||
def ui_bot(
|
||||
port: int = typer.Option(8090, "--port", "-p", help="Port to run UI on"),
|
||||
host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to"),
|
||||
public: bool = typer.Option(False, "--public", help="Make UI publicly accessible"),
|
||||
agent: str = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
|
||||
model: str = typer.Option("gpt-4o-mini", "--model", "-m", help="LLM model to use"),
|
||||
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
|
||||
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
|
||||
web_provider: str = typer.Option("duckduckgo", "--web-provider", help="Web search provider"),
|
||||
tools: str = typer.Option(None, "--tools", help="Comma-separated tool names"),
|
||||
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve tool executions"),
|
||||
):
|
||||
"""
|
||||
Start the bot chat UI with real-time streaming and tool visualization.
|
||||
|
||||
This provides a Chainlit-based chat interface backed by a PraisonAI Agent
|
||||
with real streaming (via StreamEventEmitter), tool call Steps, and hook firing.
|
||||
|
||||
Examples:
|
||||
praisonai ui bot
|
||||
praisonai ui bot --model gpt-4o --memory --web
|
||||
praisonai ui bot --agent agents.yaml --tools DuckDuckGoTool,WikipediaTool
|
||||
"""
|
||||
import os
|
||||
|
||||
# Pass config to bot.py via environment variables
|
||||
os.environ["PRAISONAI_BOT_MODEL"] = model
|
||||
if agent:
|
||||
os.environ["PRAISONAI_BOT_AGENT_FILE"] = agent
|
||||
if memory:
|
||||
os.environ["PRAISONAI_BOT_MEMORY"] = "true"
|
||||
if web_search:
|
||||
os.environ["PRAISONAI_BOT_WEB_SEARCH"] = "true"
|
||||
os.environ["PRAISONAI_BOT_WEB_PROVIDER"] = web_provider
|
||||
if tools:
|
||||
os.environ["PRAISONAI_BOT_TOOLS"] = tools
|
||||
if auto_approve:
|
||||
os.environ["PRAISONAI_BOT_AUTO_APPROVE"] = "true"
|
||||
|
||||
_launch_chainlit_ui("bot", port, host, public)
|
||||
|
||||
|
||||
@app.command("gradio")
|
||||
def ui_gradio(
|
||||
port: int = typer.Option(8080, "--port", "-p", help="Port to run UI on"),
|
||||
@@ -155,6 +198,7 @@ def _launch_chainlit_ui(ui_type: str, port: int, host: str, public: bool):
|
||||
"chat": os.path.join("ui", "chat.py"),
|
||||
"code": os.path.join("ui", "code.py"),
|
||||
"realtime": os.path.join("ui", "realtime.py"),
|
||||
"bot": os.path.join("ui", "bot.py"),
|
||||
}
|
||||
|
||||
ui_script = ui_scripts.get(ui_type, "chainlit_ui.py")
|
||||
|
||||
@@ -57,7 +57,7 @@ class BotCapabilities:
|
||||
# Execution
|
||||
sandbox: bool = False
|
||||
exec_enabled: bool = False
|
||||
auto_approve: bool = False # Auto-approve CLI tool execution
|
||||
auto_approve: bool = False
|
||||
|
||||
# Model settings
|
||||
model: Optional[str] = None
|
||||
@@ -242,6 +242,22 @@ class BotHandler:
|
||||
phone_number_id = resolve_env_vars(config.get("phone_number_id", ""))
|
||||
verify_token_val = resolve_env_vars(config.get("verify_token", ""))
|
||||
webhook_port = config.get("webhook_port", 8080)
|
||||
wa_mode = config.get("mode", "cloud")
|
||||
wa_creds_dir = config.get("creds_dir")
|
||||
# Message filtering from YAML config
|
||||
wa_respond_to = config.get("respond_to")
|
||||
wa_respond_to_groups = config.get("respond_to_groups")
|
||||
wa_respond_to_all = config.get("respond_to_all", False)
|
||||
wa_allowed_numbers = None
|
||||
wa_allowed_groups = None
|
||||
if isinstance(wa_respond_to, list):
|
||||
wa_allowed_numbers = wa_respond_to
|
||||
elif isinstance(wa_respond_to, str) and wa_respond_to:
|
||||
wa_allowed_numbers = [n.strip() for n in wa_respond_to.split(",") if n.strip()]
|
||||
if isinstance(wa_respond_to_groups, list):
|
||||
wa_allowed_groups = wa_respond_to_groups
|
||||
elif isinstance(wa_respond_to_groups, str) and wa_respond_to_groups:
|
||||
wa_allowed_groups = [g.strip() for g in wa_respond_to_groups.split(",") if g.strip()]
|
||||
self.start_whatsapp(
|
||||
token=token or None,
|
||||
phone_number_id=phone_number_id or None,
|
||||
@@ -250,6 +266,11 @@ class BotHandler:
|
||||
agent_file=None,
|
||||
capabilities=capabilities,
|
||||
agent_config_dict=agent_config,
|
||||
mode=wa_mode,
|
||||
creds_dir=wa_creds_dir,
|
||||
allowed_numbers=wa_allowed_numbers,
|
||||
allowed_groups=wa_allowed_groups,
|
||||
respond_to_all=bool(wa_respond_to_all),
|
||||
)
|
||||
|
||||
def start_telegram(
|
||||
@@ -399,32 +420,52 @@ class BotHandler:
|
||||
agent_file: Optional[str] = None,
|
||||
capabilities: Optional[BotCapabilities] = None,
|
||||
agent_config_dict: Optional[Dict[str, Any]] = None,
|
||||
mode: str = "cloud",
|
||||
creds_dir: Optional[str] = None,
|
||||
allowed_numbers: Optional[List[str]] = None,
|
||||
allowed_groups: Optional[List[str]] = None,
|
||||
respond_to_all: bool = False,
|
||||
) -> None:
|
||||
"""Start a WhatsApp bot.
|
||||
|
||||
Args:
|
||||
token: WhatsApp access token (or use WHATSAPP_ACCESS_TOKEN env var)
|
||||
phone_number_id: Phone number ID (or use WHATSAPP_PHONE_NUMBER_ID env var)
|
||||
verify_token: Webhook verify token (or use WHATSAPP_VERIFY_TOKEN env var)
|
||||
webhook_port: Port for webhook server (default 8080)
|
||||
token: WhatsApp access token (Cloud mode, or WHATSAPP_ACCESS_TOKEN env var)
|
||||
phone_number_id: Phone number ID (Cloud mode, or WHATSAPP_PHONE_NUMBER_ID env var)
|
||||
verify_token: Webhook verify token (Cloud mode, or WHATSAPP_VERIFY_TOKEN env var)
|
||||
webhook_port: Port for webhook server (Cloud mode, default 8080)
|
||||
agent_file: Optional path to agent configuration file
|
||||
capabilities: Optional capabilities configuration
|
||||
agent_config_dict: Optional agent config dict from bot YAML
|
||||
mode: Connection mode — 'cloud' (default) or 'web' (experimental)
|
||||
creds_dir: Credentials directory for Web mode
|
||||
allowed_numbers: Phone numbers the bot may respond to (besides self)
|
||||
allowed_groups: Group JIDs the bot may respond in
|
||||
respond_to_all: If True, respond to every message (no filtering)
|
||||
"""
|
||||
self._load_dotenv()
|
||||
token = token or os.environ.get("WHATSAPP_ACCESS_TOKEN")
|
||||
phone_number_id = phone_number_id or os.environ.get("WHATSAPP_PHONE_NUMBER_ID")
|
||||
verify_token = verify_token or os.environ.get("WHATSAPP_VERIFY_TOKEN", "")
|
||||
mode = (mode or "cloud").lower().strip()
|
||||
|
||||
if not token:
|
||||
print("Error: WhatsApp access token required")
|
||||
print("Provide via --token or WHATSAPP_ACCESS_TOKEN environment variable")
|
||||
return
|
||||
|
||||
if not phone_number_id:
|
||||
print("Error: WhatsApp phone number ID required")
|
||||
print("Provide via --phone-id or WHATSAPP_PHONE_NUMBER_ID environment variable")
|
||||
return
|
||||
if mode == "web":
|
||||
# Web mode: no tokens needed
|
||||
print("⚠️ WhatsApp Web mode is EXPERIMENTAL.")
|
||||
print(" Uses reverse-engineered protocol. Your number may be banned.")
|
||||
else:
|
||||
# Cloud mode: tokens required
|
||||
token = token or os.environ.get("WHATSAPP_ACCESS_TOKEN")
|
||||
phone_number_id = phone_number_id or os.environ.get("WHATSAPP_PHONE_NUMBER_ID")
|
||||
verify_token = verify_token or os.environ.get("WHATSAPP_VERIFY_TOKEN", "")
|
||||
|
||||
if not token:
|
||||
print("Error: WhatsApp access token required (Cloud mode)")
|
||||
print("Provide via --token or WHATSAPP_ACCESS_TOKEN environment variable")
|
||||
print("Or use --mode web for token-free QR scan mode")
|
||||
return
|
||||
|
||||
if not phone_number_id:
|
||||
print("Error: WhatsApp phone number ID required (Cloud mode)")
|
||||
print("Provide via --phone-id or WHATSAPP_PHONE_NUMBER_ID environment variable")
|
||||
print("Or use --mode web for token-free QR scan mode")
|
||||
return
|
||||
|
||||
# Set auto-approve if enabled
|
||||
if capabilities and capabilities.auto_approve:
|
||||
@@ -434,27 +475,52 @@ class BotHandler:
|
||||
try:
|
||||
from praisonai.bots import WhatsAppBot
|
||||
except ImportError as e:
|
||||
print(f"Error: WhatsAppBot requires aiohttp. {e}")
|
||||
print("Install with: pip install aiohttp")
|
||||
print(f"Error: WhatsAppBot import failed. {e}")
|
||||
if mode == "web":
|
||||
print("Install with: pip install 'praisonai[bot-whatsapp-web]'")
|
||||
else:
|
||||
print("Install with: pip install aiohttp")
|
||||
return
|
||||
|
||||
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
|
||||
bot = WhatsAppBot(
|
||||
token=token,
|
||||
phone_number_id=phone_number_id,
|
||||
token=token or "",
|
||||
phone_number_id=phone_number_id or "",
|
||||
agent=agent,
|
||||
verify_token=verify_token,
|
||||
verify_token=verify_token or "",
|
||||
webhook_port=webhook_port,
|
||||
mode=mode,
|
||||
creds_dir=creds_dir,
|
||||
allowed_numbers=allowed_numbers,
|
||||
allowed_groups=allowed_groups,
|
||||
respond_to_all=respond_to_all,
|
||||
)
|
||||
|
||||
self._print_startup_info("WhatsApp", capabilities)
|
||||
print(f"Webhook server on port {webhook_port}")
|
||||
if mode == "web":
|
||||
self._print_startup_info("WhatsApp (Web mode)", capabilities)
|
||||
if respond_to_all:
|
||||
print("Filtering: responding to ALL messages")
|
||||
elif allowed_numbers or allowed_groups:
|
||||
parts = []
|
||||
if allowed_numbers:
|
||||
parts.append(f"numbers: {', '.join(allowed_numbers)}")
|
||||
if allowed_groups:
|
||||
parts.append(f"groups: {', '.join(allowed_groups)}")
|
||||
print(f"Filtering: self + {' + '.join(parts)}")
|
||||
else:
|
||||
print("Filtering: self-only (message yourself to interact)")
|
||||
else:
|
||||
self._print_startup_info("WhatsApp", capabilities)
|
||||
print(f"Webhook server on port {webhook_port}")
|
||||
|
||||
try:
|
||||
asyncio.run(bot.start())
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
asyncio.run(bot.stop())
|
||||
try:
|
||||
asyncio.run(bot.stop())
|
||||
except Exception:
|
||||
pass # neonize Go threads may already be gone
|
||||
|
||||
def _get_agent_kwargs(self, capabilities: Optional[BotCapabilities]) -> Dict[str, Any]:
|
||||
"""Extract Agent constructor kwargs from BotCapabilities (DRY).
|
||||
@@ -657,7 +723,7 @@ class BotHandler:
|
||||
os.environ["WEB_SEARCH_PROVIDER"] = capabilities.web_search_provider
|
||||
logger.info(f"Web search provider set to: {capabilities.web_search_provider}")
|
||||
|
||||
# Default tools: execute_command and search_web (always enabled)
|
||||
# Default tools: execute_command, search_web, schedule tools (always enabled)
|
||||
try:
|
||||
from praisonaiagents.tools import execute_command, search_web
|
||||
tools.append(execute_command)
|
||||
@@ -666,6 +732,13 @@ class BotHandler:
|
||||
except ImportError:
|
||||
logger.warning("Default tools not available from praisonaiagents.tools")
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
|
||||
tools.extend([schedule_add, schedule_list, schedule_remove])
|
||||
logger.info("Default schedule tools enabled: schedule_add, schedule_list, schedule_remove")
|
||||
except ImportError:
|
||||
logger.warning("Schedule tools not available from praisonaiagents.tools")
|
||||
|
||||
# Browser tool
|
||||
if capabilities.browser:
|
||||
try:
|
||||
@@ -924,6 +997,11 @@ Examples:
|
||||
)
|
||||
return 0
|
||||
elif platform == "whatsapp":
|
||||
# Parse respond-to flags
|
||||
respond_to_raw = getattr(args, "respond_to", None)
|
||||
respond_to_groups_raw = getattr(args, "respond_to_groups", None)
|
||||
allowed_numbers = [n.strip() for n in respond_to_raw.split(",") if n.strip()] if respond_to_raw else None
|
||||
allowed_groups = [g.strip() for g in respond_to_groups_raw.split(",") if g.strip()] if respond_to_groups_raw else None
|
||||
handler.start_whatsapp(
|
||||
token=getattr(args, "token", None),
|
||||
phone_number_id=getattr(args, "phone_id", None),
|
||||
@@ -931,6 +1009,9 @@ Examples:
|
||||
webhook_port=getattr(args, "port", 8080),
|
||||
agent_file=getattr(args, "agent", None),
|
||||
capabilities=capabilities,
|
||||
allowed_numbers=allowed_numbers,
|
||||
allowed_groups=allowed_groups,
|
||||
respond_to_all=getattr(args, "respond_to_all", False),
|
||||
)
|
||||
return 0
|
||||
else:
|
||||
@@ -1030,6 +1111,23 @@ def add_bot_parser(subparsers) -> None:
|
||||
default=8080,
|
||||
help="Webhook server port (default: 8080)",
|
||||
)
|
||||
# WhatsApp message filtering
|
||||
whatsapp_parser.add_argument(
|
||||
"--respond-to",
|
||||
dest="respond_to",
|
||||
help="Comma-separated phone numbers the bot responds to (besides self)",
|
||||
)
|
||||
whatsapp_parser.add_argument(
|
||||
"--respond-to-groups",
|
||||
dest="respond_to_groups",
|
||||
help="Comma-separated group JIDs the bot responds in",
|
||||
)
|
||||
whatsapp_parser.add_argument(
|
||||
"--respond-to-all",
|
||||
dest="respond_to_all",
|
||||
action="store_true",
|
||||
help="Respond to ALL messages (default: self-only)",
|
||||
)
|
||||
_add_capability_args(whatsapp_parser)
|
||||
|
||||
bot_parser.set_defaults(func=handle_bot_command)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Bot-specific doctor checks for PraisonAI.
|
||||
|
||||
Registers checks for bot tokens, config validation, and channel probes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from ..models import CheckResult, CheckStatus, CheckCategory
|
||||
|
||||
|
||||
def check_bot_tokens() -> CheckResult:
|
||||
"""Check bot token environment variables."""
|
||||
token_vars = {
|
||||
"TELEGRAM_BOT_TOKEN": "Telegram",
|
||||
"DISCORD_BOT_TOKEN": "Discord",
|
||||
"SLACK_BOT_TOKEN": "Slack",
|
||||
"WHATSAPP_ACCESS_TOKEN": "WhatsApp",
|
||||
}
|
||||
found = []
|
||||
for var, name in token_vars.items():
|
||||
if os.environ.get(var):
|
||||
found.append(name)
|
||||
|
||||
if found:
|
||||
return CheckResult(
|
||||
id="bot_tokens",
|
||||
title="Bot Tokens",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"Found: {', '.join(found)}",
|
||||
)
|
||||
return CheckResult(
|
||||
id="bot_tokens",
|
||||
title="Bot Tokens",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.WARN,
|
||||
message="No bot tokens found in environment",
|
||||
remediation="Set at least one: export TELEGRAM_BOT_TOKEN=your_token",
|
||||
)
|
||||
|
||||
|
||||
def check_bot_config(config_path: str = "bot.yaml") -> CheckResult:
|
||||
"""Check bot.yaml exists and is valid."""
|
||||
start = time.time()
|
||||
if not os.path.exists(config_path):
|
||||
return CheckResult(
|
||||
id="bot_config",
|
||||
title="Bot Config",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"{config_path} not found",
|
||||
remediation="Run 'praisonai onboard' to create one",
|
||||
duration_ms=(time.time() - start) * 1000,
|
||||
)
|
||||
try:
|
||||
from praisonai.bots._config_schema import load_and_validate_bot_yaml
|
||||
load_and_validate_bot_yaml(config_path)
|
||||
return CheckResult(
|
||||
id="bot_config",
|
||||
title="Bot Config",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"{config_path} valid",
|
||||
duration_ms=(time.time() - start) * 1000,
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="bot_config",
|
||||
title="Bot Config",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Invalid: {str(e)[:200]}",
|
||||
remediation="Fix errors in bot.yaml or run 'praisonai onboard'",
|
||||
duration_ms=(time.time() - start) * 1000,
|
||||
)
|
||||
|
||||
|
||||
def get_bot_checks() -> List[CheckResult]:
|
||||
"""Run all bot-specific checks and return results."""
|
||||
return [
|
||||
check_bot_tokens(),
|
||||
check_bot_config(),
|
||||
]
|
||||
@@ -33,6 +33,7 @@ class CheckCategory(Enum):
|
||||
NETWORK = "network"
|
||||
PERFORMANCE = "performance"
|
||||
SELFTEST = "selftest"
|
||||
BOTS = "bots"
|
||||
|
||||
|
||||
class CheckSeverity(Enum):
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Onboarding wizard for PraisonAI bots.
|
||||
|
||||
Interactive setup that guides users through:
|
||||
1. Platform selection
|
||||
2. Token configuration
|
||||
3. Connection test (probe)
|
||||
4. Config file generation (bot.yaml)
|
||||
5. Optional daemon install
|
||||
|
||||
Usage:
|
||||
praisonai onboard
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Platform info for the wizard
|
||||
PLATFORMS = {
|
||||
"telegram": {
|
||||
"name": "Telegram",
|
||||
"token_env": "TELEGRAM_BOT_TOKEN",
|
||||
"token_help": "Get from @BotFather on Telegram: https://t.me/BotFather",
|
||||
"install_hint": "pip install python-telegram-bot",
|
||||
},
|
||||
"discord": {
|
||||
"name": "Discord",
|
||||
"token_env": "DISCORD_BOT_TOKEN",
|
||||
"token_help": "Create at https://discord.com/developers/applications",
|
||||
"install_hint": "pip install discord.py",
|
||||
},
|
||||
"slack": {
|
||||
"name": "Slack",
|
||||
"token_env": "SLACK_BOT_TOKEN",
|
||||
"token_help": "Create at https://api.slack.com/apps",
|
||||
"install_hint": "pip install slack-bolt",
|
||||
"extra_env": {"SLACK_APP_TOKEN": "App token (xapp-...)"},
|
||||
},
|
||||
"whatsapp": {
|
||||
"name": "WhatsApp",
|
||||
"token_env": "WHATSAPP_ACCESS_TOKEN",
|
||||
"token_help": "Get from Meta for Developers: https://developers.facebook.com",
|
||||
"install_hint": "pip install aiohttp",
|
||||
"extra_env": {"WHATSAPP_PHONE_NUMBER_ID": "Phone number ID"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agent_instructions: str = "") -> str:
|
||||
"""Generate bot.yaml content."""
|
||||
lines = ["agent:"]
|
||||
lines.append(f' name: "{agent_name}"')
|
||||
if agent_instructions:
|
||||
lines.append(f' instructions: "{agent_instructions}"')
|
||||
lines.append(' model: "gpt-4o-mini"')
|
||||
lines.append("")
|
||||
lines.append("channels:")
|
||||
for plat in platforms:
|
||||
info = PLATFORMS.get(plat, {})
|
||||
env_var = info.get("token_env", f"{plat.upper()}_BOT_TOKEN")
|
||||
lines.append(f" {plat}:")
|
||||
lines.append(f" token: ${{{env_var}}}")
|
||||
lines.append(" group_policy: mention_only")
|
||||
if plat == "whatsapp":
|
||||
lines.append(" phone_number_id: ${WHATSAPP_PHONE_NUMBER_ID}")
|
||||
lines.append("")
|
||||
lines.append("routing:")
|
||||
lines.append(" default: assistant")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class OnboardWizard:
|
||||
"""Interactive onboarding wizard using rich prompts."""
|
||||
|
||||
def __init__(self):
|
||||
self.selected_platforms: List[str] = []
|
||||
self.tokens: Dict[str, str] = {}
|
||||
self.agent_name: str = "assistant"
|
||||
self.agent_instructions: str = "You are a helpful AI assistant."
|
||||
self.config_path: str = "bot.yaml"
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the wizard interactively."""
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt, Confirm
|
||||
from rich.panel import Panel
|
||||
except ImportError:
|
||||
self._run_plain()
|
||||
return
|
||||
|
||||
console = Console()
|
||||
|
||||
# Welcome
|
||||
console.print(Panel(
|
||||
"[bold]Welcome to PraisonAI Bot Setup[/bold]\n\n"
|
||||
"This wizard will help you set up your AI bot in a few steps.\n"
|
||||
"You'll need a bot token from your chosen platform.",
|
||||
title="🤖 PraisonAI Onboard",
|
||||
border_style="blue",
|
||||
))
|
||||
|
||||
# Step 1: Platform selection
|
||||
console.print("\n[bold]Step 1: Choose your platform(s)[/bold]\n")
|
||||
for key, info in PLATFORMS.items():
|
||||
console.print(f" [cyan]{key}[/cyan] — {info['name']}")
|
||||
|
||||
platforms_input = Prompt.ask(
|
||||
"\nPlatform(s) [comma-separated]",
|
||||
default="telegram",
|
||||
)
|
||||
self.selected_platforms = [p.strip().lower() for p in platforms_input.split(",") if p.strip()]
|
||||
|
||||
# Validate
|
||||
for plat in self.selected_platforms:
|
||||
if plat not in PLATFORMS:
|
||||
console.print(f"[red]Unknown platform: {plat}[/red]")
|
||||
return
|
||||
|
||||
# Step 2: Token configuration
|
||||
console.print("\n[bold]Step 2: Configure tokens[/bold]\n")
|
||||
for plat in self.selected_platforms:
|
||||
info = PLATFORMS[plat]
|
||||
env_var = info["token_env"]
|
||||
existing = os.environ.get(env_var, "")
|
||||
|
||||
if existing:
|
||||
console.print(f" [green]✓[/green] {info['name']}: {env_var} already set")
|
||||
self.tokens[plat] = existing
|
||||
else:
|
||||
console.print(f" [dim]{info['token_help']}[/dim]")
|
||||
token = Prompt.ask(f" {info['name']} token ({env_var})")
|
||||
if token:
|
||||
self.tokens[plat] = token
|
||||
os.environ[env_var] = token
|
||||
console.print(" [green]✓[/green] Token set for this session")
|
||||
else:
|
||||
console.print(f" [yellow]⚠[/yellow] No token — you'll need to set {env_var} before starting")
|
||||
|
||||
# Extra env vars (e.g., Slack app token)
|
||||
for extra_env, extra_desc in info.get("extra_env", {}).items():
|
||||
if not os.environ.get(extra_env):
|
||||
extra_val = Prompt.ask(f" {extra_desc} ({extra_env})", default="")
|
||||
if extra_val:
|
||||
os.environ[extra_env] = extra_val
|
||||
|
||||
# Step 3: Test connection
|
||||
console.print("\n[bold]Step 3: Test connection[/bold]\n")
|
||||
for plat in self.selected_platforms:
|
||||
if plat in self.tokens:
|
||||
console.print(f" Testing {PLATFORMS[plat]['name']}...", end=" ")
|
||||
try:
|
||||
result = asyncio.run(self._probe(plat))
|
||||
if result.ok:
|
||||
console.print(f"[green]✓ @{result.bot_username}[/green] ({result.elapsed_ms:.0f}ms)")
|
||||
else:
|
||||
console.print(f"[red]✗ {result.error}[/red]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]✗ {str(e)[:100]}[/red]")
|
||||
|
||||
# Step 4: Agent configuration
|
||||
console.print("\n[bold]Step 4: Configure your agent[/bold]\n")
|
||||
self.agent_name = Prompt.ask("Agent name", default="assistant")
|
||||
self.agent_instructions = Prompt.ask(
|
||||
"Agent instructions",
|
||||
default="You are a helpful AI assistant.",
|
||||
)
|
||||
|
||||
# Step 5: Generate config
|
||||
console.print("\n[bold]Step 5: Generate configuration[/bold]\n")
|
||||
yaml_content = _generate_bot_yaml(
|
||||
self.selected_platforms,
|
||||
agent_name=self.agent_name,
|
||||
agent_instructions=self.agent_instructions,
|
||||
)
|
||||
|
||||
self.config_path = Prompt.ask("Config file path", default="bot.yaml")
|
||||
|
||||
if os.path.exists(self.config_path):
|
||||
if not Confirm.ask(f" {self.config_path} exists. Overwrite?", default=False):
|
||||
console.print(" [dim]Skipped[/dim]")
|
||||
return
|
||||
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write(yaml_content)
|
||||
console.print(f" [green]✓[/green] Written to {self.config_path}")
|
||||
|
||||
# Step 6: Optional daemon install
|
||||
if Confirm.ask("\nInstall as background service (daemon)?", default=False):
|
||||
try:
|
||||
from praisonai.daemon import install_daemon
|
||||
result = install_daemon(config_path=self.config_path)
|
||||
if result.get("ok"):
|
||||
console.print(f" [green]✓[/green] {result.get('message', 'Service installed')}")
|
||||
else:
|
||||
console.print(f" [red]✗[/red] {result.get('error', 'Install failed')}")
|
||||
except Exception as e:
|
||||
console.print(f" [red]✗[/red] {str(e)[:200]}")
|
||||
|
||||
# Done
|
||||
console.print(Panel(
|
||||
f"[bold green]Setup complete![/bold green]\n\n"
|
||||
f"Start your bot:\n"
|
||||
f" [cyan]praisonai bot start --config {self.config_path}[/cyan]\n\n"
|
||||
f"Check health:\n"
|
||||
f" [cyan]praisonai doctor[/cyan]\n\n"
|
||||
f"View status:\n"
|
||||
f" [cyan]praisonai bot status[/cyan]",
|
||||
title="✅ Done",
|
||||
border_style="green",
|
||||
))
|
||||
|
||||
async def _probe(self, platform: str):
|
||||
"""Run a probe for a platform."""
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot(platform, token=self.tokens.get(platform, ""))
|
||||
return await bot.probe()
|
||||
|
||||
def _run_plain(self) -> None:
|
||||
"""Fallback for when rich is not available."""
|
||||
print("\n=== PraisonAI Bot Setup ===\n")
|
||||
print("Available platforms: telegram, discord, slack, whatsapp")
|
||||
platforms_input = input("Platform(s) [comma-separated, default=telegram]: ").strip() or "telegram"
|
||||
self.selected_platforms = [p.strip().lower() for p in platforms_input.split(",")]
|
||||
|
||||
for plat in self.selected_platforms:
|
||||
info = PLATFORMS.get(plat, {})
|
||||
env_var = info.get("token_env", f"{plat.upper()}_BOT_TOKEN")
|
||||
if not os.environ.get(env_var):
|
||||
print(f"\n {info.get('token_help', '')}")
|
||||
token = input(f" {env_var}: ").strip()
|
||||
if token:
|
||||
os.environ[env_var] = token
|
||||
self.tokens[plat] = token
|
||||
|
||||
yaml_content = _generate_bot_yaml(self.selected_platforms)
|
||||
with open("bot.yaml", "w") as f:
|
||||
f.write(yaml_content)
|
||||
print("\n✓ Written to bot.yaml")
|
||||
print("Start with: praisonai bot start --config bot.yaml")
|
||||
|
||||
|
||||
def run_onboard() -> None:
|
||||
"""Entry point for 'praisonai onboard' command."""
|
||||
wizard = OnboardWizard()
|
||||
wizard.run()
|
||||
@@ -305,6 +305,14 @@ class AsyncTUI:
|
||||
except ImportError as e:
|
||||
logger.debug(f"internet_search import failed: {e}")
|
||||
|
||||
# Try to add schedule tools (default for all agents)
|
||||
try:
|
||||
from praisonaiagents.tools import schedule_add, schedule_list, schedule_remove
|
||||
tools.extend([schedule_add, schedule_list, schedule_remove])
|
||||
logger.debug("Added schedule tools: schedule_add, schedule_list, schedule_remove")
|
||||
except ImportError as e:
|
||||
logger.debug(f"Schedule tools import failed: {e}")
|
||||
|
||||
logger.debug(f"Final tools loaded: {len(tools)}")
|
||||
return tools
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Daemon service management for PraisonAI bots.
|
||||
|
||||
Provides OS-level service install/uninstall/status for always-on bots.
|
||||
Supports systemd (Linux) and launchd (macOS).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _detect_platform() -> str:
|
||||
"""Detect the current OS platform."""
|
||||
system = platform.system().lower()
|
||||
if system == "linux":
|
||||
return "systemd"
|
||||
elif system == "darwin":
|
||||
return "launchd"
|
||||
elif system == "windows":
|
||||
return "windows"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_daemon_status() -> Dict[str, Any]:
|
||||
"""Get daemon status for the current platform."""
|
||||
plat = _detect_platform()
|
||||
if plat == "systemd":
|
||||
from .systemd import get_status
|
||||
return get_status()
|
||||
elif plat == "launchd":
|
||||
from .launchd import get_status
|
||||
return get_status()
|
||||
return {"installed": False, "running": False, "platform": plat, "error": "Unsupported platform"}
|
||||
|
||||
|
||||
def install_daemon(config_path: str = "bot.yaml", **kwargs: Any) -> Dict[str, Any]:
|
||||
"""Install the bot as an OS daemon service."""
|
||||
plat = _detect_platform()
|
||||
if plat == "systemd":
|
||||
from .systemd import install
|
||||
return install(config_path=config_path, **kwargs)
|
||||
elif plat == "launchd":
|
||||
from .launchd import install
|
||||
return install(config_path=config_path, **kwargs)
|
||||
return {"ok": False, "error": f"Unsupported platform: {plat}"}
|
||||
|
||||
|
||||
def uninstall_daemon() -> Dict[str, Any]:
|
||||
"""Uninstall the bot daemon service."""
|
||||
plat = _detect_platform()
|
||||
if plat == "systemd":
|
||||
from .systemd import uninstall
|
||||
return uninstall()
|
||||
elif plat == "launchd":
|
||||
from .launchd import uninstall
|
||||
return uninstall()
|
||||
return {"ok": False, "error": f"Unsupported platform: {plat}"}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
launchd service management for PraisonAI bots (macOS).
|
||||
|
||||
Generates and manages a launchd plist for always-on bots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LABEL = "ai.praison.bot"
|
||||
PLIST_NAME = f"{LABEL}.plist"
|
||||
|
||||
|
||||
def _launch_agents_dir() -> str:
|
||||
return os.path.expanduser("~/Library/LaunchAgents")
|
||||
|
||||
|
||||
def _plist_path() -> str:
|
||||
return os.path.join(_launch_agents_dir(), PLIST_NAME)
|
||||
|
||||
|
||||
def _log_dir() -> str:
|
||||
d = os.path.expanduser("~/.praisonai/logs")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _generate_plist(config_path: str) -> str:
|
||||
"""Generate a launchd plist XML for the bot."""
|
||||
python = shutil.which("python3") or shutil.which("python") or sys.executable
|
||||
abs_config = os.path.abspath(config_path)
|
||||
working_dir = os.path.dirname(abs_config) or os.getcwd()
|
||||
log_dir = _log_dir()
|
||||
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{python}</string>
|
||||
<string>-m</string>
|
||||
<string>praisonai</string>
|
||||
<string>bot</string>
|
||||
<string>start</string>
|
||||
<string>--config</string>
|
||||
<string>{abs_config}</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{working_dir}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{log_dir}/bot-stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{log_dir}/bot-stderr.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>{os.environ.get('PATH', '/usr/bin:/usr/local/bin')}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
|
||||
def install(config_path: str = "bot.yaml", **kwargs: Any) -> Dict[str, Any]:
|
||||
"""Install the launchd agent."""
|
||||
la_dir = _launch_agents_dir()
|
||||
os.makedirs(la_dir, exist_ok=True)
|
||||
|
||||
plist_content = _generate_plist(config_path)
|
||||
plist_path = _plist_path()
|
||||
|
||||
with open(plist_path, "w") as f:
|
||||
f.write(plist_content)
|
||||
|
||||
try:
|
||||
subprocess.run(["launchctl", "load", plist_path], check=True, capture_output=True)
|
||||
return {"ok": True, "plist_path": plist_path, "message": f"Service installed and loaded: {plist_path}"}
|
||||
except subprocess.CalledProcessError as e:
|
||||
return {"ok": False, "error": f"launchctl error: {e.stderr.decode()[:300] if e.stderr else str(e)}"}
|
||||
|
||||
|
||||
def uninstall() -> Dict[str, Any]:
|
||||
"""Unload and remove the launchd agent."""
|
||||
plist_path = _plist_path()
|
||||
try:
|
||||
subprocess.run(["launchctl", "unload", plist_path], capture_output=True)
|
||||
if os.path.exists(plist_path):
|
||||
os.remove(plist_path)
|
||||
return {"ok": True, "message": "Service removed"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def get_status() -> Dict[str, Any]:
|
||||
"""Get the status of the launchd agent."""
|
||||
plist_path = _plist_path()
|
||||
installed = os.path.exists(plist_path)
|
||||
|
||||
if not installed:
|
||||
return {"installed": False, "running": False, "platform": "launchd"}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", LABEL],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
running = result.returncode == 0
|
||||
return {"installed": True, "running": running, "platform": "launchd", "plist_path": plist_path}
|
||||
except Exception:
|
||||
return {"installed": True, "running": False, "platform": "launchd", "plist_path": plist_path}
|
||||
|
||||
|
||||
def get_logs(lines: int = 50) -> str:
|
||||
"""Get recent logs from the service."""
|
||||
log_path = os.path.join(_log_dir(), "bot-stderr.log")
|
||||
if not os.path.exists(log_path):
|
||||
return "No log file found."
|
||||
try:
|
||||
with open(log_path) as f:
|
||||
all_lines = f.readlines()
|
||||
return "".join(all_lines[-lines:])
|
||||
except Exception as e:
|
||||
return f"Error reading logs: {e}"
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user