Compare commits
4 Commits
edb409bb0c
...
32cef2df77
| Author | SHA1 | Date | |
|---|---|---|---|
| 32cef2df77 | |||
| 2e69dc7ca8 | |||
| a6d70f3d14 | |||
| 7db96541a6 |
@ -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,21 @@ 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
|
||||
|
||||
async def _connect_mcp(self) -> None:
|
||||
"""Connect to configured MCP servers (one-time, lazy)."""
|
||||
@ -362,6 +377,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 {},
|
||||
|
||||
@ -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"
|
||||
|
||||
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
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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"`
|
||||
@ -2,6 +2,34 @@
|
||||
|
||||
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
|
||||
## 🚨 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
|
||||
@ -9,6 +37,26 @@ You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
- 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.**
|
||||
@ -88,3 +136,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