feat(tools): refactor pushover into conditional notify tool with Telegram fallback
Replace the always-registered PushoverTool with a NotifyTool that auto-selects its backend at startup: Pushover if .env credentials exist, otherwise Telegram (using bot_token + first allowed_users entry as chat_id). If neither backend is available, the tool is not registered, saving a tool slot and avoiding agent confusion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6e8c799af5
commit
5cdf1b74f3
5 changed files with 638 additions and 446 deletions
|
|
@ -1725,10 +1725,13 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||||
"schedule",
|
"schedule",
|
||||||
"Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
|
"Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
|
||||||
));
|
));
|
||||||
tool_descs.push((
|
// notify tool is conditionally registered (Pushover or Telegram fallback)
|
||||||
"pushover",
|
if tools_registry.iter().any(|t| t.name() == "notify") {
|
||||||
"Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.",
|
tool_descs.push((
|
||||||
));
|
"notify",
|
||||||
|
"Send a push notification (via Pushover or Telegram depending on configuration).",
|
||||||
|
));
|
||||||
|
}
|
||||||
if !config.agents.is_empty() {
|
if !config.agents.is_empty() {
|
||||||
tool_descs.push((
|
tool_descs.push((
|
||||||
"delegate",
|
"delegate",
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
|
||||||
"tool.browser",
|
"tool.browser",
|
||||||
"tool.composio",
|
"tool.composio",
|
||||||
"tool.http_request",
|
"tool.http_request",
|
||||||
"tool.pushover",
|
"tool.notify",
|
||||||
"memory.embeddings",
|
"memory.embeddings",
|
||||||
"tunnel.custom",
|
"tunnel.custom",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ pub mod memory_forget;
|
||||||
pub mod memory_recall;
|
pub mod memory_recall;
|
||||||
pub mod memory_store;
|
pub mod memory_store;
|
||||||
pub mod proxy_config;
|
pub mod proxy_config;
|
||||||
pub mod pushover;
|
pub mod notify;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod screenshot;
|
pub mod screenshot;
|
||||||
|
|
@ -50,7 +50,7 @@ pub use memory_forget::MemoryForgetTool;
|
||||||
pub use memory_recall::MemoryRecallTool;
|
pub use memory_recall::MemoryRecallTool;
|
||||||
pub use memory_store::MemoryStoreTool;
|
pub use memory_store::MemoryStoreTool;
|
||||||
pub use proxy_config::ProxyConfigTool;
|
pub use proxy_config::ProxyConfigTool;
|
||||||
pub use pushover::PushoverTool;
|
pub use notify::NotifyTool;
|
||||||
pub use schedule::ScheduleTool;
|
pub use schedule::ScheduleTool;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use schema::{CleaningStrategy, SchemaCleanr};
|
pub use schema::{CleaningStrategy, SchemaCleanr};
|
||||||
|
|
@ -151,12 +151,16 @@ pub fn all_tools_with_runtime(
|
||||||
security.clone(),
|
security.clone(),
|
||||||
workspace_dir.to_path_buf(),
|
workspace_dir.to_path_buf(),
|
||||||
)),
|
)),
|
||||||
Box::new(PushoverTool::new(
|
|
||||||
security.clone(),
|
|
||||||
workspace_dir.to_path_buf(),
|
|
||||||
)),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if let Some(notify_tool) = NotifyTool::detect(
|
||||||
|
security.clone(),
|
||||||
|
workspace_dir,
|
||||||
|
root_config.channels_config.telegram.as_ref(),
|
||||||
|
) {
|
||||||
|
tools.push(Box::new(notify_tool));
|
||||||
|
}
|
||||||
|
|
||||||
if browser_config.enabled {
|
if browser_config.enabled {
|
||||||
// Add legacy browser_open tool for simple URL opening
|
// Add legacy browser_open tool for simple URL opening
|
||||||
tools.push(Box::new(BrowserOpenTool::new(
|
tools.push(Box::new(BrowserOpenTool::new(
|
||||||
|
|
@ -294,7 +298,8 @@ mod tests {
|
||||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||||
assert!(!names.contains(&"browser_open"));
|
assert!(!names.contains(&"browser_open"));
|
||||||
assert!(names.contains(&"schedule"));
|
assert!(names.contains(&"schedule"));
|
||||||
assert!(names.contains(&"pushover"));
|
// notify tool is conditionally registered — not present without credentials
|
||||||
|
assert!(!names.contains(&"notify"));
|
||||||
assert!(names.contains(&"proxy_config"));
|
assert!(names.contains(&"proxy_config"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,7 +338,8 @@ mod tests {
|
||||||
);
|
);
|
||||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||||
assert!(names.contains(&"browser_open"));
|
assert!(names.contains(&"browser_open"));
|
||||||
assert!(names.contains(&"pushover"));
|
// notify tool is conditionally registered — not present without credentials
|
||||||
|
assert!(!names.contains(&"notify"));
|
||||||
assert!(names.contains(&"proxy_config"));
|
assert!(names.contains(&"proxy_config"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
616
src/tools/notify.rs
Normal file
616
src/tools/notify.rs
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
use super::traits::{Tool, ToolResult};
|
||||||
|
use crate::config::TelegramConfig;
|
||||||
|
use crate::security::SecurityPolicy;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json";
|
||||||
|
const NOTIFY_REQUEST_TIMEOUT_SECS: u64 = 15;
|
||||||
|
|
||||||
|
enum NotifyBackend {
|
||||||
|
Pushover { token: String, user_key: String },
|
||||||
|
Telegram { bot_token: String, chat_id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NotifyTool {
|
||||||
|
security: Arc<SecurityPolicy>,
|
||||||
|
backend: NotifyBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotifyTool {
|
||||||
|
/// Detect the best available notification backend.
|
||||||
|
///
|
||||||
|
/// Checks Pushover credentials first (from `.env`), then Telegram config.
|
||||||
|
/// Returns `None` if neither backend is available.
|
||||||
|
pub fn detect(
|
||||||
|
security: Arc<SecurityPolicy>,
|
||||||
|
workspace_dir: &std::path::Path,
|
||||||
|
telegram_config: Option<&TelegramConfig>,
|
||||||
|
) -> Option<Self> {
|
||||||
|
// Try Pushover first
|
||||||
|
if let Some((token, user_key)) = Self::read_pushover_credentials(workspace_dir) {
|
||||||
|
return Some(Self {
|
||||||
|
security,
|
||||||
|
backend: NotifyBackend::Pushover { token, user_key },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Telegram
|
||||||
|
if let Some(tg) = telegram_config {
|
||||||
|
if let Some(chat_id) = tg.allowed_users.first() {
|
||||||
|
if !tg.bot_token.is_empty() && !chat_id.is_empty() {
|
||||||
|
return Some(Self {
|
||||||
|
security,
|
||||||
|
backend: NotifyBackend::Telegram {
|
||||||
|
bot_token: tg.bot_token.clone(),
|
||||||
|
chat_id: chat_id.clone(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_env_value(raw: &str) -> String {
|
||||||
|
let raw = raw.trim();
|
||||||
|
|
||||||
|
let unquoted = if raw.len() >= 2
|
||||||
|
&& ((raw.starts_with('"') && raw.ends_with('"'))
|
||||||
|
|| (raw.starts_with('\'') && raw.ends_with('\'')))
|
||||||
|
{
|
||||||
|
&raw[1..raw.len() - 1]
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep support for inline comments in unquoted values:
|
||||||
|
// KEY=value # comment
|
||||||
|
unquoted.split_once(" #").map_or_else(
|
||||||
|
|| unquoted.trim().to_string(),
|
||||||
|
|(value, _)| value.trim().to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_pushover_credentials(workspace_dir: &std::path::Path) -> Option<(String, String)> {
|
||||||
|
let env_path = workspace_dir.join(".env");
|
||||||
|
let content = std::fs::read_to_string(&env_path).ok()?;
|
||||||
|
|
||||||
|
let mut token = None;
|
||||||
|
let mut user_key = None;
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.starts_with('#') || line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
let key = key.trim();
|
||||||
|
let value = Self::parse_env_value(value);
|
||||||
|
|
||||||
|
if key.eq_ignore_ascii_case("PUSHOVER_TOKEN") {
|
||||||
|
token = Some(value);
|
||||||
|
} else if key.eq_ignore_ascii_case("PUSHOVER_USER_KEY") {
|
||||||
|
user_key = Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((token?, user_key?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_label(&self) -> &str {
|
||||||
|
match &self.backend {
|
||||||
|
NotifyBackend::Pushover { .. } => "Pushover",
|
||||||
|
NotifyBackend::Telegram { .. } => "Telegram",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_pushover(
|
||||||
|
token: &str,
|
||||||
|
user_key: &str,
|
||||||
|
message: &str,
|
||||||
|
title: Option<&str>,
|
||||||
|
priority: Option<i64>,
|
||||||
|
sound: Option<&str>,
|
||||||
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
let mut form = reqwest::multipart::Form::new()
|
||||||
|
.text("token", token.to_owned())
|
||||||
|
.text("user", user_key.to_owned())
|
||||||
|
.text("message", message.to_owned());
|
||||||
|
|
||||||
|
if let Some(title) = title {
|
||||||
|
form = form.text("title", title.to_owned());
|
||||||
|
}
|
||||||
|
if let Some(priority) = priority {
|
||||||
|
form = form.text("priority", priority.to_string());
|
||||||
|
}
|
||||||
|
if let Some(sound) = sound {
|
||||||
|
form = form.text("sound", sound.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = crate::config::build_runtime_proxy_client_with_timeouts(
|
||||||
|
"tool.notify",
|
||||||
|
NOTIFY_REQUEST_TIMEOUT_SECS,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
let response = client.post(PUSHOVER_API_URL).multipart(form).send().await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: body,
|
||||||
|
error: Some(format!("Pushover API returned status {}", status)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_status = serde_json::from_str::<serde_json::Value>(&body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|json| json.get("status").and_then(|value| value.as_i64()));
|
||||||
|
|
||||||
|
if api_status == Some(1) {
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: format!("Notification sent via Pushover. Response: {}", body),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: body,
|
||||||
|
error: Some("Pushover API returned an application-level error".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_telegram(
|
||||||
|
bot_token: &str,
|
||||||
|
chat_id: &str,
|
||||||
|
message: &str,
|
||||||
|
title: Option<&str>,
|
||||||
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
let text = match title {
|
||||||
|
Some(t) if !t.is_empty() => format!("*{}*\n{}", t, message),
|
||||||
|
_ => message.to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://api.telegram.org/bot{}/sendMessage",
|
||||||
|
bot_token
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = crate::config::build_runtime_proxy_client_with_timeouts(
|
||||||
|
"tool.notify",
|
||||||
|
NOTIFY_REQUEST_TIMEOUT_SECS,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: body,
|
||||||
|
error: Some(format!("Telegram API returned status {}", status)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = serde_json::from_str::<serde_json::Value>(&body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|json| json.get("ok").and_then(|v| v.as_bool()));
|
||||||
|
|
||||||
|
if ok == Some(true) {
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: format!("Notification sent via Telegram. Response: {}", body),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: body,
|
||||||
|
error: Some("Telegram API returned an application-level error".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for NotifyTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"notify"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
match &self.backend {
|
||||||
|
NotifyBackend::Pushover { .. } => {
|
||||||
|
"Send a push notification to your device via Pushover. Supports title, priority, and sound options."
|
||||||
|
}
|
||||||
|
NotifyBackend::Telegram { .. } => {
|
||||||
|
"Send a notification message via Telegram. Supports optional title (priority/sound are ignored)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The notification message to send"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional notification title"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [-2, -1, 0, 1, 2],
|
||||||
|
"description": "Message priority (Pushover only): -2 (lowest/silent), -1 (low/no sound), 0 (normal), 1 (high), 2 (emergency/repeating)"
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Notification sound override (Pushover only, e.g., 'pushover', 'bike', 'bugle', 'cashregister', etc.)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||||
|
if !self.security.can_act() {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("Action blocked: autonomy is read-only".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.security.record_action() {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("Action blocked: rate limit exceeded".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = args
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let title = args.get("title").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
|
||||||
|
let priority = match args.get("priority").and_then(|v| v.as_i64()) {
|
||||||
|
Some(value) if (-2..=2).contains(&value) => Some(value),
|
||||||
|
Some(value) => {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(format!(
|
||||||
|
"Invalid 'priority': {value}. Expected integer in range -2..=2"
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sound = args.get("sound").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
|
||||||
|
match &self.backend {
|
||||||
|
NotifyBackend::Pushover { token, user_key } => {
|
||||||
|
Self::send_pushover(
|
||||||
|
token,
|
||||||
|
user_key,
|
||||||
|
&message,
|
||||||
|
title.as_deref(),
|
||||||
|
priority,
|
||||||
|
sound.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
NotifyBackend::Telegram { bot_token, chat_id } => {
|
||||||
|
Self::send_telegram(bot_token, chat_id, &message, title.as_deref()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::security::AutonomyLevel;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn test_security(level: AutonomyLevel, max_actions_per_hour: u32) -> Arc<SecurityPolicy> {
|
||||||
|
Arc::new(SecurityPolicy {
|
||||||
|
autonomy: level,
|
||||||
|
max_actions_per_hour,
|
||||||
|
workspace_dir: std::env::temp_dir(),
|
||||||
|
..SecurityPolicy::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_pushover_tool(security: Arc<SecurityPolicy>) -> NotifyTool {
|
||||||
|
NotifyTool {
|
||||||
|
security,
|
||||||
|
backend: NotifyBackend::Pushover {
|
||||||
|
token: "test_token".into(),
|
||||||
|
user_key: "test_user".into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_telegram_tool(security: Arc<SecurityPolicy>) -> NotifyTool {
|
||||||
|
NotifyTool {
|
||||||
|
security,
|
||||||
|
backend: NotifyBackend::Telegram {
|
||||||
|
bot_token: "123:ABC".into(),
|
||||||
|
chat_id: "456".into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notify_tool_name() {
|
||||||
|
let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100));
|
||||||
|
assert_eq!(tool.name(), "notify");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notify_tool_description_pushover() {
|
||||||
|
let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100));
|
||||||
|
assert!(tool.description().contains("Pushover"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notify_tool_description_telegram() {
|
||||||
|
let tool = make_telegram_tool(test_security(AutonomyLevel::Full, 100));
|
||||||
|
assert!(tool.description().contains("Telegram"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notify_tool_has_parameters_schema() {
|
||||||
|
let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100));
|
||||||
|
let schema = tool.parameters_schema();
|
||||||
|
assert_eq!(schema["type"], "object");
|
||||||
|
assert!(schema["properties"].get("message").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notify_tool_requires_message() {
|
||||||
|
let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100));
|
||||||
|
let schema = tool.parameters_schema();
|
||||||
|
let required = schema["required"].as_array().unwrap();
|
||||||
|
assert!(required.contains(&serde_json::Value::String("message".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_parsed_from_env_file() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let env_path = tmp.path().join(".env");
|
||||||
|
fs::write(
|
||||||
|
&env_path,
|
||||||
|
"PUSHOVER_TOKEN=testtoken123\nPUSHOVER_USER_KEY=userkey456\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = NotifyTool::read_pushover_credentials(tmp.path());
|
||||||
|
assert!(result.is_some());
|
||||||
|
let (token, user_key) = result.unwrap();
|
||||||
|
assert_eq!(token, "testtoken123");
|
||||||
|
assert_eq!(user_key, "userkey456");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_none_without_env_file() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let result = NotifyTool::read_pushover_credentials(tmp.path());
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_none_without_token() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let env_path = tmp.path().join(".env");
|
||||||
|
fs::write(&env_path, "PUSHOVER_USER_KEY=userkey456\n").unwrap();
|
||||||
|
|
||||||
|
let result = NotifyTool::read_pushover_credentials(tmp.path());
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_none_without_user_key() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let env_path = tmp.path().join(".env");
|
||||||
|
fs::write(&env_path, "PUSHOVER_TOKEN=testtoken123\n").unwrap();
|
||||||
|
|
||||||
|
let result = NotifyTool::read_pushover_credentials(tmp.path());
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_ignore_comments() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let env_path = tmp.path().join(".env");
|
||||||
|
fs::write(&env_path, "# This is a comment\nPUSHOVER_TOKEN=realtoken\n# Another comment\nPUSHOVER_USER_KEY=realuser\n").unwrap();
|
||||||
|
|
||||||
|
let result = NotifyTool::read_pushover_credentials(tmp.path());
|
||||||
|
assert!(result.is_some());
|
||||||
|
let (token, user_key) = result.unwrap();
|
||||||
|
assert_eq!(token, "realtoken");
|
||||||
|
assert_eq!(user_key, "realuser");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notify_tool_supports_priority() {
|
||||||
|
let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100));
|
||||||
|
let schema = tool.parameters_schema();
|
||||||
|
assert!(schema["properties"].get("priority").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notify_tool_supports_sound() {
|
||||||
|
let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100));
|
||||||
|
let schema = tool.parameters_schema();
|
||||||
|
assert!(schema["properties"].get("sound").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credentials_support_export_and_quoted_values() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let env_path = tmp.path().join(".env");
|
||||||
|
fs::write(
|
||||||
|
&env_path,
|
||||||
|
"export PUSHOVER_TOKEN=\"quotedtoken\"\nPUSHOVER_USER_KEY='quoteduser'\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = NotifyTool::read_pushover_credentials(tmp.path());
|
||||||
|
assert!(result.is_some());
|
||||||
|
let (token, user_key) = result.unwrap();
|
||||||
|
assert_eq!(token, "quotedtoken");
|
||||||
|
assert_eq!(user_key, "quoteduser");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_blocks_readonly_mode() {
|
||||||
|
let tool = make_pushover_tool(test_security(AutonomyLevel::ReadOnly, 100));
|
||||||
|
|
||||||
|
let result = tool.execute(json!({"message": "hello"})).await.unwrap();
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result.error.unwrap().contains("read-only"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_blocks_rate_limit() {
|
||||||
|
let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 0));
|
||||||
|
|
||||||
|
let result = tool.execute(json!({"message": "hello"})).await.unwrap();
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result.error.unwrap().contains("rate limit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_rejects_priority_out_of_range() {
|
||||||
|
let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100));
|
||||||
|
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({"message": "hello", "priority": 5}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result.error.unwrap().contains("-2..=2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_returns_none_when_no_backend_available() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let security = test_security(AutonomyLevel::Full, 100);
|
||||||
|
|
||||||
|
let result = NotifyTool::detect(security, tmp.path(), None);
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_prefers_pushover_when_both_available() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let env_path = tmp.path().join(".env");
|
||||||
|
fs::write(
|
||||||
|
&env_path,
|
||||||
|
"PUSHOVER_TOKEN=token\nPUSHOVER_USER_KEY=user\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tg = TelegramConfig {
|
||||||
|
bot_token: "123:ABC".into(),
|
||||||
|
allowed_users: vec!["456".into()],
|
||||||
|
stream_mode: crate::config::StreamMode::Off,
|
||||||
|
draft_update_interval_ms: 1000,
|
||||||
|
mention_only: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let security = test_security(AutonomyLevel::Full, 100);
|
||||||
|
let tool = NotifyTool::detect(security, tmp.path(), Some(&tg));
|
||||||
|
assert!(tool.is_some());
|
||||||
|
assert_eq!(tool.unwrap().backend_label(), "Pushover");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_falls_back_to_telegram_when_no_pushover_credentials() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let tg = TelegramConfig {
|
||||||
|
bot_token: "123:ABC".into(),
|
||||||
|
allowed_users: vec!["456".into()],
|
||||||
|
stream_mode: crate::config::StreamMode::Off,
|
||||||
|
draft_update_interval_ms: 1000,
|
||||||
|
mention_only: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let security = test_security(AutonomyLevel::Full, 100);
|
||||||
|
let tool = NotifyTool::detect(security, tmp.path(), Some(&tg));
|
||||||
|
assert!(tool.is_some());
|
||||||
|
assert_eq!(tool.unwrap().backend_label(), "Telegram");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_returns_none_for_telegram_with_empty_allowed_users() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let tg = TelegramConfig {
|
||||||
|
bot_token: "123:ABC".into(),
|
||||||
|
allowed_users: vec![],
|
||||||
|
stream_mode: crate::config::StreamMode::Off,
|
||||||
|
draft_update_interval_ms: 1000,
|
||||||
|
mention_only: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let security = test_security(AutonomyLevel::Full, 100);
|
||||||
|
let result = NotifyTool::detect(security, tmp.path(), Some(&tg));
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telegram_backend_formats_message_with_title() {
|
||||||
|
// Verify the format logic used by send_telegram
|
||||||
|
let title = Some("Alert");
|
||||||
|
let message = "Server is down";
|
||||||
|
let text = match title {
|
||||||
|
Some(t) if !t.is_empty() => format!("*{}*\n{}", t, message),
|
||||||
|
_ => message.to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(text, "*Alert*\nServer is down");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telegram_backend_formats_message_without_title() {
|
||||||
|
let title: Option<&str> = None;
|
||||||
|
let message = "Server is down";
|
||||||
|
let text = match title {
|
||||||
|
Some(t) if !t.is_empty() => format!("*{}*\n{}", t, message),
|
||||||
|
_ => message.to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(text, "Server is down");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,433 +0,0 @@
|
||||||
use super::traits::{Tool, ToolResult};
|
|
||||||
use crate::security::SecurityPolicy;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json";
|
|
||||||
const PUSHOVER_REQUEST_TIMEOUT_SECS: u64 = 15;
|
|
||||||
|
|
||||||
pub struct PushoverTool {
|
|
||||||
security: Arc<SecurityPolicy>,
|
|
||||||
workspace_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PushoverTool {
|
|
||||||
pub fn new(security: Arc<SecurityPolicy>, workspace_dir: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
security,
|
|
||||||
workspace_dir,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_env_value(raw: &str) -> String {
|
|
||||||
let raw = raw.trim();
|
|
||||||
|
|
||||||
let unquoted = if raw.len() >= 2
|
|
||||||
&& ((raw.starts_with('"') && raw.ends_with('"'))
|
|
||||||
|| (raw.starts_with('\'') && raw.ends_with('\'')))
|
|
||||||
{
|
|
||||||
&raw[1..raw.len() - 1]
|
|
||||||
} else {
|
|
||||||
raw
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keep support for inline comments in unquoted values:
|
|
||||||
// KEY=value # comment
|
|
||||||
unquoted.split_once(" #").map_or_else(
|
|
||||||
|| unquoted.trim().to_string(),
|
|
||||||
|(value, _)| value.trim().to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_credentials(&self) -> anyhow::Result<(String, String)> {
|
|
||||||
let env_path = self.workspace_dir.join(".env");
|
|
||||||
let content = std::fs::read_to_string(&env_path)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read {}: {}", env_path.display(), e))?;
|
|
||||||
|
|
||||||
let mut token = None;
|
|
||||||
let mut user_key = None;
|
|
||||||
|
|
||||||
for line in content.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
if line.starts_with('#') || line.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
|
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
|
||||||
let key = key.trim();
|
|
||||||
let value = Self::parse_env_value(value);
|
|
||||||
|
|
||||||
if key.eq_ignore_ascii_case("PUSHOVER_TOKEN") {
|
|
||||||
token = Some(value);
|
|
||||||
} else if key.eq_ignore_ascii_case("PUSHOVER_USER_KEY") {
|
|
||||||
user_key = Some(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = token.ok_or_else(|| anyhow::anyhow!("PUSHOVER_TOKEN not found in .env"))?;
|
|
||||||
let user_key =
|
|
||||||
user_key.ok_or_else(|| anyhow::anyhow!("PUSHOVER_USER_KEY not found in .env"))?;
|
|
||||||
|
|
||||||
Ok((token, user_key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for PushoverTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"pushover"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"message": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The notification message to send"
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Optional notification title"
|
|
||||||
},
|
|
||||||
"priority": {
|
|
||||||
"type": "integer",
|
|
||||||
"enum": [-2, -1, 0, 1, 2],
|
|
||||||
"description": "Message priority: -2 (lowest/silent), -1 (low/no sound), 0 (normal), 1 (high), 2 (emergency/repeating)"
|
|
||||||
},
|
|
||||||
"sound": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Notification sound override (e.g., 'pushover', 'bike', 'bugle', 'cashregister', etc.)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["message"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
||||||
if !self.security.can_act() {
|
|
||||||
return Ok(ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: String::new(),
|
|
||||||
error: Some("Action blocked: autonomy is read-only".into()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.security.record_action() {
|
|
||||||
return Ok(ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: String::new(),
|
|
||||||
error: Some("Action blocked: rate limit exceeded".into()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = args
|
|
||||||
.get("message")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let title = args.get("title").and_then(|v| v.as_str()).map(String::from);
|
|
||||||
|
|
||||||
let priority = match args.get("priority").and_then(|v| v.as_i64()) {
|
|
||||||
Some(value) if (-2..=2).contains(&value) => Some(value),
|
|
||||||
Some(value) => {
|
|
||||||
return Ok(ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: String::new(),
|
|
||||||
error: Some(format!(
|
|
||||||
"Invalid 'priority': {value}. Expected integer in range -2..=2"
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let sound = args.get("sound").and_then(|v| v.as_str()).map(String::from);
|
|
||||||
|
|
||||||
let (token, user_key) = self.get_credentials()?;
|
|
||||||
|
|
||||||
let mut form = reqwest::multipart::Form::new()
|
|
||||||
.text("token", token)
|
|
||||||
.text("user", user_key)
|
|
||||||
.text("message", message);
|
|
||||||
|
|
||||||
if let Some(title) = title {
|
|
||||||
form = form.text("title", title);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(priority) = priority {
|
|
||||||
form = form.text("priority", priority.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(sound) = sound {
|
|
||||||
form = form.text("sound", sound);
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = crate::config::build_runtime_proxy_client_with_timeouts(
|
|
||||||
"tool.pushover",
|
|
||||||
PUSHOVER_REQUEST_TIMEOUT_SECS,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
let response = client.post(PUSHOVER_API_URL).multipart(form).send().await?;
|
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
return Ok(ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: body,
|
|
||||||
error: Some(format!("Pushover API returned status {}", status)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let api_status = serde_json::from_str::<serde_json::Value>(&body)
|
|
||||||
.ok()
|
|
||||||
.and_then(|json| json.get("status").and_then(|value| value.as_i64()));
|
|
||||||
|
|
||||||
if api_status == Some(1) {
|
|
||||||
Ok(ToolResult {
|
|
||||||
success: true,
|
|
||||||
output: format!(
|
|
||||||
"Pushover notification sent successfully. Response: {}",
|
|
||||||
body
|
|
||||||
),
|
|
||||||
error: None,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Ok(ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: body,
|
|
||||||
error: Some("Pushover API returned an application-level error".into()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::security::AutonomyLevel;
|
|
||||||
use std::fs;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
fn test_security(level: AutonomyLevel, max_actions_per_hour: u32) -> Arc<SecurityPolicy> {
|
|
||||||
Arc::new(SecurityPolicy {
|
|
||||||
autonomy: level,
|
|
||||||
max_actions_per_hour,
|
|
||||||
workspace_dir: std::env::temp_dir(),
|
|
||||||
..SecurityPolicy::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pushover_tool_name() {
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
);
|
|
||||||
assert_eq!(tool.name(), "pushover");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pushover_tool_description() {
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
);
|
|
||||||
assert!(!tool.description().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pushover_tool_has_parameters_schema() {
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
);
|
|
||||||
let schema = tool.parameters_schema();
|
|
||||||
assert_eq!(schema["type"], "object");
|
|
||||||
assert!(schema["properties"].get("message").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pushover_tool_requires_message() {
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
);
|
|
||||||
let schema = tool.parameters_schema();
|
|
||||||
let required = schema["required"].as_array().unwrap();
|
|
||||||
assert!(required.contains(&serde_json::Value::String("message".to_string())));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn credentials_parsed_from_env_file() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let env_path = tmp.path().join(".env");
|
|
||||||
fs::write(
|
|
||||||
&env_path,
|
|
||||||
"PUSHOVER_TOKEN=testtoken123\nPUSHOVER_USER_KEY=userkey456\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
tmp.path().to_path_buf(),
|
|
||||||
);
|
|
||||||
let result = tool.get_credentials();
|
|
||||||
|
|
||||||
assert!(result.is_ok());
|
|
||||||
let (token, user_key) = result.unwrap();
|
|
||||||
assert_eq!(token, "testtoken123");
|
|
||||||
assert_eq!(user_key, "userkey456");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn credentials_fail_without_env_file() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
tmp.path().to_path_buf(),
|
|
||||||
);
|
|
||||||
let result = tool.get_credentials();
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn credentials_fail_without_token() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let env_path = tmp.path().join(".env");
|
|
||||||
fs::write(&env_path, "PUSHOVER_USER_KEY=userkey456\n").unwrap();
|
|
||||||
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
tmp.path().to_path_buf(),
|
|
||||||
);
|
|
||||||
let result = tool.get_credentials();
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn credentials_fail_without_user_key() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let env_path = tmp.path().join(".env");
|
|
||||||
fs::write(&env_path, "PUSHOVER_TOKEN=testtoken123\n").unwrap();
|
|
||||||
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
tmp.path().to_path_buf(),
|
|
||||||
);
|
|
||||||
let result = tool.get_credentials();
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn credentials_ignore_comments() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let env_path = tmp.path().join(".env");
|
|
||||||
fs::write(&env_path, "# This is a comment\nPUSHOVER_TOKEN=realtoken\n# Another comment\nPUSHOVER_USER_KEY=realuser\n").unwrap();
|
|
||||||
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
tmp.path().to_path_buf(),
|
|
||||||
);
|
|
||||||
let result = tool.get_credentials();
|
|
||||||
|
|
||||||
assert!(result.is_ok());
|
|
||||||
let (token, user_key) = result.unwrap();
|
|
||||||
assert_eq!(token, "realtoken");
|
|
||||||
assert_eq!(user_key, "realuser");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pushover_tool_supports_priority() {
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
);
|
|
||||||
let schema = tool.parameters_schema();
|
|
||||||
assert!(schema["properties"].get("priority").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pushover_tool_supports_sound() {
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
);
|
|
||||||
let schema = tool.parameters_schema();
|
|
||||||
assert!(schema["properties"].get("sound").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn credentials_support_export_and_quoted_values() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let env_path = tmp.path().join(".env");
|
|
||||||
fs::write(
|
|
||||||
&env_path,
|
|
||||||
"export PUSHOVER_TOKEN=\"quotedtoken\"\nPUSHOVER_USER_KEY='quoteduser'\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
tmp.path().to_path_buf(),
|
|
||||||
);
|
|
||||||
let result = tool.get_credentials();
|
|
||||||
|
|
||||||
assert!(result.is_ok());
|
|
||||||
let (token, user_key) = result.unwrap();
|
|
||||||
assert_eq!(token, "quotedtoken");
|
|
||||||
assert_eq!(user_key, "quoteduser");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn execute_blocks_readonly_mode() {
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::ReadOnly, 100),
|
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = tool.execute(json!({"message": "hello"})).await.unwrap();
|
|
||||||
assert!(!result.success);
|
|
||||||
assert!(result.error.unwrap().contains("read-only"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn execute_blocks_rate_limit() {
|
|
||||||
let tool = PushoverTool::new(test_security(AutonomyLevel::Full, 0), PathBuf::from("/tmp"));
|
|
||||||
|
|
||||||
let result = tool.execute(json!({"message": "hello"})).await.unwrap();
|
|
||||||
assert!(!result.success);
|
|
||||||
assert!(result.error.unwrap().contains("rate limit"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn execute_rejects_priority_out_of_range() {
|
|
||||||
let tool = PushoverTool::new(
|
|
||||||
test_security(AutonomyLevel::Full, 100),
|
|
||||||
PathBuf::from("/tmp"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = tool
|
|
||||||
.execute(json!({"message": "hello", "priority": 5}))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(!result.success);
|
|
||||||
assert!(result.error.unwrap().contains("-2..=2"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue