Merge pull request 'feature/web-search-and-cron-improvements' (#2) from feature/web-search-and-cron-improvements into feature/cleanup-providers-llama-only
Reviewed-on: #2
This commit is contained in:
commit
98d7c0b788
99
.gitea/workflows/ci.yml
Normal file
99
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,99 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, develop ]
|
||||
pull_request:
|
||||
branches: [ main, master, develop ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint with ruff
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install ruff
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ruff>=0.1.0
|
||||
|
||||
- name: Run ruff check
|
||||
run: |
|
||||
ruff check nanobot/
|
||||
|
||||
- name: Run ruff format check
|
||||
run: |
|
||||
ruff format --check nanobot/
|
||||
|
||||
test:
|
||||
name: Test Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12']
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest tests/ -v --tb=short
|
||||
|
||||
- name: Check package can be imported
|
||||
run: |
|
||||
python -c "import nanobot; print(f'nanobot version check passed')"
|
||||
|
||||
build:
|
||||
name: Build package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build hatchling
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
python -m build
|
||||
|
||||
- name: Check build artifacts
|
||||
run: |
|
||||
ls -lh dist/
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-packages
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
|
||||
229
CALENDAR_SETUP.md
Normal file
229
CALENDAR_SETUP.md
Normal file
@ -0,0 +1,229 @@
|
||||
# Google Calendar Integration Setup
|
||||
|
||||
This guide explains how to set up Google Calendar integration for nanobot.
|
||||
|
||||
## Features
|
||||
|
||||
- **List upcoming events** from your Google Calendar
|
||||
- **Create calendar events** programmatically
|
||||
- **Check availability** for time slots
|
||||
- **Automatic scheduling from emails** - when an email mentions a meeting, nanobot can automatically schedule it
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Google account with Calendar access
|
||||
2. Google Cloud Project with Calendar API enabled
|
||||
3. OAuth2 credentials (Desktop app type)
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Enable Google Calendar API
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the **Google Calendar API**:
|
||||
- Navigate to "APIs & Services" > "Library"
|
||||
- Search for "Google Calendar API"
|
||||
- Click "Enable"
|
||||
|
||||
### 2. Configure OAuth Consent Screen
|
||||
|
||||
**IMPORTANT:** This step is required before creating credentials.
|
||||
|
||||
1. Go to "APIs & Services" > "OAuth consent screen"
|
||||
2. Choose **"External"** user type (unless you have a Google Workspace account)
|
||||
3. Fill in required fields:
|
||||
- **App name**: "nanobot" (or any name)
|
||||
- **User support email**: Your email address
|
||||
- **Developer contact information**: Your email address
|
||||
4. Click "Save and Continue"
|
||||
5. **Add Scopes:**
|
||||
- Click "Add or Remove Scopes"
|
||||
- Search for and add: `https://www.googleapis.com/auth/calendar`
|
||||
- Click "Update" then "Save and Continue"
|
||||
6. **Add Test Users (CRITICAL):**
|
||||
- Click "Add Users"
|
||||
- Add your email address (`adayear2025@gmail.com`)
|
||||
- Click "Add" then "Save and Continue"
|
||||
7. Review and go back to dashboard
|
||||
|
||||
### 3. Create OAuth2 Credentials
|
||||
|
||||
1. Go to "APIs & Services" > "Credentials"
|
||||
2. Click "Create Credentials" > "OAuth client ID"
|
||||
3. Select:
|
||||
- **Application type**: **Desktop app**
|
||||
- **Name**: "nanobot" (or any name)
|
||||
4. Click "Create"
|
||||
5. **Download the credentials JSON file** - click "Download JSON"
|
||||
6. Save it as `credentials.json` and copy to your server at `~/.nanobot/credentials.json`
|
||||
|
||||
### 3. Configure nanobot
|
||||
|
||||
Set environment variables or add to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Enable calendar functionality
|
||||
export NANOBOT_TOOLS__CALENDAR__ENABLED=true
|
||||
|
||||
# Path to OAuth2 credentials JSON file
|
||||
export NANOBOT_TOOLS__CALENDAR__CREDENTIALS_FILE=/path/to/credentials.json
|
||||
|
||||
# Optional: Custom token storage location (default: ~/.nanobot/calendar_token.json)
|
||||
export NANOBOT_TOOLS__CALENDAR__TOKEN_FILE=~/.nanobot/calendar_token.json
|
||||
|
||||
# Optional: Calendar ID (default: "primary")
|
||||
export NANOBOT_TOOLS__CALENDAR__CALENDAR_ID=primary
|
||||
|
||||
# Optional: Auto-schedule meetings from emails (default: true)
|
||||
export NANOBOT_TOOLS__CALENDAR__AUTO_SCHEDULE_FROM_EMAIL=true
|
||||
```
|
||||
|
||||
### 4. First-Time Authorization
|
||||
|
||||
**You don't need to manually get or copy any token** - the OAuth flow handles everything automatically.
|
||||
|
||||
On first run, when nanobot tries to use the calendar tool, it will:
|
||||
|
||||
1. **Automatically open a browser window** for Google OAuth authorization
|
||||
2. **You sign in** to your Google account in the browser
|
||||
3. **Grant calendar access** by clicking "Allow"
|
||||
4. **Automatically save the token** to `~/.nanobot/calendar_token.json` for future use
|
||||
|
||||
**Important:**
|
||||
- The token is **automatically generated and saved** - you don't need to copy it from anywhere
|
||||
- This happens **automatically** the first time you use a calendar command
|
||||
- After the first authorization, you won't need to do this again (the token is reused)
|
||||
- The token file is created automatically at `~/.nanobot/calendar_token.json`
|
||||
|
||||
**Example first run:**
|
||||
```bash
|
||||
# First time using calendar - triggers OAuth flow
|
||||
python3 -m nanobot.cli.commands agent -m "What's on my calendar?"
|
||||
|
||||
# Browser opens automatically → Sign in → Grant access → Token saved
|
||||
# Future runs use the saved token automatically
|
||||
```
|
||||
|
||||
**Note for remote/headless servers:**
|
||||
If you're running nanobot on a remote server without a display, you have two options:
|
||||
|
||||
1. **Run OAuth on your local machine first:**
|
||||
- Run nanobot locally once to complete OAuth
|
||||
- Copy the generated `~/.nanobot/calendar_token.json` to your remote server
|
||||
- The token will work on the remote server
|
||||
|
||||
2. **Use SSH port forwarding:**
|
||||
- The OAuth flow uses a local web server
|
||||
- You may need to set up port forwarding or use a different OAuth flow method
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### List Upcoming Events
|
||||
|
||||
```
|
||||
User: "What's on my calendar?"
|
||||
Agent: [Uses calendar tool to list events]
|
||||
```
|
||||
|
||||
### Create an Event
|
||||
|
||||
```
|
||||
User: "Schedule a meeting tomorrow at 2pm"
|
||||
Agent: [Creates calendar event]
|
||||
```
|
||||
|
||||
### Automatic Email Scheduling
|
||||
|
||||
When an email mentions a meeting:
|
||||
|
||||
```
|
||||
Email: "Hi, let's have a meeting tomorrow at 2pm in Conference Room A"
|
||||
Agent: [Automatically extracts meeting info and creates calendar event]
|
||||
Agent: "I've scheduled a meeting for tomorrow at 2pm in Conference Room A"
|
||||
```
|
||||
|
||||
## Calendar Tool API
|
||||
|
||||
The calendar tool supports three actions:
|
||||
|
||||
### 1. List Events
|
||||
|
||||
```python
|
||||
calendar(
|
||||
action="list_events",
|
||||
max_results=10, # Optional, default: 10
|
||||
time_min="2024-01-15T00:00:00Z" # Optional, defaults to now
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Create Event
|
||||
|
||||
```python
|
||||
calendar(
|
||||
action="create_event",
|
||||
title="Team Meeting",
|
||||
start_time="tomorrow 2pm", # or "2024-01-15T14:00:00"
|
||||
end_time="tomorrow 3pm", # Optional, defaults to 1 hour after start
|
||||
description="Discuss project progress", # Optional
|
||||
location="Conference Room A", # Optional
|
||||
attendees=["colleague@example.com"] # Optional list
|
||||
)
|
||||
```
|
||||
|
||||
**Time formats:**
|
||||
- Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"`
|
||||
- ISO format: `"2024-01-15T14:00:00"`
|
||||
|
||||
### 3. Check Availability
|
||||
|
||||
```python
|
||||
calendar(
|
||||
action="check_availability",
|
||||
start_time="2024-01-15T14:00:00",
|
||||
end_time="2024-01-15T15:00:00"
|
||||
)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Error: Could not authenticate with Google Calendar"
|
||||
|
||||
- Ensure `credentials_file` path is correct
|
||||
- Check that the credentials JSON file is valid
|
||||
- Run nanobot once to complete OAuth flow
|
||||
|
||||
### "Error accessing Google Calendar API"
|
||||
|
||||
- Verify Calendar API is enabled in Google Cloud Console
|
||||
- Check that OAuth consent screen is configured
|
||||
- Ensure your email is added as a test user (if app is in testing mode)
|
||||
|
||||
### Token Expired
|
||||
|
||||
The tool automatically refreshes expired tokens. If refresh fails:
|
||||
1. Delete `~/.nanobot/calendar_token.json`
|
||||
2. Run nanobot again to re-authorize
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Keep your `credentials.json` file secure (don't commit to git)
|
||||
- The `calendar_token.json` file contains sensitive access tokens
|
||||
- Use file permissions: `chmod 600 ~/.nanobot/calendar_token.json`
|
||||
- Consider using environment variables or a secrets manager for production
|
||||
|
||||
## Integration with Email Channel
|
||||
|
||||
When `auto_schedule_from_email` is enabled, nanobot will:
|
||||
|
||||
1. Monitor incoming emails
|
||||
2. Detect meeting-related keywords (meeting, appointment, call, etc.)
|
||||
3. Extract meeting details (time, location, attendees)
|
||||
4. Automatically create calendar events
|
||||
5. Confirm with the user
|
||||
|
||||
This works best when:
|
||||
- Emails contain clear time references ("tomorrow at 2pm", "next Monday")
|
||||
- Meeting details are in the email body or subject
|
||||
- The agent has access to the email channel
|
||||
|
||||
@ -101,6 +101,15 @@ Your workspace is at: {workspace_path}
|
||||
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
|
||||
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
||||
|
||||
## Gitea API (This Repository)
|
||||
**CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub.
|
||||
- Repository: `ilia/nanobot`
|
||||
- Token: `$NANOBOT_GITLE_TOKEN`
|
||||
- **NEVER use placeholder URLs like `gitea.example.com`**
|
||||
- **ALWAYS use `http://` (NOT `https://`)** - Gitea runs on HTTP, using HTTPS causes SSL errors
|
||||
- Always detect from `git remote get-url origin` or use `http://10.0.30.169:3000/api/v1`
|
||||
- Example: `curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"`
|
||||
|
||||
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
|
||||
Only use the 'message' tool when the user explicitly asks you to send a message to someone else or to a different channel.
|
||||
For normal conversation, acknowledgments (Thanks, OK, etc.), or when the user is talking to YOU, just respond with text - do NOT call the message tool.
|
||||
@ -109,7 +118,9 @@ For simple acknowledgments like "Thanks", "OK", "You're welcome", "Got it", etc.
|
||||
|
||||
Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language).
|
||||
When remembering something important, write to {workspace_path}/memory/MEMORY.md
|
||||
To recall past events, grep {workspace_path}/memory/HISTORY.md"""
|
||||
To recall past events, grep {workspace_path}/memory/HISTORY.md
|
||||
|
||||
IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS use the read_emails tool. NEVER use exec() with mail/tail/awk commands or read_file() on /var/mail - those will not work. The read_emails tool is the only way to access emails."""
|
||||
|
||||
def _load_bootstrap_files(self) -> str:
|
||||
"""Load all bootstrap files from workspace."""
|
||||
|
||||
@ -122,6 +122,36 @@ class AgentLoop:
|
||||
# Cron tool (for scheduling)
|
||||
if self.cron_service:
|
||||
self.tools.register(CronTool(self.cron_service))
|
||||
|
||||
# Email tool (if email channel is configured)
|
||||
try:
|
||||
from nanobot.agent.tools.email import EmailTool
|
||||
from nanobot.config.loader import load_config
|
||||
config = load_config()
|
||||
if config.channels.email.enabled:
|
||||
email_tool = EmailTool(email_config=config.channels.email)
|
||||
self.tools.register(email_tool)
|
||||
logger.info(f"Email tool '{email_tool.name}' registered successfully")
|
||||
else:
|
||||
logger.debug("Email tool not registered: email channel not enabled")
|
||||
except Exception as e:
|
||||
logger.warning(f"Email tool not available: {e}")
|
||||
# Email tool not available or not configured - silently skip
|
||||
|
||||
# Calendar tool (if calendar is configured)
|
||||
try:
|
||||
from nanobot.agent.tools.calendar import CalendarTool
|
||||
from nanobot.config.loader import load_config
|
||||
config = load_config()
|
||||
if config.tools.calendar.enabled:
|
||||
calendar_tool = CalendarTool(calendar_config=config.tools.calendar)
|
||||
self.tools.register(calendar_tool)
|
||||
logger.info(f"Calendar tool '{calendar_tool.name}' registered successfully")
|
||||
else:
|
||||
logger.debug("Calendar tool not registered: calendar not enabled")
|
||||
except Exception as e:
|
||||
logger.warning(f"Calendar tool not available: {e}")
|
||||
# Calendar tool not available or not configured - silently skip
|
||||
|
||||
async def _connect_mcp(self) -> None:
|
||||
"""Connect to configured MCP servers (one-time, lazy)."""
|
||||
@ -362,6 +392,9 @@ class AgentLoop:
|
||||
)
|
||||
|
||||
async def _bus_progress(content: str) -> None:
|
||||
# Skip progress updates for email channel to avoid sending intermediate tool call hints as emails
|
||||
if msg.channel == "email":
|
||||
return
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||
metadata=msg.metadata or {},
|
||||
|
||||
1120
nanobot/agent/tools/calendar.py
Normal file
1120
nanobot/agent/tools/calendar.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -26,7 +26,19 @@ class CronTool(Tool):
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Schedule reminders and recurring tasks. REQUIRED: Always include 'action' parameter ('add', 'list', or 'remove'). For reminders, use action='add' with message and timing (in_seconds, at, every_seconds, or cron_expr)."
|
||||
return """Schedule reminders and recurring tasks. REQUIRED: Always include 'action' parameter ('add', 'list', or 'remove').
|
||||
|
||||
For 'add' action:
|
||||
- MUST include 'message' parameter - extract the reminder/task text from user's request
|
||||
- Examples: 'remind me to call mama' → message='call mama'
|
||||
|
||||
For timing patterns:
|
||||
- 'remind me in X seconds' → in_seconds=X (DO NOT use 'at')
|
||||
- 'every X seconds' (forever) → every_seconds=X
|
||||
- 'every X seconds for Y seconds' → EVERY_SECONDS=X AND IN_SECONDS=Y (creates multiple reminders, DO NOT use 'at')
|
||||
- 'at specific time' → at='ISO datetime' (only when user specifies exact time)
|
||||
|
||||
CRITICAL: For 'every X seconds for Y seconds', you MUST use both every_seconds AND in_seconds together. DO NOT use 'at' parameter for this pattern."""
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
@ -40,11 +52,11 @@ class CronTool(Tool):
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Reminder message (for add)"
|
||||
"description": "REQUIRED for 'add' action: The reminder message to send. Extract this from the user's request. Examples: 'Remind me to call mama' → message='call mama', 'Remind me every hour to drink water' → message='drink water', 'Schedule a task to check email' → message='check email'. Always extract the actual task/reminder text, not the full user request."
|
||||
},
|
||||
"every_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Interval in seconds (for recurring tasks)"
|
||||
"description": "Interval in seconds (for recurring tasks). For 'every X seconds for Y seconds', use BOTH every_seconds AND in_seconds together to create multiple reminders."
|
||||
},
|
||||
"cron_expr": {
|
||||
"type": "string",
|
||||
@ -56,11 +68,11 @@ class CronTool(Tool):
|
||||
},
|
||||
"at": {
|
||||
"type": "string",
|
||||
"description": "ISO datetime string for one-time execution. Format: YYYY-MM-DDTHH:MM:SS (e.g. '2026-03-03T12:19:30'). You MUST calculate this from the current time shown in your system prompt plus the requested seconds/minutes, then format as ISO string."
|
||||
"description": "ISO datetime string for one-time execution at a SPECIFIC time. Format: YYYY-MM-DDTHH:MM:SS (e.g. '2026-03-03T12:19:30'). ONLY use this when user specifies an exact time like 'at 3pm' or 'at 2026-03-03 14:30'. DO NOT use 'at' for 'every X seconds for Y seconds' - use every_seconds + in_seconds instead."
|
||||
},
|
||||
"in_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Alternative to 'at': Schedule reminder in N seconds from now. Use this instead of calculating 'at' manually. Example: in_seconds=25 for 'remind me in 25 seconds'."
|
||||
"description": "Schedule reminder in N seconds from now, OR duration for recurring reminders. Use this instead of calculating 'at' manually. Examples: 'remind me in 25 seconds' → in_seconds=25. For 'every 10 seconds for the next minute' → every_seconds=10 AND in_seconds=60 (creates 6 reminders)."
|
||||
},
|
||||
"reminder": {
|
||||
"type": "boolean",
|
||||
@ -111,7 +123,11 @@ class CronTool(Tool):
|
||||
reminder: bool = False,
|
||||
) -> str:
|
||||
if not message:
|
||||
return "Error: message is required for add"
|
||||
return "Error: message is required for 'add' action. You must extract the reminder/task text from the user's request. Example: if user says 'remind me to call mama', use message='call mama'. If user says 'remind me every hour to drink water', use message='drink water'."
|
||||
|
||||
# Detect common mistake: using 'at' with 'every_seconds' when 'in_seconds' should be used
|
||||
if every_seconds is not None and at is not None and in_seconds is None:
|
||||
return f"Error: You used 'at' with 'every_seconds', but for 'every X seconds for Y seconds' pattern, you MUST use 'in_seconds' instead of 'at'. Example: 'every 10 seconds for the next minute' → every_seconds=10 AND in_seconds=60 (NOT 'at'). The 'in_seconds' parameter specifies the duration, and the tool will create multiple reminders automatically."
|
||||
|
||||
# Use defaults for CLI mode if context not set
|
||||
channel = self._channel or "cli"
|
||||
@ -131,6 +147,34 @@ class CronTool(Tool):
|
||||
# Build schedule - prioritize 'in_seconds' for relative time, then 'at' for absolute time
|
||||
delete_after = False
|
||||
|
||||
# Special case: recurring job with duration limit (every_seconds + in_seconds)
|
||||
if every_seconds is not None and in_seconds is not None:
|
||||
# Create multiple one-time jobs for "every X seconds for Y seconds"
|
||||
from datetime import datetime, timedelta
|
||||
num_jobs = max(1, in_seconds // every_seconds)
|
||||
results = []
|
||||
for i in range(num_jobs):
|
||||
job_time = datetime.now() + timedelta(seconds=i * every_seconds)
|
||||
job_at = job_time.isoformat()
|
||||
try:
|
||||
dt = datetime.fromisoformat(job_at)
|
||||
at_ms = int(dt.timestamp() * 1000)
|
||||
schedule = CronSchedule(kind="at", at_ms=at_ms)
|
||||
job = self._cron.add_job(
|
||||
name=f"{message[:25]} ({i+1}/{num_jobs})" if num_jobs > 1 else message[:30],
|
||||
schedule=schedule,
|
||||
message=message,
|
||||
deliver=True,
|
||||
channel=channel,
|
||||
to=chat_id,
|
||||
delete_after_run=True,
|
||||
reminder=reminder,
|
||||
)
|
||||
results.append(f"Created job '{job.name}' (id: {job.id})")
|
||||
except Exception as e:
|
||||
results.append(f"Error creating job {i+1}: {str(e)}")
|
||||
return f"Created {len([r for r in results if 'Created' in r])} reminder(s):\n" + "\n".join(results)
|
||||
|
||||
# Handle relative time (in_seconds) - compute datetime automatically
|
||||
if in_seconds is not None:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
346
nanobot/agent/tools/email.py
Normal file
346
nanobot/agent/tools/email.py
Normal file
@ -0,0 +1,346 @@
|
||||
"""Email tool: read emails from IMAP mailbox."""
|
||||
|
||||
import asyncio
|
||||
import imaplib
|
||||
import ssl
|
||||
from datetime import date
|
||||
from email import policy
|
||||
from email.header import decode_header, make_header
|
||||
from email.parser import BytesParser
|
||||
from email.utils import parseaddr
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
|
||||
class EmailTool(Tool):
|
||||
"""Read emails from configured IMAP mailbox."""
|
||||
|
||||
name = "read_emails"
|
||||
description = "USE THIS TOOL FOR ALL EMAIL QUERIES. When user asks about emails, latest email, email sender, inbox, etc., you MUST call read_emails(). DO NOT use exec() with mail/tail/awk commands. DO NOT use read_file() on /var/mail or memory files. DO NOT try alternative methods. This is the ONLY way to read emails - it connects to IMAP and fetches real-time data. For 'latest email' queries, use limit=1. CRITICAL: When user asks for specific fields like 'From and Subject' or 'sender and subject', extract and return ONLY those fields from the tool output. Do NOT summarize or analyze the email body content unless the user specifically asks for it. If user asks 'give me the from and subject', respond with just: 'From: [email] Subject: [subject]'. Parameters: limit (1-50, default 10, use 1 for latest), unread_only (bool, default false), mark_seen (bool, default false). Returns formatted email list with sender, subject, date, and body."
|
||||
|
||||
def __init__(self, email_config: Any = None):
|
||||
"""
|
||||
Initialize email tool with email configuration.
|
||||
|
||||
Args:
|
||||
email_config: Optional EmailConfig instance. If None, loads from config.
|
||||
"""
|
||||
self._email_config = email_config
|
||||
|
||||
@property
|
||||
def config(self) -> Any:
|
||||
"""Lazy load email config if not provided."""
|
||||
if self._email_config is None:
|
||||
from nanobot.config.loader import load_config
|
||||
config = load_config()
|
||||
self._email_config = config.channels.email
|
||||
return self._email_config
|
||||
|
||||
def coerce_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Coerce parameters, handling common name mismatches."""
|
||||
coerced = super().coerce_params(params)
|
||||
# Map 'count' to 'limit' if limit not present
|
||||
if 'count' in coerced and 'limit' not in coerced:
|
||||
try:
|
||||
coerced['limit'] = int(coerced.pop('count'))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Remove unsupported parameters
|
||||
supported = {'limit', 'unread_only', 'mark_seen'}
|
||||
coerced = {k: v for k, v in coerced.items() if k in supported}
|
||||
return coerced
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of emails to return. REQUIRED for 'latest email' queries - always use limit=1. For multiple emails use limit=5, limit=10, etc. (default: 10, max: 50)",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
},
|
||||
"unread_only": {
|
||||
"type": "boolean",
|
||||
"description": "If true, only return unread emails. If false, returns all emails including read ones (default: false)",
|
||||
},
|
||||
"mark_seen": {
|
||||
"type": "boolean",
|
||||
"description": "If true, mark emails as read after fetching. If false, leave read/unread status unchanged (default: false)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
async def execute(self, limit: int = 10, unread_only: bool = False, mark_seen: bool = False, **kwargs: Any) -> str:
|
||||
"""
|
||||
Read emails from IMAP mailbox.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of emails to return (use limit=1 for latest email)
|
||||
unread_only: If true, only fetch unread emails
|
||||
mark_seen: If true, mark emails as read after fetching
|
||||
**kwargs: Ignore any extra parameters (like count, sort_by, direction)
|
||||
|
||||
Returns:
|
||||
Formatted string with email information
|
||||
"""
|
||||
# Handle common parameter name mismatches (agent sometimes uses 'count' instead of 'limit')
|
||||
# Also handle if count is passed as a positional argument via kwargs
|
||||
if 'count' in kwargs:
|
||||
try:
|
||||
limit = int(kwargs['count'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Also check if limit was passed in kwargs (in case it wasn't a named parameter)
|
||||
if 'limit' in kwargs:
|
||||
try:
|
||||
limit = int(kwargs['limit'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Ignore unsupported parameters like sort_by, direction, reverse, etc.
|
||||
try:
|
||||
config = self.config
|
||||
except Exception as e:
|
||||
return f"Error loading email configuration: {str(e)}"
|
||||
|
||||
if not config:
|
||||
return "Error: Email configuration not found"
|
||||
|
||||
if not hasattr(config, 'enabled') or not config.enabled:
|
||||
return "Error: Email channel is not enabled in configuration. Set NANOBOT_CHANNELS__EMAIL__ENABLED=true"
|
||||
|
||||
if not hasattr(config, 'consent_granted') or not config.consent_granted:
|
||||
return "Error: Email access consent not granted. Set NANOBOT_CHANNELS__EMAIL__CONSENT_GRANTED=true"
|
||||
|
||||
if not hasattr(config, 'imap_host') or not config.imap_host:
|
||||
return "Error: IMAP host not configured. Set NANOBOT_CHANNELS__EMAIL__IMAP_HOST"
|
||||
|
||||
if not hasattr(config, 'imap_username') or not config.imap_username:
|
||||
return "Error: IMAP username not configured. Set NANOBOT_CHANNELS__EMAIL__IMAP_USERNAME"
|
||||
|
||||
if not hasattr(config, 'imap_password') or not config.imap_password:
|
||||
return "Error: IMAP password not configured. Set NANOBOT_CHANNELS__EMAIL__IMAP_PASSWORD"
|
||||
|
||||
# Limit to reasonable maximum
|
||||
try:
|
||||
limit = min(max(1, int(limit)), 50)
|
||||
except (ValueError, TypeError):
|
||||
limit = 10
|
||||
|
||||
try:
|
||||
messages = await asyncio.to_thread(
|
||||
self._fetch_messages,
|
||||
unread_only=unread_only,
|
||||
mark_seen=mark_seen,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if not messages:
|
||||
if unread_only:
|
||||
return "No unread emails found in your inbox."
|
||||
else:
|
||||
return f"No emails found in your inbox. The mailbox appears to be empty or there was an issue retrieving emails."
|
||||
|
||||
result_parts = [f"Found {len(messages)} email(s):\n"]
|
||||
for i, msg in enumerate(messages, 1):
|
||||
result_parts.append(f"\n--- Email {i} ---")
|
||||
result_parts.append(f"From: {msg['sender']}")
|
||||
result_parts.append(f"Subject: {msg['subject']}")
|
||||
result_parts.append(f"Date: {msg['metadata']['date']}")
|
||||
# Only include body content if specifically requested, otherwise keep it brief
|
||||
result_parts.append(f"\nBody: {msg['content'][:500]}..." if len(msg['content']) > 500 else f"\nBody: {msg['content']}")
|
||||
|
||||
return "\n".join(result_parts)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
return f"Error reading emails: {str(e)}\n\nDetails: {error_details}"
|
||||
|
||||
def _fetch_messages(
|
||||
self,
|
||||
unread_only: bool,
|
||||
mark_seen: bool,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch messages from IMAP mailbox."""
|
||||
messages: list[dict[str, Any]] = []
|
||||
mailbox = self.config.imap_mailbox or "INBOX"
|
||||
|
||||
# Build search criteria
|
||||
if unread_only:
|
||||
search_criteria = ("UNSEEN",)
|
||||
else:
|
||||
search_criteria = ("ALL",)
|
||||
|
||||
# Connect to IMAP server
|
||||
try:
|
||||
if self.config.imap_use_ssl:
|
||||
client = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
|
||||
else:
|
||||
client = imaplib.IMAP4(self.config.imap_host, self.config.imap_port)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to connect to IMAP server {self.config.imap_host}:{self.config.imap_port}: {str(e)}")
|
||||
|
||||
try:
|
||||
client.login(self.config.imap_username, self.config.imap_password.strip())
|
||||
except imaplib.IMAP4.error as e:
|
||||
error_msg = str(e)
|
||||
if "AUTHENTICATE" in error_msg.upper() or "LOGIN" in error_msg.upper():
|
||||
raise Exception(
|
||||
f"IMAP authentication failed. Please check:\n"
|
||||
f"1. Your email username: {self.config.imap_username}\n"
|
||||
f"2. Your password/app password is correct\n"
|
||||
f"3. For Gmail: Enable 2-Step Verification and create an App Password at https://myaccount.google.com/apppasswords\n"
|
||||
f"4. IMAP is enabled in your email account settings\n"
|
||||
f"Original error: {error_msg}"
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
status, _ = client.select(mailbox)
|
||||
if status != "OK":
|
||||
return messages
|
||||
|
||||
status, data = client.search(None, *search_criteria)
|
||||
if status != "OK" or not data:
|
||||
return messages
|
||||
|
||||
ids = data[0].split()
|
||||
if limit > 0 and len(ids) > limit:
|
||||
# Get most recent emails (last N)
|
||||
ids = ids[-limit:]
|
||||
|
||||
for imap_id in ids:
|
||||
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
|
||||
if status != "OK" or not fetched:
|
||||
continue
|
||||
|
||||
raw_bytes = self._extract_message_bytes(fetched)
|
||||
if raw_bytes is None:
|
||||
continue
|
||||
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
|
||||
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
|
||||
if not sender:
|
||||
# Try to get display name if email not found
|
||||
from_addr = parsed.get("From", "")
|
||||
sender = from_addr if from_addr else "unknown"
|
||||
|
||||
subject = self._decode_header_value(parsed.get("Subject", ""))
|
||||
date_value = parsed.get("Date", "")
|
||||
message_id = parsed.get("Message-ID", "").strip()
|
||||
body = self._extract_text_body(parsed)
|
||||
|
||||
if not body:
|
||||
body = "(empty email body)"
|
||||
|
||||
# Limit body length
|
||||
max_chars = getattr(self.config, 'max_body_chars', 12000)
|
||||
body = body[:max_chars]
|
||||
|
||||
content = (
|
||||
f"Email received.\n"
|
||||
f"From: {sender}\n"
|
||||
f"Subject: {subject}\n"
|
||||
f"Date: {date_value}\n\n"
|
||||
f"{body}"
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"message_id": message_id,
|
||||
"subject": subject,
|
||||
"date": date_value,
|
||||
"sender_email": sender,
|
||||
}
|
||||
|
||||
messages.append({
|
||||
"sender": sender,
|
||||
"subject": subject,
|
||||
"message_id": message_id,
|
||||
"content": content,
|
||||
"metadata": metadata,
|
||||
})
|
||||
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
|
||||
"""Extract raw message bytes from IMAP fetch response."""
|
||||
for item in fetched:
|
||||
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)):
|
||||
return bytes(item[1])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _decode_header_value(value: str) -> str:
|
||||
"""Decode email header value (handles encoded words)."""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return str(make_header(decode_header(value)))
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_body(msg: Any) -> str:
|
||||
"""Extract readable text body from email message."""
|
||||
if msg.is_multipart():
|
||||
plain_parts: list[str] = []
|
||||
html_parts: list[str] = []
|
||||
for part in msg.walk():
|
||||
if part.get_content_disposition() == "attachment":
|
||||
continue
|
||||
content_type = part.get_content_type()
|
||||
try:
|
||||
payload = part.get_content()
|
||||
except Exception:
|
||||
payload_bytes = part.get_payload(decode=True) or b""
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
payload = payload_bytes.decode(charset, errors="replace")
|
||||
if not isinstance(payload, str):
|
||||
continue
|
||||
if content_type == "text/plain":
|
||||
plain_parts.append(payload)
|
||||
elif content_type == "text/html":
|
||||
html_parts.append(payload)
|
||||
if plain_parts:
|
||||
return "\n\n".join(plain_parts).strip()
|
||||
if html_parts:
|
||||
# Simple HTML to text conversion
|
||||
import re
|
||||
import html
|
||||
text = re.sub(r"<\s*br\s*/?>", "\n", "\n\n".join(html_parts), flags=re.IGNORECASE)
|
||||
text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
return html.unescape(text).strip()
|
||||
return ""
|
||||
|
||||
try:
|
||||
payload = msg.get_content()
|
||||
except Exception:
|
||||
payload_bytes = msg.get_payload(decode=True) or b""
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
payload = payload_bytes.decode(charset, errors="replace")
|
||||
if not isinstance(payload, str):
|
||||
return ""
|
||||
if msg.get_content_type() == "text/html":
|
||||
import re
|
||||
import html
|
||||
text = re.sub(r"<\s*br\s*/?>", "\n", payload, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
return html.unescape(text).strip()
|
||||
return payload.strip()
|
||||
|
||||
@ -46,6 +46,7 @@ class ExecTool(Tool):
|
||||
IMPORTANT:
|
||||
- For READING files (including PDFs, text files, etc.), ALWAYS use read_file FIRST. Do NOT use exec to read files.
|
||||
- Only use exec for complex data processing AFTER you have already read the file content using read_file.
|
||||
- For git commands (git commit, git push, git status, etc.), ALWAYS use exec tool, NOT write_file or edit_file.
|
||||
|
||||
For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
|
||||
- Excel files: python3 -c "import pandas as pd; df = pd.read_excel('file.xlsx'); result = df['Column Name'].sum(); print(result)"
|
||||
@ -53,7 +54,13 @@ For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
|
||||
- NEVER use pandas/openpyxl as command-line tools (they don't exist)
|
||||
- NEVER use non-existent tools like csvcalc, xlsxcalc, etc.
|
||||
- For calculations: Use pandas operations like .sum(), .mean(), .max(), etc.
|
||||
- For total inventory value: (df['Unit Price'] * df['Quantity']).sum()"""
|
||||
- For total inventory value: (df['Unit Price'] * df['Quantity']).sum()
|
||||
|
||||
For git operations:
|
||||
- git commit: exec(command="git commit -m 'message'")
|
||||
- git status: exec(command="git status")
|
||||
- git push: exec(command="git push")
|
||||
- NEVER use write_file or edit_file for git commands"""
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
@ -74,6 +81,10 @@ For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
|
||||
|
||||
async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
|
||||
cwd = working_dir or self.working_dir or os.getcwd()
|
||||
|
||||
# Sanitize Gitea API URLs: convert HTTPS to HTTP for 10.0.30.169:3000
|
||||
command = self._sanitize_gitea_urls(command)
|
||||
|
||||
guard_error = self._guard_command(command, cwd)
|
||||
if guard_error:
|
||||
return guard_error
|
||||
@ -83,11 +94,14 @@ For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
|
||||
logger.debug(f"ExecTool: command={command[:200]}, cwd={cwd}, working_dir={working_dir}")
|
||||
|
||||
try:
|
||||
# Ensure environment variables are available (including from .env file)
|
||||
env = os.environ.copy()
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
)
|
||||
|
||||
try:
|
||||
@ -200,3 +214,33 @@ For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
|
||||
return "Error: Command blocked by safety guard (path outside working dir)"
|
||||
|
||||
return None
|
||||
|
||||
def _sanitize_gitea_urls(self, command: str) -> str:
|
||||
"""
|
||||
Sanitize Gitea API URLs in curl commands: convert HTTPS to HTTP.
|
||||
|
||||
Gitea API at 10.0.30.169:3000 runs on HTTP, not HTTPS.
|
||||
This prevents SSL/TLS errors when the agent generates HTTPS URLs.
|
||||
"""
|
||||
# Pattern to match https://10.0.30.169:3000/api/... in curl commands
|
||||
# This handles various curl formats:
|
||||
# - curl "https://10.0.30.169:3000/api/..."
|
||||
# - curl -X GET https://10.0.30.169:3000/api/...
|
||||
# - curl -H "..." "https://10.0.30.169:3000/api/..."
|
||||
# Matches URLs with or without quotes, and captures the full path
|
||||
pattern = r'https://10\.0\.30\.169:3000(/api/[^\s"\']*)'
|
||||
|
||||
def replace_url(match):
|
||||
path = match.group(1)
|
||||
return f'http://10.0.30.169:3000{path}'
|
||||
|
||||
sanitized = re.sub(pattern, replace_url, command)
|
||||
|
||||
# Log if we made a change
|
||||
if sanitized != command:
|
||||
from loguru import logger
|
||||
logger.info(f"ExecTool: Sanitized Gitea API URL (HTTPS -> HTTP)")
|
||||
logger.debug(f"Original: {command[:200]}...")
|
||||
logger.debug(f"Sanitized: {sanitized[:200]}...")
|
||||
|
||||
return sanitized
|
||||
|
||||
@ -35,7 +35,16 @@ class SpawnTool(Tool):
|
||||
return (
|
||||
"Spawn a subagent to handle a task in the background. "
|
||||
"Use this for complex or time-consuming tasks that can run independently. "
|
||||
"The subagent will complete the task and report back when done."
|
||||
"The subagent will complete the task and report back when done.\n\n"
|
||||
"CRITICAL: The 'task' parameter MUST be a natural language description of what to do, "
|
||||
"NOT a tool call. The subagent will figure out how to accomplish the task using its own tools.\n\n"
|
||||
"CORRECT examples:\n"
|
||||
"- task='Read all documentation files in the project and create a summary'\n"
|
||||
"- task='Analyze the codebase structure and generate a report'\n"
|
||||
"- task='Search for information about X and compile findings'\n\n"
|
||||
"WRONG (do not use tool call syntax):\n"
|
||||
"- task='read_dir(path=\"/path/to/file\")' ❌\n"
|
||||
"- task='read_file(path=\"file.txt\")' ❌"
|
||||
)
|
||||
|
||||
@property
|
||||
@ -45,7 +54,12 @@ class SpawnTool(Tool):
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The task for the subagent to complete",
|
||||
"description": (
|
||||
"A natural language description of the task for the subagent to complete. "
|
||||
"DO NOT use tool call syntax. Examples: 'Read all documentation and summarize', "
|
||||
"'Analyze the codebase structure', 'Search the web for X and compile findings'. "
|
||||
"The subagent will determine which tools to use to accomplish this task."
|
||||
),
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
|
||||
@ -101,9 +101,9 @@ class WebSearchTool(Tool):
|
||||
try:
|
||||
n = min(max(count or self.max_results, 1), 10)
|
||||
|
||||
# Try using duckduckgo_search library if available
|
||||
# Try using ddgs library if available (renamed from duckduckgo_search)
|
||||
try:
|
||||
from duckduckgo_search import DDGS
|
||||
from ddgs import DDGS
|
||||
with DDGS() as ddgs:
|
||||
results = []
|
||||
for r in ddgs.text(query, max_results=n):
|
||||
@ -112,7 +112,7 @@ class WebSearchTool(Tool):
|
||||
"url": r.get("href", ""),
|
||||
"description": r.get("body", "")
|
||||
})
|
||||
|
||||
|
||||
if not results:
|
||||
return f"No results found for: {query}"
|
||||
|
||||
@ -123,51 +123,58 @@ class WebSearchTool(Tool):
|
||||
lines.append(f" {item['description']}")
|
||||
return "\n".join(lines)
|
||||
except ImportError:
|
||||
# Fallback: use DuckDuckGo instant answer API (simpler, but limited)
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=15.0
|
||||
) as client:
|
||||
# Use DuckDuckGo instant answer API (no key needed)
|
||||
url = "https://api.duckduckgo.com/"
|
||||
r = await client.get(
|
||||
url,
|
||||
params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"},
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
results = []
|
||||
# Get RelatedTopics (search results)
|
||||
if "RelatedTopics" in data:
|
||||
for topic in data["RelatedTopics"][:n]:
|
||||
if "Text" in topic and "FirstURL" in topic:
|
||||
results.append({
|
||||
"title": topic.get("Text", "").split(" - ")[0] if " - " in topic.get("Text", "") else topic.get("Text", "")[:50],
|
||||
"url": topic.get("FirstURL", ""),
|
||||
"description": topic.get("Text", "")
|
||||
})
|
||||
|
||||
# Also check AbstractText for direct answer
|
||||
if "AbstractText" in data and data["AbstractText"]:
|
||||
results.insert(0, {
|
||||
"title": data.get("Heading", query),
|
||||
"url": data.get("AbstractURL", ""),
|
||||
"description": data.get("AbstractText", "")
|
||||
})
|
||||
|
||||
if not results:
|
||||
return f"No results found for: {query}. Try installing 'duckduckgo-search' package for better results: pip install duckduckgo-search"
|
||||
|
||||
lines = [f"Results for: {query}\n"]
|
||||
for i, item in enumerate(results[:n], 1):
|
||||
lines.append(f"{i}. {item['title']}\n {item['url']}")
|
||||
if item['description']:
|
||||
lines.append(f" {item['description']}")
|
||||
return "\n".join(lines)
|
||||
# ddgs package not installed, fall through to fallback
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log ddgs errors but fall through to fallback API
|
||||
import logging
|
||||
logging.debug(f"ddgs search error: {e}")
|
||||
|
||||
# Fallback: use DuckDuckGo instant answer API (simpler, but limited)
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=15.0
|
||||
) as client:
|
||||
# Use DuckDuckGo instant answer API (no key needed)
|
||||
url = "https://api.duckduckgo.com/"
|
||||
r = await client.get(
|
||||
url,
|
||||
params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"},
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
results = []
|
||||
# Get RelatedTopics (search results)
|
||||
if "RelatedTopics" in data:
|
||||
for topic in data["RelatedTopics"][:n]:
|
||||
if "Text" in topic and "FirstURL" in topic:
|
||||
results.append({
|
||||
"title": topic.get("Text", "").split(" - ")[0] if " - " in topic.get("Text", "") else topic.get("Text", "")[:50],
|
||||
"url": topic.get("FirstURL", ""),
|
||||
"description": topic.get("Text", "")
|
||||
})
|
||||
|
||||
# Also check AbstractText for direct answer
|
||||
if "AbstractText" in data and data["AbstractText"]:
|
||||
results.insert(0, {
|
||||
"title": data.get("Heading", query),
|
||||
"url": data.get("AbstractURL", ""),
|
||||
"description": data.get("AbstractText", "")
|
||||
})
|
||||
|
||||
if not results:
|
||||
return f"No results found for: {query}. Try installing 'ddgs' package for better results: pip install ddgs"
|
||||
|
||||
lines = [f"Results for: {query}\n"]
|
||||
for i, item in enumerate(results[:n], 1):
|
||||
lines.append(f"{i}. {item['title']}\n {item['url']}")
|
||||
if item['description']:
|
||||
lines.append(f" {item['description']}")
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
return f"Error searching: {e}. Try installing 'duckduckgo-search' package: pip install duckduckgo-search"
|
||||
return f"Error searching: {e}. Try installing 'ddgs' package: pip install ddgs"
|
||||
|
||||
|
||||
class WebFetchTool(Tool):
|
||||
|
||||
228
nanobot/agent/utils/email_parser.py
Normal file
228
nanobot/agent/utils/email_parser.py
Normal file
@ -0,0 +1,228 @@
|
||||
"""Email parsing utilities for extracting meeting information."""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def extract_meeting_info(email_content: str, email_subject: str = "") -> dict[str, Any] | None:
|
||||
"""
|
||||
Extract meeting information from email content and subject.
|
||||
|
||||
Args:
|
||||
email_content: Email body text
|
||||
email_subject: Email subject line
|
||||
|
||||
Returns:
|
||||
Dictionary with meeting details if found, None otherwise
|
||||
"""
|
||||
text = (email_subject + " " + email_content).lower()
|
||||
|
||||
# Check for meeting-related keywords
|
||||
meeting_keywords = [
|
||||
"meeting",
|
||||
"appointment",
|
||||
"call",
|
||||
"conference",
|
||||
"standup",
|
||||
"stand-up",
|
||||
"sync",
|
||||
"discussion",
|
||||
"catch up",
|
||||
"catch-up",
|
||||
]
|
||||
|
||||
has_meeting_keyword = any(keyword in text for keyword in meeting_keywords)
|
||||
if not has_meeting_keyword:
|
||||
return None
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"title": None,
|
||||
"start_time": None,
|
||||
"end_time": None,
|
||||
"location": None,
|
||||
"attendees": [],
|
||||
}
|
||||
|
||||
# Extract title from subject or first line
|
||||
if email_subject:
|
||||
# Remove common prefixes
|
||||
title = email_subject
|
||||
for prefix in ["Re:", "Fwd:", "FW:"]:
|
||||
if title.lower().startswith(prefix.lower()):
|
||||
title = title[len(prefix) :].strip()
|
||||
result["title"] = title[:100] # Limit length
|
||||
|
||||
# Extract time information
|
||||
# Month names mapping
|
||||
month_names = {
|
||||
"january": 1, "jan": 1,
|
||||
"february": 2, "feb": 2,
|
||||
"march": 3, "mar": 3,
|
||||
"april": 4, "apr": 4,
|
||||
"may": 5,
|
||||
"june": 6, "jun": 6,
|
||||
"july": 7, "jul": 7,
|
||||
"august": 8, "aug": 8,
|
||||
"september": 9, "sep": 9, "sept": 9,
|
||||
"october": 10, "oct": 10,
|
||||
"november": 11, "nov": 11,
|
||||
"december": 12, "dec": 12,
|
||||
}
|
||||
|
||||
time_patterns = [
|
||||
# "March 7 at 15:00" or "March 7th at 3pm" or "on March 7 at 15:00"
|
||||
r"(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?\s+(?:at\s+)?(\d{1,2}):(\d{2})",
|
||||
r"(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?\s+(?:at\s+)?(\d{1,2})\s*(am|pm)",
|
||||
# "March 7" (date only, assume current year)
|
||||
r"(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?",
|
||||
# Relative dates
|
||||
r"tomorrow\s+at\s+(\d{1,2})\s*(am|pm)?",
|
||||
r"tomorrow\s+(\d{1,2})\s*(am|pm)?",
|
||||
r"(\d{1,2})\s*(am|pm)\s+tomorrow",
|
||||
r"(\d{1,2}):(\d{2})\s*(am|pm)?\s+tomorrow",
|
||||
r"in\s+(\d+)\s+(hour|hours|minute|minutes|day|days)",
|
||||
# Date formats
|
||||
r"(\d{1,2})/(\d{1,2})/(\d{4})\s+at\s+(\d{1,2}):(\d{2})\s*(am|pm)?",
|
||||
r"(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})",
|
||||
]
|
||||
|
||||
for pattern in time_patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
try:
|
||||
now = datetime.now()
|
||||
groups = match.groups()
|
||||
|
||||
# Check if this is a month name pattern (first group is month name)
|
||||
if groups and groups[0].lower() in month_names:
|
||||
month_name = groups[0].lower()
|
||||
month = month_names[month_name]
|
||||
day = int(groups[1])
|
||||
year = now.year
|
||||
|
||||
# Check if date is in the past (assume next year if so)
|
||||
test_date = datetime(year, month, day)
|
||||
if test_date < now.replace(hour=0, minute=0, second=0, microsecond=0):
|
||||
year += 1
|
||||
|
||||
# Check if time is provided (pattern with 4 groups means time included)
|
||||
if len(groups) >= 4 and groups[2] and groups[3]:
|
||||
# Check if groups[3] is am/pm or minutes
|
||||
if groups[3].lower() in ['am', 'pm']:
|
||||
# Format: "March 7 at 3pm" (12-hour with am/pm)
|
||||
hour = int(groups[2])
|
||||
period = groups[3].lower()
|
||||
minute = 0
|
||||
if period == "pm" and hour != 12:
|
||||
hour += 12
|
||||
elif period == "am" and hour == 12:
|
||||
hour = 0
|
||||
else:
|
||||
# Format: "March 7 at 15:00" (24-hour with colon)
|
||||
# groups[2] = hour, groups[3] = minute
|
||||
hour = int(groups[2])
|
||||
minute = int(groups[3])
|
||||
result["start_time"] = datetime(year, month, day, hour, minute)
|
||||
else:
|
||||
# Date only, default to 9am
|
||||
result["start_time"] = datetime(year, month, day, 9, 0)
|
||||
break
|
||||
elif "tomorrow" in pattern:
|
||||
base_date = now + timedelta(days=1)
|
||||
hour = int(match.group(1))
|
||||
period = match.group(2) if len(match.groups()) > 1 else None
|
||||
if period:
|
||||
if period.lower() == "pm" and hour != 12:
|
||||
hour += 12
|
||||
elif period.lower() == "am" and hour == 12:
|
||||
hour = 0
|
||||
result["start_time"] = base_date.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
break
|
||||
elif "in" in pattern:
|
||||
amount = int(match.group(1))
|
||||
unit = match.group(2)
|
||||
if "hour" in unit:
|
||||
result["start_time"] = now + timedelta(hours=amount)
|
||||
elif "minute" in unit:
|
||||
result["start_time"] = now + timedelta(minutes=amount)
|
||||
elif "day" in unit:
|
||||
result["start_time"] = now + timedelta(days=amount)
|
||||
break
|
||||
except (ValueError, IndexError, AttributeError):
|
||||
continue
|
||||
|
||||
# Extract location
|
||||
location_patterns = [
|
||||
r"location[:\s]+([^\n]+)",
|
||||
r"where[:\s]+([^\n]+)",
|
||||
r"at\s+([A-Z][^\n]+)", # Capitalized location names
|
||||
r"room\s+([A-Z0-9]+)",
|
||||
]
|
||||
|
||||
for pattern in location_patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
location = match.group(1).strip()
|
||||
if len(location) < 100: # Reasonable length
|
||||
result["location"] = location
|
||||
break
|
||||
|
||||
# Extract attendees (email addresses)
|
||||
email_pattern = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
|
||||
emails = re.findall(email_pattern, email_content)
|
||||
if emails:
|
||||
result["attendees"] = list(set(emails)) # Remove duplicates
|
||||
|
||||
# Only return if we found at least a title or time
|
||||
if result["title"] or result["start_time"]:
|
||||
logger.info(f"Extracted meeting info: {result}")
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_meeting_for_calendar(meeting_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Format meeting info for calendar tool.
|
||||
|
||||
Args:
|
||||
meeting_info: Meeting information dictionary
|
||||
|
||||
Returns:
|
||||
Formatted dictionary for calendar.create_event
|
||||
"""
|
||||
formatted: dict[str, Any] = {
|
||||
"action": "create_event",
|
||||
}
|
||||
|
||||
if meeting_info.get("title"):
|
||||
formatted["title"] = meeting_info["title"]
|
||||
else:
|
||||
formatted["title"] = "Meeting"
|
||||
|
||||
if meeting_info.get("start_time"):
|
||||
if isinstance(meeting_info["start_time"], datetime):
|
||||
formatted["start_time"] = meeting_info["start_time"].isoformat()
|
||||
else:
|
||||
formatted["start_time"] = str(meeting_info["start_time"])
|
||||
|
||||
if meeting_info.get("end_time"):
|
||||
if isinstance(meeting_info["end_time"], datetime):
|
||||
formatted["end_time"] = meeting_info["end_time"].isoformat()
|
||||
else:
|
||||
formatted["end_time"] = str(meeting_info["end_time"])
|
||||
|
||||
if meeting_info.get("location"):
|
||||
formatted["location"] = meeting_info["location"]
|
||||
|
||||
if meeting_info.get("description"):
|
||||
formatted["description"] = meeting_info["description"]
|
||||
|
||||
if meeting_info.get("attendees"):
|
||||
formatted["attendees"] = meeting_info["attendees"]
|
||||
|
||||
return formatted
|
||||
|
||||
@ -6,6 +6,7 @@ import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
import uuid
|
||||
from datetime import date
|
||||
from email import policy
|
||||
from email.header import decode_header, make_header
|
||||
@ -57,6 +58,8 @@ class EmailChannel(BaseChannel):
|
||||
self._last_message_id_by_chat: dict[str, str] = {}
|
||||
self._processed_uids: set[str] = set() # Capped to prevent unbounded growth
|
||||
self._MAX_PROCESSED_UIDS = 100000
|
||||
self._sent_message_ids: set[str] = set() # Track Message-IDs of emails we sent to prevent feedback loops
|
||||
self._MAX_SENT_MESSAGE_IDS = 10000
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start polling IMAP for inbound emails."""
|
||||
@ -134,6 +137,12 @@ class EmailChannel(BaseChannel):
|
||||
email_msg["To"] = to_addr
|
||||
email_msg["Subject"] = subject
|
||||
email_msg.set_content(msg.content or "")
|
||||
|
||||
# Generate a Message-ID for the email we're sending (to track and prevent feedback loops)
|
||||
from_email = email_msg["From"]
|
||||
domain = from_email.split("@")[-1] if "@" in from_email else "nanobot.local"
|
||||
message_id = f"<{uuid.uuid4()}@{domain}>"
|
||||
email_msg["Message-ID"] = message_id
|
||||
|
||||
in_reply_to = self._last_message_id_by_chat.get(to_addr)
|
||||
if in_reply_to:
|
||||
@ -142,6 +151,13 @@ class EmailChannel(BaseChannel):
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(self._smtp_send, email_msg)
|
||||
# Track this Message-ID so we can ignore replies to it (prevent feedback loops)
|
||||
self._sent_message_ids.add(message_id)
|
||||
# Trim if too large
|
||||
if len(self._sent_message_ids) > self._MAX_SENT_MESSAGE_IDS:
|
||||
# Remove oldest entries (simple approach: keep recent ones)
|
||||
self._sent_message_ids.clear()
|
||||
logger.debug(f"Sent email with Message-ID: {message_id} to {to_addr}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email to {to_addr}: {e}")
|
||||
raise
|
||||
@ -248,6 +264,10 @@ class EmailChannel(BaseChannel):
|
||||
ids = data[0].split()
|
||||
if limit > 0 and len(ids) > limit:
|
||||
ids = ids[-limit:]
|
||||
|
||||
our_email = (self.config.from_address or self.config.smtp_username or self.config.imap_username).strip().lower()
|
||||
skipped_count = 0
|
||||
|
||||
for imap_id in ids:
|
||||
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
|
||||
if status != "OK" or not fetched:
|
||||
@ -265,10 +285,46 @@ class EmailChannel(BaseChannel):
|
||||
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
|
||||
if not sender:
|
||||
continue
|
||||
|
||||
|
||||
# Skip emails from ourselves (prevent feedback loops)
|
||||
if sender == our_email:
|
||||
# Track skipped UIDs to avoid reprocessing
|
||||
if uid and dedupe:
|
||||
self._processed_uids.add(uid)
|
||||
# Trim if too large
|
||||
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
|
||||
# Remove oldest entries (simple approach: keep recent ones)
|
||||
self._processed_uids.clear()
|
||||
# Mark as seen so it doesn't keep appearing in UNSEEN searches
|
||||
if mark_seen:
|
||||
try:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
except Exception:
|
||||
pass
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
subject = self._decode_header_value(parsed.get("Subject", ""))
|
||||
date_value = parsed.get("Date", "")
|
||||
message_id = parsed.get("Message-ID", "").strip()
|
||||
in_reply_to = parsed.get("In-Reply-To", "").strip()
|
||||
|
||||
# Skip emails that are replies to emails we sent (prevent feedback loops)
|
||||
if in_reply_to and in_reply_to in self._sent_message_ids:
|
||||
# Track skipped UIDs to avoid reprocessing
|
||||
if uid and dedupe:
|
||||
self._processed_uids.add(uid)
|
||||
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
|
||||
self._processed_uids.clear()
|
||||
# Mark as seen so it doesn't keep appearing in UNSEEN searches
|
||||
if mark_seen:
|
||||
try:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
except Exception:
|
||||
pass
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
body = self._extract_text_body(parsed)
|
||||
|
||||
if not body:
|
||||
@ -313,6 +369,10 @@ class EmailChannel(BaseChannel):
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log summary of skipped emails (only if significant number) - reduces log noise
|
||||
if skipped_count > 0:
|
||||
logger.debug(f"Skipped {skipped_count} email(s) from self or replies to our emails")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
@ -517,6 +517,7 @@ def agent(
|
||||
from nanobot.cron.service import CronService
|
||||
from loguru import logger
|
||||
|
||||
# Load config (this also loads .env file into environment)
|
||||
config = load_config()
|
||||
|
||||
bus = MessageBus()
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Configuration loading utilities."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
@ -17,6 +18,43 @@ def get_data_dir() -> Path:
|
||||
return get_data_path()
|
||||
|
||||
|
||||
def _load_env_file(workspace: Path | None = None) -> None:
|
||||
"""Load .env file from workspace directory if it exists."""
|
||||
if workspace:
|
||||
env_file = Path(workspace) / ".env"
|
||||
else:
|
||||
# Try current directory and workspace
|
||||
env_file = Path(".env")
|
||||
if not env_file.exists():
|
||||
# Try workspace directory
|
||||
try:
|
||||
from nanobot.utils.helpers import get_workspace_path
|
||||
workspace_path = get_workspace_path()
|
||||
env_file = workspace_path / ".env"
|
||||
except:
|
||||
pass
|
||||
|
||||
if env_file.exists():
|
||||
try:
|
||||
with open(env_file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip comments and empty lines
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# Parse KEY=VALUE format
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
# Only set if not already in environment
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
except Exception:
|
||||
# Silently fail if .env can't be loaded
|
||||
pass
|
||||
|
||||
|
||||
def load_config(config_path: Path | None = None) -> Config:
|
||||
"""
|
||||
Load configuration from file or create default.
|
||||
@ -27,6 +65,15 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
Returns:
|
||||
Loaded configuration object.
|
||||
"""
|
||||
# Load .env file before loading config (so env vars are available to pydantic)
|
||||
try:
|
||||
from nanobot.utils.helpers import get_workspace_path
|
||||
workspace = get_workspace_path()
|
||||
_load_env_file(workspace)
|
||||
except:
|
||||
# Fallback to current directory
|
||||
_load_env_file()
|
||||
|
||||
path = config_path or get_config_path()
|
||||
|
||||
if path.exists():
|
||||
|
||||
@ -244,6 +244,17 @@ class WebToolsConfig(Base):
|
||||
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
|
||||
|
||||
|
||||
class CalendarConfig(Base):
|
||||
"""Google Calendar configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
credentials_file: str = "" # Path to OAuth2 credentials JSON file
|
||||
token_file: str = "" # Path to store OAuth2 token (default: ~/.nanobot/calendar_token.json)
|
||||
calendar_id: str = "primary" # Calendar ID to use (default: primary calendar)
|
||||
auto_schedule_from_email: bool = True # Automatically schedule meetings from emails
|
||||
timezone: str = "UTC" # Timezone for parsing times (e.g., "America/New_York", "Europe/London", "UTC")
|
||||
|
||||
|
||||
class ExecToolConfig(Base):
|
||||
"""Shell exec tool configuration."""
|
||||
|
||||
@ -264,6 +275,7 @@ class ToolsConfig(Base):
|
||||
|
||||
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
||||
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
||||
calendar: CalendarConfig = Field(default_factory=CalendarConfig)
|
||||
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
|
||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@ -62,22 +62,37 @@ class CustomProvider(LLMProvider):
|
||||
# If no structured tool calls, try to parse from content (Ollama sometimes returns JSON in content)
|
||||
# Only parse if content looks like it contains a tool call JSON (to avoid false positives)
|
||||
content = msg.content or ""
|
||||
if not tool_calls and content and '"name"' in content and '"parameters"' in content:
|
||||
# Check for standard format: {"name": "...", "parameters": {...}}
|
||||
has_standard_format = '"name"' in content and '"parameters"' in content
|
||||
# Check for calendar tool format: {"action": "...", ...}
|
||||
has_calendar_format = '"action"' in content and ("calendar" in content.lower() or any(action in content for action in ["list_events", "create_event", "update_event", "delete_event"]))
|
||||
|
||||
if not tool_calls and content and (has_standard_format or has_calendar_format):
|
||||
import re
|
||||
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}}
|
||||
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...}
|
||||
# Find complete JSON objects by matching braces
|
||||
pattern = r'\{\s*"name"\s*:\s*"(\w+)"'
|
||||
# Try "action" pattern first (for calendar tool), then "name" pattern
|
||||
patterns = [
|
||||
(r'\{\s*"action"\s*:\s*"(\w+)"', "action"), # Calendar tool format
|
||||
(r'\{\s*"name"\s*:\s*"(\w+)"', "name"), # Standard format
|
||||
]
|
||||
start_pos = 0
|
||||
max_iterations = 5 # Safety limit
|
||||
max_iterations = 10 # Increased for multiple patterns
|
||||
iteration = 0
|
||||
while iteration < max_iterations:
|
||||
iteration += 1
|
||||
match = re.search(pattern, content[start_pos:])
|
||||
match = None
|
||||
pattern_type = None
|
||||
for pattern, ptype in patterns:
|
||||
match = re.search(pattern, content[start_pos:])
|
||||
if match:
|
||||
pattern_type = ptype
|
||||
break
|
||||
if not match:
|
||||
break
|
||||
|
||||
json_start = start_pos + match.start()
|
||||
name = match.group(1)
|
||||
key_value = match.group(1)
|
||||
|
||||
# Find the matching closing brace by counting braces
|
||||
brace_count = 0
|
||||
@ -98,8 +113,41 @@ class CustomProvider(LLMProvider):
|
||||
try:
|
||||
json_str = content[json_start:json_end]
|
||||
tool_obj = json_repair.loads(json_str)
|
||||
# Only accept if it has both name and parameters, and name is a valid tool name
|
||||
valid_tools = ["exec", "read_file", "write_file", "list_dir", "web_search"]
|
||||
|
||||
# Handle calendar tool format: {"action": "...", ...}
|
||||
if isinstance(tool_obj, dict) and "action" in tool_obj:
|
||||
# This is a calendar tool call in JSON format
|
||||
action = tool_obj.get("action")
|
||||
if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]:
|
||||
# Convert to calendar tool call format
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=f"call_{len(tool_calls)}",
|
||||
name="calendar",
|
||||
arguments=tool_obj # Pass the whole object as arguments
|
||||
))
|
||||
# Remove the tool call from content
|
||||
content = content[:json_start] + content[json_end:].strip()
|
||||
start_pos = json_start # Stay at same position since we removed text
|
||||
continue
|
||||
|
||||
# Handle standard format: {"name": "...", "parameters": {...}}
|
||||
# Note: This list should match tools registered in AgentLoop._register_default_tools()
|
||||
valid_tools = [
|
||||
# File tools
|
||||
"read_file", "write_file", "edit_file", "list_dir",
|
||||
# Shell tool
|
||||
"exec",
|
||||
# Web tools
|
||||
"web_search", "web_fetch",
|
||||
# Communication tools
|
||||
"message", "spawn",
|
||||
# Calendar tool
|
||||
"calendar",
|
||||
# Cron tool
|
||||
"cron",
|
||||
# Email tool
|
||||
"email",
|
||||
]
|
||||
if (isinstance(tool_obj, dict) and
|
||||
"name" in tool_obj and
|
||||
"parameters" in tool_obj and
|
||||
|
||||
73
nanobot/skills/calendar/SKILL.md
Normal file
73
nanobot/skills/calendar/SKILL.md
Normal file
@ -0,0 +1,73 @@
|
||||
---
|
||||
name: calendar
|
||||
description: "Interact with Google Calendar. Create events, list upcoming events, and check availability."
|
||||
---
|
||||
|
||||
# Calendar Skill
|
||||
|
||||
Use the `calendar` tool to interact with Google Calendar.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Enable Google Calendar API in Google Cloud Console
|
||||
2. Create OAuth2 credentials (Desktop app)
|
||||
3. Download credentials JSON file
|
||||
4. Configure in nanobot:
|
||||
```bash
|
||||
export NANOBOT_TOOLS__CALENDAR__ENABLED=true
|
||||
export NANOBOT_TOOLS__CALENDAR__CREDENTIALS_FILE=/path/to/credentials.json
|
||||
```
|
||||
|
||||
On first run, you'll be prompted to authorize access via OAuth flow.
|
||||
|
||||
## Actions
|
||||
|
||||
### List Events
|
||||
|
||||
List upcoming calendar events:
|
||||
```
|
||||
calendar(action="list_events", max_results=10)
|
||||
```
|
||||
|
||||
### Create Event
|
||||
|
||||
Create a new calendar event:
|
||||
```
|
||||
calendar(
|
||||
action="create_event",
|
||||
title="Team Meeting",
|
||||
start_time="2024-01-15T14:00:00",
|
||||
end_time="2024-01-15T15:00:00",
|
||||
description="Discuss project progress",
|
||||
location="Conference Room A",
|
||||
attendees=["colleague@example.com"]
|
||||
)
|
||||
```
|
||||
|
||||
**Time formats:**
|
||||
- ISO format: `"2024-01-15T14:00:00"`
|
||||
- Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"`
|
||||
|
||||
### Check Availability
|
||||
|
||||
Check if a time slot is available:
|
||||
```
|
||||
calendar(
|
||||
action="check_availability",
|
||||
start_time="2024-01-15T14:00:00",
|
||||
end_time="2024-01-15T15:00:00"
|
||||
)
|
||||
```
|
||||
|
||||
## Email Integration
|
||||
|
||||
When an email mentions a meeting (e.g., "meeting tomorrow at 2pm"), the agent can automatically:
|
||||
1. Parse the email to extract meeting details
|
||||
2. Create a calendar event using `create_event`
|
||||
3. Confirm the event was created
|
||||
|
||||
Enable automatic scheduling:
|
||||
```bash
|
||||
export NANOBOT_TOOLS__CALENDAR__AUTO_SCHEDULE_FROM_EMAIL=true
|
||||
```
|
||||
|
||||
@ -15,6 +15,11 @@ Use the `cron` tool to schedule reminders or recurring tasks.
|
||||
|
||||
## Examples
|
||||
|
||||
**IMPORTANT**: Always extract the message from the user's request:
|
||||
- User: "remind me to call mama" → `message="call mama"`
|
||||
- User: "remind me every hour to drink water" → `message="drink water"`
|
||||
- User: "remind me every 10 seconds for the next minute to call mama" → `message="call mama"`
|
||||
|
||||
Fixed reminder:
|
||||
```
|
||||
cron(action="add", message="Time to take a break!", every_seconds=1200)
|
||||
@ -50,6 +55,8 @@ cron(action="remove", job_id="abc123")
|
||||
| remind me in 1 hour | **in_seconds: 3600** (1 hour = 3600 seconds) |
|
||||
| every 20 minutes | every_seconds: 1200 |
|
||||
| every hour | every_seconds: 3600 |
|
||||
| **every 10 seconds for the next minute** | **every_seconds: 10 AND in_seconds: 60** (creates 6 reminders: at 0s, 10s, 20s, 30s, 40s, 50s) |
|
||||
| **every 5 seconds for 30 seconds** | **every_seconds: 5 AND in_seconds: 30** (creates 6 reminders) |
|
||||
| every day at 8am | cron_expr: "0 8 * * *" |
|
||||
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
|
||||
| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
|
||||
@ -61,6 +68,8 @@ cron(action="remove", job_id="abc123")
|
||||
- "remind me in 25 seconds" → `cron(action="add", message="...", in_seconds=25)`
|
||||
- "remind me in 5 minutes" → `cron(action="add", message="...", in_seconds=300)` (5 * 60 = 300)
|
||||
- "remind me in 1 hour" → `cron(action="add", message="...", in_seconds=3600)` (60 * 60 = 3600)
|
||||
- **"remind me every 10 seconds for the next minute"** → `cron(action="add", message="...", every_seconds=10, in_seconds=60)` (creates 6 reminders)
|
||||
- **"every 5 seconds for 30 seconds"** → `cron(action="add", message="...", every_seconds=5, in_seconds=30)` (creates 6 reminders)
|
||||
|
||||
The `in_seconds` parameter automatically computes the correct future datetime - you don't need to calculate it yourself!
|
||||
|
||||
|
||||
52
nanobot/skills/gitea/SKILL.md
Normal file
52
nanobot/skills/gitea/SKILL.md
Normal file
@ -0,0 +1,52 @@
|
||||
---
|
||||
name: gitea
|
||||
description: "Interact with Gitea API using curl. This repository uses Gitea (NOT GitHub) at http://10.0.30.169:3000/api/v1. ALWAYS use HTTP (not HTTPS)."
|
||||
metadata: {"nanobot":{"emoji":"🔧","requires":{"env":["NANOBOT_GITLE_TOKEN"]}}}
|
||||
---
|
||||
|
||||
# Gitea Skill
|
||||
|
||||
**CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub.
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **ALWAYS use `http://` (NOT `https://`)** - Gitea runs on HTTP port 3000
|
||||
2. **ALWAYS include Authorization header** with `$NANOBOT_GITLE_TOKEN`
|
||||
3. **Repository**: `ilia/nanobot`
|
||||
4. **API Base**: `http://10.0.30.169:3000/api/v1`
|
||||
|
||||
## Pull Requests
|
||||
|
||||
List all pull requests:
|
||||
```bash
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
||||
```
|
||||
|
||||
List open pull requests:
|
||||
```bash
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls?state=open"
|
||||
```
|
||||
|
||||
## Issues
|
||||
|
||||
List open issues:
|
||||
```bash
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/issues?state=open"
|
||||
```
|
||||
|
||||
## Helper Script
|
||||
|
||||
You can also use the helper script:
|
||||
```bash
|
||||
./workspace/gitea_api.sh prs
|
||||
./workspace/gitea_api.sh issues open
|
||||
```
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
❌ **WRONG**: `curl https://10.0.30.169:3000/api/...` (SSL error)
|
||||
❌ **WRONG**: `curl http://gitea.example.com/api/...` (placeholder URL)
|
||||
✅ **CORRECT**: `curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"`
|
||||
@ -24,6 +24,7 @@ dependencies = [
|
||||
"websockets>=12.0",
|
||||
"websocket-client>=1.6.0",
|
||||
"httpx>=0.25.0",
|
||||
"ddgs>=9.0.0",
|
||||
"oauth-cli-kit>=0.1.1",
|
||||
"loguru>=0.7.0",
|
||||
"readability-lxml>=0.8.0",
|
||||
@ -42,6 +43,10 @@ dependencies = [
|
||||
"prompt-toolkit>=3.0.0",
|
||||
"mcp>=1.0.0",
|
||||
"json-repair>=0.30.0",
|
||||
"google-api-python-client>=2.0.0",
|
||||
"google-auth-httplib2>=0.2.0",
|
||||
"google-auth-oauthlib>=1.0.0",
|
||||
"pytz>=2024.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@ -2,13 +2,68 @@
|
||||
|
||||
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
|
||||
**CRITICAL: TOOL EXECUTION**
|
||||
- When you need to use a tool, CALL IT DIRECTLY - the system will execute it automatically
|
||||
- DO NOT show JSON like `{"action": "list_events"}` in your response text
|
||||
- DO NOT explain what tool you would call - just call it
|
||||
- Tools are executed automatically when you call them - you don't need to show the JSON
|
||||
|
||||
## 🚨 CRITICAL: Gitea API Requests
|
||||
|
||||
**When user asks to list PRs, issues, or use Gitea API:**
|
||||
|
||||
**MANDATORY COMMAND FORMAT:**
|
||||
```bash
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
||||
```
|
||||
|
||||
**CRITICAL RULES:**
|
||||
1. **DO NOT use web_search** - execute the curl command directly
|
||||
2. **MUST use `http://` (NOT `https://`)** - Gitea runs on HTTP port 3000
|
||||
3. **MUST include Authorization header** with `$NANOBOT_GITLE_TOKEN`
|
||||
4. **Copy the exact command above** - do not modify the protocol to HTTPS
|
||||
|
||||
**WRONG (will fail):**
|
||||
- `curl -X GET https://10.0.30.169:3000/api/...` ❌ (SSL error)
|
||||
- `curl https://10.0.30.169:3000/api/...` ❌ (SSL error)
|
||||
|
||||
**CORRECT:**
|
||||
- `curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"` ✅
|
||||
|
||||
**OR use the helper script (recommended - avoids HTTPS mistakes):**
|
||||
```bash
|
||||
./workspace/gitea_api.sh prs
|
||||
./workspace/gitea_api.sh issues open
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Always explain what you're doing before taking actions
|
||||
- **CRITICAL: When you need to use a tool, the system will automatically execute it when you call it. You do NOT need to show JSON.**
|
||||
- **When user asks you to do something, IMMEDIATELY call the necessary tools - do not explain, do not show JSON, just call them.**
|
||||
- The system handles tool execution automatically - you just need to call the tools in your response.
|
||||
- Ask for clarification when the request is ambiguous
|
||||
- Use tools to help accomplish tasks
|
||||
- Remember important information in your memory files
|
||||
|
||||
## Git Operations
|
||||
|
||||
**CRITICAL**: When user asks to commit, push, or perform git operations:
|
||||
- **ALWAYS use the `exec` tool** to run git commands
|
||||
- **NEVER use `write_file` or `edit_file`** for git commands
|
||||
- Git commands are shell commands and must be executed, not written to files
|
||||
|
||||
**Examples:**
|
||||
- User: "commit with message 'Fix bug'" → `exec(command="git commit -m 'Fix bug'")`
|
||||
- User: "commit the staged files" → `exec(command="git commit -m 'your message here'")`
|
||||
- User: "push to remote" → `exec(command="git push")`
|
||||
- User: "check git status" → `exec(command="git status")`
|
||||
|
||||
**WRONG (will not work):**
|
||||
- `write_file(path="git commit -m 'message'", content="...")` ❌
|
||||
- `edit_file(path="git commit", ...)` ❌
|
||||
|
||||
**CORRECT:**
|
||||
- `exec(command="git commit -m 'Fix HTTPS to HTTP conversion for Gitea API'")` ✅
|
||||
|
||||
## When NOT to Use Tools
|
||||
|
||||
**For simple acknowledgments, respond naturally and conversationally - no tools needed.**
|
||||
@ -42,6 +97,8 @@ You have access to:
|
||||
- Messaging (message)
|
||||
- Background tasks (spawn)
|
||||
- Scheduled tasks (cron) - for reminders and delayed actions
|
||||
- Email (read_emails) - read emails from IMAP mailbox
|
||||
- Calendar (calendar) - interact with Google Calendar (if enabled)
|
||||
|
||||
## Memory
|
||||
|
||||
@ -72,6 +129,106 @@ When the scheduled time arrives, the cron system will send the message back to y
|
||||
|
||||
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. Use the `cron` tool.
|
||||
|
||||
## Calendar Integration
|
||||
|
||||
**CRITICAL: When processing emails that mention meetings, you MUST automatically schedule them in the calendar.**
|
||||
|
||||
**CRITICAL: When using calendar tools, EXECUTE them immediately. Do NOT show JSON or explain what you would do - just call the tool.**
|
||||
|
||||
When an email mentions a meeting (e.g., "meeting tomorrow at 2pm", "reminder about our meeting on March 7 at 15:00", "call scheduled for next week"), you MUST:
|
||||
|
||||
1. **Extract meeting details** from the email:
|
||||
- Title/subject (use email subject if no explicit title)
|
||||
- Date and time (parse formats like "March 7 at 15:00", "tomorrow 2pm", etc.)
|
||||
- Location (if mentioned)
|
||||
- Attendees (email addresses)
|
||||
|
||||
2. **Check if meeting already exists** (optional but recommended):
|
||||
- Use `calendar(action="list_events")` to check upcoming events
|
||||
- Look for events with similar title/time
|
||||
|
||||
3. **Use the `calendar` tool** to create the event:
|
||||
```
|
||||
calendar(
|
||||
action="create_event",
|
||||
title="Meeting Title",
|
||||
start_time="March 7 15:00", # Use natural language format, NOT ISO format
|
||||
end_time="March 7 16:00", # optional, defaults to 1 hour after start
|
||||
location="Conference Room A", # optional
|
||||
attendees=["colleague@example.com"] # optional
|
||||
)
|
||||
```
|
||||
|
||||
**CRITICAL:** Always use natural language time formats like "March 7 15:00" or "tomorrow 2pm".
|
||||
**DO NOT** generate ISO format strings like "2024-03-06T19:00:00" - the calendar tool will parse
|
||||
natural language correctly and handle the current year automatically. If you generate ISO format
|
||||
with the wrong year (e.g., 2024 instead of 2026), the meeting will be scheduled in the past.
|
||||
|
||||
4. **Confirm to the user** that the meeting was scheduled (include the calendar link if available).
|
||||
|
||||
**Time formats supported:**
|
||||
- Month names: `"March 7 at 15:00"`, `"March 7th at 3pm"`, `"on March 7 at 15:00"`
|
||||
- Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"`
|
||||
- ISO format: `"2024-01-15T14:00:00"`
|
||||
|
||||
**Deleting/Canceling Events:**
|
||||
When the user asks to cancel or delete meetings, you MUST follow this workflow - DO NOT explain, just execute:
|
||||
|
||||
**STEP 1: ALWAYS call list_events FIRST - DO THIS NOW, DO NOT EXPLAIN**
|
||||
- IMMEDIATELY call `calendar(action="list_events", time_min="today")`
|
||||
- Do NOT explain what you will do - just call the tool
|
||||
- Do NOT try to use `delete_events_today` (it doesn't exist)
|
||||
|
||||
**STEP 2: From the list_events response, identify the target event(s)**
|
||||
- "Cancel all meetings today" → ALL events from today (extract ALL IDs from the response)
|
||||
- "Cancel my last meeting" → The last event in the list (marked as "LAST - latest time")
|
||||
- "Cancel my 8pm meeting" → Event(s) at 8pm
|
||||
- "Cancel the meeting with John" → Event(s) with "John" in title/description
|
||||
|
||||
**STEP 3: Extract event IDs from the response**
|
||||
- Event IDs are long strings (20+ characters) after `[ID: ` or in the `Event IDs:` line
|
||||
- For "cancel all", extract ALL IDs from the response
|
||||
|
||||
**STEP 4: Call delete_event or delete_events with the extracted IDs**
|
||||
- Single event: `calendar(action="delete_event", event_id="...")`
|
||||
- Multiple events: `calendar(action="delete_events", event_ids=[...])`
|
||||
- **CRITICAL**: Do NOT use placeholder IDs - you MUST extract real IDs from list_events response
|
||||
- **CRITICAL**: Do NOT use `update_event` with `status: "cancelled"` (that doesn't work)
|
||||
|
||||
**Rescheduling/Moving Events:**
|
||||
When the user asks to reschedule or move a meeting, you MUST follow these steps:
|
||||
|
||||
**STEP 1: ALWAYS call list_events FIRST - DO THIS NOW, DO NOT EXPLAIN**
|
||||
- IMMEDIATELY call `calendar(action="list_events", time_min="today")`
|
||||
- Do NOT explain what you will do - just call the tool
|
||||
- Do NOT use placeholder values - you MUST get the actual ID from the response
|
||||
|
||||
**STEP 2: From the list_events response, identify the target event**
|
||||
- "last meeting" → The event with the LATEST time (marked as "LAST - latest time" in the response, usually the last numbered item)
|
||||
- "first meeting" → The event with the EARLIEST time (marked as "FIRST - earliest time", usually #1)
|
||||
- "8pm meeting" → Event(s) at 8pm (look for "8:00 PM" or "20:00" in the time)
|
||||
- "meeting with John" → Event(s) with "John" in the title
|
||||
- Extract the actual event_id (long string after `[ID: `, usually 20+ characters)
|
||||
- IMPORTANT: Events are numbered in the response - use the number and the "LAST" marker to identify correctly
|
||||
|
||||
**STEP 3: IMMEDIATELY call update_event with the actual event_id**
|
||||
- Call `calendar(action="update_event", event_id="actual_id_from_step_2", start_time="new time")`
|
||||
- Use natural language for new time: "4pm", "next Monday at 4pm", "tomorrow 2pm", etc.
|
||||
- Do NOT explain - just execute the tool call
|
||||
|
||||
**CRITICAL:**
|
||||
- When you get an error saying "Invalid event_id" or "placeholder", DO NOT explain the solution
|
||||
- Instead, IMMEDIATELY call list_events, then call update_event again with the real ID
|
||||
- NEVER show JSON - just call the tools
|
||||
- NEVER use placeholder values - always get real IDs from list_events
|
||||
|
||||
**Automatic scheduling:** When `auto_schedule_from_email` is enabled (default: true), automatically schedule meetings when detected in emails. Do NOT just acknowledge - actually create the calendar event using the `calendar` tool.
|
||||
|
||||
**Examples of emails that should trigger scheduling:**
|
||||
- "Reminder about our meeting on March 7 at 15:00" → Schedule for March 7 at 3 PM
|
||||
- "Meeting tomorrow at 2pm" → Schedule for tomorrow at 2 PM
|
||||
- "Call scheduled for next week" → Extract date and schedule
|
||||
|
||||
## Heartbeat Tasks
|
||||
|
||||
`HEARTBEAT.md` is checked every 30 minutes. You can manage periodic tasks by editing this file:
|
||||
@ -88,3 +245,114 @@ Task format examples:
|
||||
```
|
||||
|
||||
When the user asks you to add a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time reminder. Keep the file small to minimize token usage.
|
||||
|
||||
## ⚠️ CRITICAL: Gitea API Access
|
||||
|
||||
**THIS REPOSITORY USES GITEA, NOT GITHUB. NEVER USE PLACEHOLDER URLS.**
|
||||
|
||||
When user asks about pull requests, issues, or Gitea API:
|
||||
1. **ALWAYS detect the real Gitea URL from git remote first**
|
||||
2. **NEVER use placeholder URLs like `gitea.example.com` or `https://gitea.example.com`**
|
||||
3. **The correct Gitea API base is: `http://10.0.30.169:3000/api/v1`**
|
||||
|
||||
To access Gitea API:
|
||||
|
||||
1. **Detect Gitea URL from git remote:**
|
||||
```bash
|
||||
git remote get-url origin
|
||||
# Returns: gitea@10.0.30.169:ilia/nanobot.git
|
||||
# Extract host: 10.0.30.169
|
||||
# API base: http://10.0.30.169:3000/api/v1
|
||||
# Repo: ilia/nanobot
|
||||
```
|
||||
|
||||
2. **Use the token from environment:**
|
||||
```bash
|
||||
TOKEN=$NANOBOT_GITLE_TOKEN
|
||||
curl -H "Authorization: token $TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
||||
```
|
||||
|
||||
3. **Or use the helper script:**
|
||||
```bash
|
||||
source workspace/get_gitea_info.sh
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"${GITEA_API_BASE}/repos/${GITEA_REPO}/pulls"
|
||||
```
|
||||
|
||||
**Important:** Never use placeholder URLs like `gitea.example.com`. Always detect from git remote or use the actual host `10.0.30.169:3000`.
|
||||
|
||||
## 🚨 GITEA URL DETECTION (MANDATORY)
|
||||
|
||||
**BEFORE making any Gitea API call, you MUST:**
|
||||
|
||||
1. Run: `git remote get-url origin`
|
||||
- This returns: `gitea@10.0.30.169:ilia/nanobot.git`
|
||||
|
||||
2. Extract the host: `10.0.30.169`
|
||||
- Command: `git remote get-url origin | sed 's/.*@\([^:]*\).*/\1/'`
|
||||
|
||||
3. Extract the repo: `ilia/nanobot`
|
||||
- Command: `git remote get-url origin | sed 's/.*:\(.*\)\.git/\1/'`
|
||||
|
||||
4. Construct API URL: `http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/...`
|
||||
|
||||
**Example correct command (MUST use $NANOBOT_GITLE_TOKEN variable):**
|
||||
```bash
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
||||
```
|
||||
|
||||
**CRITICAL**: Always use `$NANOBOT_GITLE_TOKEN` in the curl command. The token is automatically loaded from `.env` file into the environment when nanobot starts. Do NOT hardcode the token value.
|
||||
|
||||
**WRONG (never use):**
|
||||
- `https://gitea.example.com/api/...` ❌
|
||||
- `https://gitea.example.com/ap...` ❌
|
||||
- Any placeholder URL ❌
|
||||
|
||||
## Gitea API Token Usage
|
||||
|
||||
**MANDATORY**: When making Gitea API calls, you **MUST** include the Authorization header with the token:
|
||||
|
||||
```bash
|
||||
# ✅ CORRECT - includes Authorization header with token
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
||||
|
||||
# ❌ WRONG - missing Authorization header (will get 401 error)
|
||||
curl -X GET "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
||||
|
||||
# ❌ WRONG - missing token in header
|
||||
curl "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
||||
```
|
||||
|
||||
**CRITICAL RULES:**
|
||||
1. **ALWAYS** include `-H "Authorization: token $NANOBOT_GITLE_TOKEN"` in curl commands
|
||||
2. **ALWAYS use `http://` (NOT `https://`)** - Gitea runs on HTTP, not HTTPS
|
||||
3. The token is automatically loaded from `.env` file when nanobot starts
|
||||
4. The `$NANOBOT_GITLE_TOKEN` environment variable is available in all exec commands
|
||||
5. Without the Authorization header, you will get a 401 Unauthorized error
|
||||
6. Using `https://` will cause SSL/TLS errors - always use `http://10.0.30.169:3000`
|
||||
|
||||
## Direct Gitea API Execution
|
||||
|
||||
When user asks to "list PRs" or "show issues" using Gitea API:
|
||||
|
||||
**DO THIS:**
|
||||
1. Run the curl command directly (no web_search needed)
|
||||
2. Use the exact command format below
|
||||
3. Parse and display the results
|
||||
|
||||
**Example - List PRs:**
|
||||
```bash
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls" | \
|
||||
python3 -m json.tool
|
||||
```
|
||||
|
||||
**DO NOT:**
|
||||
- Use web_search to find API documentation
|
||||
- Search for "how to use Gitea API"
|
||||
- Ask for clarification - just execute the command
|
||||
|
||||
You already have all the information you need in this file. Just execute the curl command.
|
||||
|
||||
46
workspace/GITEA_API.md
Normal file
46
workspace/GITEA_API.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Gitea API Quick Reference
|
||||
|
||||
**CRITICAL: This repository uses Gitea, NOT GitHub. Never use placeholder URLs.**
|
||||
|
||||
## Correct Gitea API Information
|
||||
|
||||
- **API Base URL**: `http://10.0.30.169:3000/api/v1`
|
||||
- **Repository**: `ilia/nanobot`
|
||||
- **Token**: Available in `$NANOBOT_GITLE_TOKEN` environment variable
|
||||
|
||||
## How to Detect (if needed)
|
||||
|
||||
```bash
|
||||
# Get git remote
|
||||
REMOTE=$(git remote get-url origin)
|
||||
# Returns: gitea@10.0.30.169:ilia/nanobot.git
|
||||
|
||||
# Extract host (remove gitea@ and :repo.git)
|
||||
HOST=$(echo "$REMOTE" | sed 's/.*@\([^:]*\).*/\1/')
|
||||
# Returns: 10.0.30.169
|
||||
|
||||
# Extract repo path
|
||||
REPO=$(echo "$REMOTE" | sed 's/.*:\(.*\)\.git/\1/')
|
||||
# Returns: ilia/nanobot
|
||||
|
||||
# API base (Gitea runs on port 3000)
|
||||
API_BASE="http://${HOST}:3000/api/v1"
|
||||
```
|
||||
|
||||
## Example API Calls
|
||||
|
||||
```bash
|
||||
# List pull requests
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
||||
|
||||
# List open issues
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/issues?state=open"
|
||||
|
||||
# Get repository info
|
||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot"
|
||||
```
|
||||
|
||||
**DO NOT USE**: `gitea.example.com` or any placeholder URLs. Always use `10.0.30.169:3000`.
|
||||
38
workspace/GITEA_INFO.md
Normal file
38
workspace/GITEA_INFO.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Gitea Configuration
|
||||
|
||||
## API Information
|
||||
|
||||
- **Gitea API Base URL**: `http://10.0.30.169:3000/api/v1`
|
||||
- **Repository**: `ilia/nanobot`
|
||||
- **Token Environment Variable**: `NANOBOT_GITLE_TOKEN`
|
||||
|
||||
## How to Use
|
||||
|
||||
When making Gitea API calls, use:
|
||||
|
||||
```bash
|
||||
# Get token from environment
|
||||
TOKEN=$NANOBOT_GITLE_TOKEN
|
||||
|
||||
# List open issues
|
||||
curl -H "Authorization: token $TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/issues?state=open"
|
||||
|
||||
# List pull requests
|
||||
curl -H "Authorization: token $TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
||||
|
||||
# Get repository info
|
||||
curl -H "Authorization: token $TOKEN" \
|
||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot"
|
||||
```
|
||||
|
||||
## Detecting Repository Info
|
||||
|
||||
You can detect the repository from git remote:
|
||||
```bash
|
||||
# Get repo path (owner/repo)
|
||||
git remote get-url origin | sed 's/.*:\(.*\)\.git/\1/'
|
||||
|
||||
# Gitea host is: 10.0.30.169:3000
|
||||
# API base: http://10.0.30.169:3000/api/v1
|
||||
@ -42,6 +42,14 @@ exec(command: str, working_dir: str = None) -> str
|
||||
- Output is truncated at 10,000 characters
|
||||
- Optional `restrictToWorkspace` config to limit paths
|
||||
|
||||
**Git Commands:**
|
||||
- **ALWAYS use exec for git commands** (git commit, git push, git status, etc.)
|
||||
- **NEVER use write_file or edit_file for git commands**
|
||||
- Examples:
|
||||
- `exec(command="git commit -m 'Fix bug'")`
|
||||
- `exec(command="git status")`
|
||||
- `exec(command="git push")`
|
||||
|
||||
## Web Access
|
||||
|
||||
### web_search
|
||||
@ -63,6 +71,26 @@ web_fetch(url: str, extractMode: str = "markdown", maxChars: int = 50000) -> str
|
||||
- Supports markdown or plain text extraction
|
||||
- Output is truncated at 50,000 characters by default
|
||||
|
||||
## Email
|
||||
|
||||
### read_emails
|
||||
Read emails from your configured email account via IMAP. **ALWAYS use this tool for email queries - NEVER use exec with mail commands.**
|
||||
```
|
||||
read_emails(limit: int = 10, unread_only: bool = False, mark_seen: bool = False) -> str
|
||||
```
|
||||
|
||||
**CRITICAL:** For ANY question about emails (latest email, email sender, email content, etc.), you MUST use this tool. Do NOT use `exec` with `mail` command or read memory files for email information. This tool connects directly to IMAP and fetches CURRENT, real-time email data.
|
||||
|
||||
**Parameters:**
|
||||
- `limit`: Maximum number of emails to return (1-50, default: 10)
|
||||
- `unread_only`: If true, only return unread emails (default: false)
|
||||
- `mark_seen`: If true, mark emails as read after fetching (default: false)
|
||||
|
||||
**Examples:**
|
||||
- `read_emails(limit=1)` - Get the latest email
|
||||
- `read_emails(unread_only=true)` - Get all unread emails
|
||||
- `read_emails(limit=5, mark_seen=false)` - Get last 5 emails without marking as read
|
||||
|
||||
## Communication
|
||||
|
||||
### message
|
||||
|
||||
25
workspace/get_gitea_info.sh
Executable file
25
workspace/get_gitea_info.sh
Executable file
@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Helper script to get Gitea API information from git remote
|
||||
|
||||
REMOTE=$(git remote get-url origin 2>/dev/null)
|
||||
if [ -z "$REMOTE" ]; then
|
||||
echo "Error: No git remote found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract host (assuming format: gitea@HOST:repo.git or ssh://gitea@HOST/repo.git)
|
||||
if [[ $REMOTE == *"@"* ]]; then
|
||||
HOST=$(echo "$REMOTE" | sed 's/.*@\([^:]*\).*/\1/')
|
||||
else
|
||||
HOST=$(echo "$REMOTE" | sed 's|.*://\([^/]*\).*|\1|')
|
||||
fi
|
||||
|
||||
# Extract repo path (owner/repo)
|
||||
REPO=$(echo "$REMOTE" | sed 's/.*:\(.*\)\.git/\1/' | sed 's|.*/\(.*/.*\)|\1|')
|
||||
|
||||
# Gitea typically runs on port 3000
|
||||
API_BASE="http://${HOST}:3000/api/v1"
|
||||
|
||||
echo "GITEA_HOST=${HOST}"
|
||||
echo "GITEA_REPO=${REPO}"
|
||||
echo "GITEA_API_BASE=${API_BASE}"
|
||||
28
workspace/gitea_api.sh
Executable file
28
workspace/gitea_api.sh
Executable file
@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Gitea API helper script - ALWAYS uses HTTP (not HTTPS)
|
||||
|
||||
API_BASE="http://10.0.30.169:3000/api/v1"
|
||||
REPO="ilia/nanobot"
|
||||
TOKEN="${NANOBOT_GITLE_TOKEN}"
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "Error: NANOBOT_GITLE_TOKEN not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
prs|pulls)
|
||||
curl -s -H "Authorization: token $TOKEN" \
|
||||
"${API_BASE}/repos/${REPO}/pulls"
|
||||
;;
|
||||
issues)
|
||||
curl -s -H "Authorization: token $TOKEN" \
|
||||
"${API_BASE}/repos/${REPO}/issues?state=${2:-open}"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {prs|pulls|issues} [state]"
|
||||
echo "Example: $0 prs"
|
||||
echo "Example: $0 issues open"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Loading…
x
Reference in New Issue
Block a user