Compare commits
6 Commits
master
...
feat/micro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0f87fa3d | ||
|
|
97b9739320 | ||
|
|
d600c775c2 | ||
|
|
c7f341885b | ||
|
|
71b16357f3 | ||
|
|
2dbe4e4812 |
@ -11,12 +11,12 @@ pub use schema::{
|
|||||||
ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig,
|
ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig,
|
||||||
GoogleTtsConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
|
GoogleTtsConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
|
||||||
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig,
|
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig,
|
||||||
McpServerConfig, McpTransport, MemoryConfig, ModelRouteConfig, MultimodalConfig,
|
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
|
||||||
NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig, OtpConfig, OtpMethod,
|
MultimodalConfig, NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig,
|
||||||
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QdrantConfig,
|
OtpConfig, OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope,
|
||||||
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
|
QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
|
||||||
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig,
|
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
|
||||||
SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
|
SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||||
StorageProviderSection, StreamMode, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
|
StorageProviderSection, StreamMode, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
|
||||||
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -188,6 +188,10 @@ pub struct Config {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub composio: ComposioConfig,
|
pub composio: ComposioConfig,
|
||||||
|
|
||||||
|
/// Microsoft 365 Graph API integration (`[microsoft365]`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub microsoft365: Microsoft365Config,
|
||||||
|
|
||||||
/// Secrets encryption configuration (`[secrets]`).
|
/// Secrets encryption configuration (`[secrets]`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub secrets: SecretsConfig,
|
pub secrets: SecretsConfig,
|
||||||
@ -1282,6 +1286,78 @@ impl Default for ComposioConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Microsoft 365 (Graph API integration) ───────────────────────
|
||||||
|
|
||||||
|
/// Microsoft 365 integration via Microsoft Graph API (`[microsoft365]` section).
|
||||||
|
///
|
||||||
|
/// Provides access to Outlook mail, Teams messages, Calendar events,
|
||||||
|
/// OneDrive files, and SharePoint search.
|
||||||
|
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct Microsoft365Config {
|
||||||
|
/// Enable Microsoft 365 integration
|
||||||
|
#[serde(default, alias = "enable")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Azure AD tenant ID
|
||||||
|
#[serde(default)]
|
||||||
|
pub tenant_id: Option<String>,
|
||||||
|
/// Azure AD application (client) ID
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
/// Azure AD client secret (stored encrypted when secrets.encrypt = true)
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
/// Authentication flow: "client_credentials" or "device_code"
|
||||||
|
#[serde(default = "default_ms365_auth_flow")]
|
||||||
|
pub auth_flow: String,
|
||||||
|
/// OAuth scopes to request
|
||||||
|
#[serde(default = "default_ms365_scopes")]
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
/// Encrypt the token cache file on disk
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub token_cache_encrypted: bool,
|
||||||
|
/// User principal name or "me" (for delegated flows)
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ms365_auth_flow() -> String {
|
||||||
|
"client_credentials".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ms365_scopes() -> Vec<String> {
|
||||||
|
vec!["https://graph.microsoft.com/.default".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Microsoft365Config {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Microsoft365Config")
|
||||||
|
.field("enabled", &self.enabled)
|
||||||
|
.field("tenant_id", &self.tenant_id)
|
||||||
|
.field("client_id", &self.client_id)
|
||||||
|
.field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
|
||||||
|
.field("auth_flow", &self.auth_flow)
|
||||||
|
.field("scopes", &self.scopes)
|
||||||
|
.field("token_cache_encrypted", &self.token_cache_encrypted)
|
||||||
|
.field("user_id", &self.user_id)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Microsoft365Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
tenant_id: None,
|
||||||
|
client_id: None,
|
||||||
|
client_secret: None,
|
||||||
|
auth_flow: default_ms365_auth_flow(),
|
||||||
|
scopes: default_ms365_scopes(),
|
||||||
|
token_cache_encrypted: true,
|
||||||
|
user_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Secrets (encrypted credential store) ────────────────────────
|
// ── Secrets (encrypted credential store) ────────────────────────
|
||||||
|
|
||||||
/// Secrets encryption configuration (`[secrets]` section).
|
/// Secrets encryption configuration (`[secrets]` section).
|
||||||
@ -4186,6 +4262,7 @@ impl Default for Config {
|
|||||||
tunnel: TunnelConfig::default(),
|
tunnel: TunnelConfig::default(),
|
||||||
gateway: GatewayConfig::default(),
|
gateway: GatewayConfig::default(),
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
|
microsoft365: Microsoft365Config::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
browser: BrowserConfig::default(),
|
browser: BrowserConfig::default(),
|
||||||
http_request: HttpRequestConfig::default(),
|
http_request: HttpRequestConfig::default(),
|
||||||
@ -4679,6 +4756,11 @@ impl Config {
|
|||||||
&mut config.composio.api_key,
|
&mut config.composio.api_key,
|
||||||
"config.composio.api_key",
|
"config.composio.api_key",
|
||||||
)?;
|
)?;
|
||||||
|
decrypt_optional_secret(
|
||||||
|
&store,
|
||||||
|
&mut config.microsoft365.client_secret,
|
||||||
|
"config.microsoft365.client_secret",
|
||||||
|
)?;
|
||||||
|
|
||||||
decrypt_optional_secret(
|
decrypt_optional_secret(
|
||||||
&store,
|
&store,
|
||||||
@ -5227,6 +5309,63 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Microsoft 365
|
||||||
|
if self.microsoft365.enabled {
|
||||||
|
let tenant = self
|
||||||
|
.microsoft365
|
||||||
|
.tenant_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
if tenant.is_none() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"microsoft365.tenant_id must not be empty when microsoft365 is enabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Validate tenant_id format to prevent URL injection in OAuth
|
||||||
|
// endpoints. Azure AD tenant IDs are either UUIDs or verified
|
||||||
|
// domain names (alphanumeric, hyphens, dots).
|
||||||
|
if let Some(tid) = tenant {
|
||||||
|
if !tid
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '.')
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"microsoft365.tenant_id contains invalid characters; \
|
||||||
|
expected UUID or domain format (alphanumeric, hyphens, dots)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let client = self
|
||||||
|
.microsoft365
|
||||||
|
.client_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
if client.is_none() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"microsoft365.client_id must not be empty when microsoft365 is enabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let flow = self.microsoft365.auth_flow.trim();
|
||||||
|
if flow != "client_credentials" && flow != "device_code" {
|
||||||
|
anyhow::bail!(
|
||||||
|
"microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if flow == "client_credentials"
|
||||||
|
&& self
|
||||||
|
.microsoft365
|
||||||
|
.client_secret
|
||||||
|
.as_deref()
|
||||||
|
.map_or(true, |s| s.trim().is_empty())
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MCP
|
// MCP
|
||||||
if self.mcp.enabled {
|
if self.mcp.enabled {
|
||||||
validate_mcp_config(&self.mcp)?;
|
validate_mcp_config(&self.mcp)?;
|
||||||
@ -5606,6 +5745,11 @@ impl Config {
|
|||||||
&mut config_to_save.composio.api_key,
|
&mut config_to_save.composio.api_key,
|
||||||
"config.composio.api_key",
|
"config.composio.api_key",
|
||||||
)?;
|
)?;
|
||||||
|
encrypt_optional_secret(
|
||||||
|
&store,
|
||||||
|
&mut config_to_save.microsoft365.client_secret,
|
||||||
|
"config.microsoft365.client_secret",
|
||||||
|
)?;
|
||||||
|
|
||||||
encrypt_optional_secret(
|
encrypt_optional_secret(
|
||||||
&store,
|
&store,
|
||||||
@ -5950,6 +6094,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::unused_async)]
|
||||||
async fn sync_directory(path: &Path) -> Result<()> {
|
async fn sync_directory(path: &Path) -> Result<()> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@ -5975,6 +6120,7 @@ mod tests {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
#[allow(unused_imports)]
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio::sync::{Mutex, MutexGuard};
|
use tokio::sync::{Mutex, MutexGuard};
|
||||||
use tokio::test;
|
use tokio::test;
|
||||||
@ -6292,6 +6438,7 @@ default_temperature = 0.7
|
|||||||
tunnel: TunnelConfig::default(),
|
tunnel: TunnelConfig::default(),
|
||||||
gateway: GatewayConfig::default(),
|
gateway: GatewayConfig::default(),
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
|
microsoft365: Microsoft365Config::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
browser: BrowserConfig::default(),
|
browser: BrowserConfig::default(),
|
||||||
http_request: HttpRequestConfig::default(),
|
http_request: HttpRequestConfig::default(),
|
||||||
@ -6583,6 +6730,7 @@ tool_dispatcher = "xml"
|
|||||||
tunnel: TunnelConfig::default(),
|
tunnel: TunnelConfig::default(),
|
||||||
gateway: GatewayConfig::default(),
|
gateway: GatewayConfig::default(),
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
|
microsoft365: Microsoft365Config::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
browser: BrowserConfig::default(),
|
browser: BrowserConfig::default(),
|
||||||
http_request: HttpRequestConfig::default(),
|
http_request: HttpRequestConfig::default(),
|
||||||
|
|||||||
@ -159,6 +159,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
|||||||
tunnel: tunnel_config,
|
tunnel: tunnel_config,
|
||||||
gateway: crate::config::GatewayConfig::default(),
|
gateway: crate::config::GatewayConfig::default(),
|
||||||
composio: composio_config,
|
composio: composio_config,
|
||||||
|
microsoft365: crate::config::Microsoft365Config::default(),
|
||||||
secrets: secrets_config,
|
secrets: secrets_config,
|
||||||
browser: BrowserConfig::default(),
|
browser: BrowserConfig::default(),
|
||||||
http_request: crate::config::HttpRequestConfig::default(),
|
http_request: crate::config::HttpRequestConfig::default(),
|
||||||
@ -516,6 +517,7 @@ async fn run_quick_setup_with_home(
|
|||||||
tunnel: crate::config::TunnelConfig::default(),
|
tunnel: crate::config::TunnelConfig::default(),
|
||||||
gateway: crate::config::GatewayConfig::default(),
|
gateway: crate::config::GatewayConfig::default(),
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
|
microsoft365: crate::config::Microsoft365Config::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
browser: BrowserConfig::default(),
|
browser: BrowserConfig::default(),
|
||||||
http_request: crate::config::HttpRequestConfig::default(),
|
http_request: crate::config::HttpRequestConfig::default(),
|
||||||
|
|||||||
431
src/tools/microsoft365/auth.rs
Normal file
431
src/tools/microsoft365/auth.rs
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// Cached OAuth2 token state persisted to disk between runs.
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CachedTokenState {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
/// Unix timestamp (seconds) when the access token expires.
|
||||||
|
pub expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for CachedTokenState {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("CachedTokenState")
|
||||||
|
.field("access_token", &"***")
|
||||||
|
.field("refresh_token", &self.refresh_token.as_ref().map(|_| "***"))
|
||||||
|
.field("expires_at", &self.expires_at)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedTokenState {
|
||||||
|
/// Returns `true` when the token is expired or will expire within 60 seconds.
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
self.expires_at <= now + 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe token cache with disk persistence.
|
||||||
|
pub struct TokenCache {
|
||||||
|
inner: RwLock<Option<CachedTokenState>>,
|
||||||
|
/// Serialises the slow acquire/refresh path so only one caller performs the
|
||||||
|
/// network round-trip while others wait and then read the updated cache.
|
||||||
|
acquire_lock: Mutex<()>,
|
||||||
|
config: super::types::Microsoft365ResolvedConfig,
|
||||||
|
cache_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenCache {
|
||||||
|
pub fn new(
|
||||||
|
config: super::types::Microsoft365ResolvedConfig,
|
||||||
|
zeroclaw_dir: &std::path::Path,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
if config.token_cache_encrypted {
|
||||||
|
anyhow::bail!(
|
||||||
|
"microsoft365: token_cache_encrypted is enabled but encryption is not yet \
|
||||||
|
implemented; refusing to store tokens in plaintext. Set token_cache_encrypted \
|
||||||
|
to false or wait for encryption support."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope cache file to (tenant_id, client_id, auth_flow) so config
|
||||||
|
// changes never reuse tokens from a different account/flow.
|
||||||
|
// Use a deterministic fingerprint (not DefaultHasher which is unstable
|
||||||
|
// across Rust versions and would orphan cache files on toolchain updates).
|
||||||
|
let fingerprint = format!(
|
||||||
|
"{}_{}_{}", config.tenant_id, config.client_id, config.auth_flow
|
||||||
|
).replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "_");
|
||||||
|
|
||||||
|
let cache_path = zeroclaw_dir.join(format!("ms365_token_cache_{fingerprint}.json"));
|
||||||
|
let cached = Self::load_from_disk(&cache_path);
|
||||||
|
Ok(Self {
|
||||||
|
inner: RwLock::new(cached),
|
||||||
|
acquire_lock: Mutex::new(()),
|
||||||
|
config,
|
||||||
|
cache_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a valid access token, refreshing or re-authenticating as needed.
|
||||||
|
pub async fn get_token(&self, client: &reqwest::Client) -> anyhow::Result<String> {
|
||||||
|
// Fast path: cached and not expired.
|
||||||
|
{
|
||||||
|
let guard = self.inner.read();
|
||||||
|
if let Some(ref state) = *guard {
|
||||||
|
if !state.is_expired() {
|
||||||
|
return Ok(state.access_token.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: serialise through a mutex so only one caller performs the
|
||||||
|
// network round-trip while concurrent callers wait and re-check.
|
||||||
|
let _lock = self.acquire_lock.lock().await;
|
||||||
|
|
||||||
|
// Re-check after acquiring the lock — another caller may have refreshed
|
||||||
|
// while we were waiting.
|
||||||
|
{
|
||||||
|
let guard = self.inner.read();
|
||||||
|
if let Some(ref state) = *guard {
|
||||||
|
if !state.is_expired() {
|
||||||
|
return Ok(state.access_token.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_state = self.acquire_token(client).await?;
|
||||||
|
let token = new_state.access_token.clone();
|
||||||
|
self.persist_to_disk(&new_state);
|
||||||
|
*self.inner.write() = Some(new_state);
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn acquire_token(&self, client: &reqwest::Client) -> anyhow::Result<CachedTokenState> {
|
||||||
|
// Try refresh first if we have a refresh token and the flow supports it.
|
||||||
|
// Client credentials flow does not issue refresh tokens, so skip the
|
||||||
|
// attempt entirely to avoid a wasted round-trip.
|
||||||
|
if self.config.auth_flow.as_str() != "client_credentials" {
|
||||||
|
// Clone the token out so the RwLock guard is dropped before the await.
|
||||||
|
let refresh_token_copy = {
|
||||||
|
let guard = self.inner.read();
|
||||||
|
guard.as_ref().and_then(|state| state.refresh_token.clone())
|
||||||
|
};
|
||||||
|
if let Some(refresh_tok) = refresh_token_copy {
|
||||||
|
match self.refresh_token(client, &refresh_tok).await {
|
||||||
|
Ok(new_state) => return Ok(new_state),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("ms365: refresh token failed, re-authenticating: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.config.auth_flow.as_str() {
|
||||||
|
"client_credentials" => self.client_credentials_flow(client).await,
|
||||||
|
"device_code" => self.device_code_flow(client).await,
|
||||||
|
other => anyhow::bail!("Unsupported auth flow: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn client_credentials_flow(
|
||||||
|
&self,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
) -> anyhow::Result<CachedTokenState> {
|
||||||
|
let client_secret = self
|
||||||
|
.config
|
||||||
|
.client_secret
|
||||||
|
.as_deref()
|
||||||
|
.context("client_credentials flow requires client_secret")?;
|
||||||
|
|
||||||
|
let token_url = format!(
|
||||||
|
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
|
||||||
|
self.config.tenant_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let scope = self.config.scopes.join(" ");
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&token_url)
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "client_credentials"),
|
||||||
|
("client_id", &self.config.client_id),
|
||||||
|
("client_secret", client_secret),
|
||||||
|
("scope", &scope),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: failed to request client_credentials token")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
tracing::debug!("ms365: client_credentials raw OAuth error: {body}");
|
||||||
|
anyhow::bail!("ms365: client_credentials token request failed ({status})");
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_resp: TokenResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("ms365: failed to parse token response")?;
|
||||||
|
|
||||||
|
Ok(CachedTokenState {
|
||||||
|
access_token: token_resp.access_token,
|
||||||
|
refresh_token: token_resp.refresh_token,
|
||||||
|
expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn device_code_flow(&self, client: &reqwest::Client) -> anyhow::Result<CachedTokenState> {
|
||||||
|
let device_code_url = format!(
|
||||||
|
"https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
|
||||||
|
self.config.tenant_id
|
||||||
|
);
|
||||||
|
let scope = self.config.scopes.join(" ");
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&device_code_url)
|
||||||
|
.form(&[
|
||||||
|
("client_id", self.config.client_id.as_str()),
|
||||||
|
("scope", &scope),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: failed to request device code")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
tracing::debug!("ms365: device_code initiation raw error: {body}");
|
||||||
|
anyhow::bail!("ms365: device code request failed ({status})");
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_resp: DeviceCodeResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("ms365: failed to parse device code response")?;
|
||||||
|
|
||||||
|
// Log only a generic prompt; the full device_resp.message may contain
|
||||||
|
// sensitive verification URIs or codes that should not appear in logs.
|
||||||
|
tracing::info!(
|
||||||
|
"ms365: device code auth required — follow the instructions shown to the user"
|
||||||
|
);
|
||||||
|
// Print the user-facing message to stderr so the operator can act on it
|
||||||
|
// without it being captured in structured log sinks.
|
||||||
|
eprintln!("ms365: {}", device_resp.message);
|
||||||
|
|
||||||
|
let token_url = format!(
|
||||||
|
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
|
||||||
|
self.config.tenant_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let interval = device_resp.interval.max(5);
|
||||||
|
let max_polls = u32::try_from(
|
||||||
|
(device_resp.expires_in / i64::try_from(interval).unwrap_or(i64::MAX)).max(1),
|
||||||
|
)
|
||||||
|
.unwrap_or(u32::MAX);
|
||||||
|
|
||||||
|
for _ in 0..max_polls {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
|
||||||
|
|
||||||
|
let poll_resp = client
|
||||||
|
.post(&token_url)
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||||
|
("client_id", self.config.client_id.as_str()),
|
||||||
|
("device_code", &device_resp.device_code),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: failed to poll device code token")?;
|
||||||
|
|
||||||
|
if poll_resp.status().is_success() {
|
||||||
|
let token_resp: TokenResponse = poll_resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("ms365: failed to parse token response")?;
|
||||||
|
return Ok(CachedTokenState {
|
||||||
|
access_token: token_resp.access_token,
|
||||||
|
refresh_token: token_resp.refresh_token,
|
||||||
|
expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = poll_resp.text().await.unwrap_or_default();
|
||||||
|
if body.contains("authorization_pending") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if body.contains("slow_down") {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tracing::debug!("ms365: device code polling raw error: {body}");
|
||||||
|
anyhow::bail!("ms365: device code polling failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow::bail!("ms365: device code flow timed out waiting for user authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_token(
|
||||||
|
&self,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
refresh_token: &str,
|
||||||
|
) -> anyhow::Result<CachedTokenState> {
|
||||||
|
let token_url = format!(
|
||||||
|
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
|
||||||
|
self.config.tenant_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut params = vec![
|
||||||
|
("grant_type", "refresh_token"),
|
||||||
|
("client_id", self.config.client_id.as_str()),
|
||||||
|
("refresh_token", refresh_token),
|
||||||
|
];
|
||||||
|
|
||||||
|
let secret_ref;
|
||||||
|
if let Some(ref secret) = self.config.client_secret {
|
||||||
|
secret_ref = secret.as_str();
|
||||||
|
params.push(("client_secret", secret_ref));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&token_url)
|
||||||
|
.form(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: failed to refresh token")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
tracing::debug!("ms365: token refresh raw error: {body}");
|
||||||
|
anyhow::bail!("ms365: token refresh failed ({status})");
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_resp: TokenResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("ms365: failed to parse refresh token response")?;
|
||||||
|
|
||||||
|
Ok(CachedTokenState {
|
||||||
|
access_token: token_resp.access_token,
|
||||||
|
refresh_token: token_resp
|
||||||
|
.refresh_token
|
||||||
|
.or_else(|| Some(refresh_token.to_string())),
|
||||||
|
expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_disk(path: &std::path::Path) -> Option<CachedTokenState> {
|
||||||
|
let data = std::fs::read_to_string(path).ok()?;
|
||||||
|
serde_json::from_str(&data).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_to_disk(&self, state: &CachedTokenState) {
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(state) {
|
||||||
|
if let Err(e) = Self::write_restricted(&self.cache_path, &json) {
|
||||||
|
tracing::warn!("ms365: failed to persist token cache: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write file with restricted permissions (0o600 on Unix) to prevent
|
||||||
|
/// other local users from reading cached OAuth tokens.
|
||||||
|
fn write_restricted(path: &std::path::Path, content: &str) -> std::io::Result<()> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)?;
|
||||||
|
file.write_all(content.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
std::fs::write(path, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
#[serde(default = "default_expires_in")]
|
||||||
|
expires_in: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_expires_in() -> i64 {
|
||||||
|
3600
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DeviceCodeResponse {
|
||||||
|
device_code: String,
|
||||||
|
message: String,
|
||||||
|
#[serde(default = "default_device_interval")]
|
||||||
|
interval: u64,
|
||||||
|
#[serde(default = "default_device_expires_in")]
|
||||||
|
expires_in: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_device_interval() -> u64 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_device_expires_in() -> i64 {
|
||||||
|
900
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_is_expired_when_past_deadline() {
|
||||||
|
let state = CachedTokenState {
|
||||||
|
access_token: "test".into(),
|
||||||
|
refresh_token: None,
|
||||||
|
expires_at: chrono::Utc::now().timestamp() - 10,
|
||||||
|
};
|
||||||
|
assert!(state.is_expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_is_expired_within_buffer() {
|
||||||
|
let state = CachedTokenState {
|
||||||
|
access_token: "test".into(),
|
||||||
|
refresh_token: None,
|
||||||
|
expires_at: chrono::Utc::now().timestamp() + 30,
|
||||||
|
};
|
||||||
|
assert!(state.is_expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_is_valid_when_far_from_expiry() {
|
||||||
|
let state = CachedTokenState {
|
||||||
|
access_token: "test".into(),
|
||||||
|
refresh_token: None,
|
||||||
|
expires_at: chrono::Utc::now().timestamp() + 3600,
|
||||||
|
};
|
||||||
|
assert!(!state.is_expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_disk_returns_none_for_missing_file() {
|
||||||
|
let path = std::path::Path::new("/nonexistent/ms365_token_cache.json");
|
||||||
|
assert!(TokenCache::load_from_disk(path).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
516
src/tools/microsoft365/graph_client.rs
Normal file
516
src/tools/microsoft365/graph_client.rs
Normal file
@ -0,0 +1,516 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
|
||||||
|
|
||||||
|
/// Build the user path segment: `/me` or `/users/{user_id}`.
|
||||||
|
/// The user_id is percent-encoded to prevent path-traversal attacks.
|
||||||
|
fn user_path(user_id: &str) -> String {
|
||||||
|
if user_id == "me" {
|
||||||
|
"/me".to_string()
|
||||||
|
} else {
|
||||||
|
format!("/users/{}", urlencoding::encode(user_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent-encode a single path segment to prevent path-traversal attacks.
|
||||||
|
fn encode_path_segment(segment: &str) -> String {
|
||||||
|
urlencoding::encode(segment).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List mail messages for a user.
|
||||||
|
pub async fn mail_list(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
user_id: &str,
|
||||||
|
folder: Option<&str>,
|
||||||
|
top: u32,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let base = user_path(user_id);
|
||||||
|
let path = match folder {
|
||||||
|
Some(f) => format!(
|
||||||
|
"{GRAPH_BASE}{base}/mailFolders/{}/messages",
|
||||||
|
encode_path_segment(f)
|
||||||
|
),
|
||||||
|
None => format!("{GRAPH_BASE}{base}/messages"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&path)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.query(&[("$top", top.to_string())])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: mail_list request failed")?;
|
||||||
|
|
||||||
|
handle_json_response(resp, "mail_list").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a mail message.
|
||||||
|
pub async fn mail_send(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
user_id: &str,
|
||||||
|
to: &[String],
|
||||||
|
subject: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let base = user_path(user_id);
|
||||||
|
let url = format!("{GRAPH_BASE}{base}/sendMail");
|
||||||
|
|
||||||
|
let to_recipients: Vec<serde_json::Value> = to
|
||||||
|
.iter()
|
||||||
|
.map(|addr| {
|
||||||
|
serde_json::json!({
|
||||||
|
"emailAddress": { "address": addr }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"message": {
|
||||||
|
"subject": subject,
|
||||||
|
"body": {
|
||||||
|
"contentType": "Text",
|
||||||
|
"content": body
|
||||||
|
},
|
||||||
|
"toRecipients": to_recipients
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: mail_send request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
tracing::debug!("ms365: mail_send raw error body: {body}");
|
||||||
|
anyhow::bail!("ms365: mail_send failed ({status}, code={code})");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List messages in a Teams channel.
|
||||||
|
pub async fn teams_message_list(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
team_id: &str,
|
||||||
|
channel_id: &str,
|
||||||
|
top: u32,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let url = format!(
|
||||||
|
"{GRAPH_BASE}/teams/{}/channels/{}/messages",
|
||||||
|
encode_path_segment(team_id),
|
||||||
|
encode_path_segment(channel_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.query(&[("$top", top.to_string())])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: teams_message_list request failed")?;
|
||||||
|
|
||||||
|
handle_json_response(resp, "teams_message_list").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message to a Teams channel.
|
||||||
|
pub async fn teams_message_send(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
team_id: &str,
|
||||||
|
channel_id: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let url = format!(
|
||||||
|
"{GRAPH_BASE}/teams/{}/channels/{}/messages",
|
||||||
|
encode_path_segment(team_id),
|
||||||
|
encode_path_segment(channel_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"body": {
|
||||||
|
"content": body
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: teams_message_send request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
tracing::debug!("ms365: teams_message_send raw error body: {body}");
|
||||||
|
anyhow::bail!("ms365: teams_message_send failed ({status}, code={code})");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List calendar events in a date range.
|
||||||
|
pub async fn calendar_events_list(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
user_id: &str,
|
||||||
|
start: &str,
|
||||||
|
end: &str,
|
||||||
|
top: u32,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let base = user_path(user_id);
|
||||||
|
let url = format!("{GRAPH_BASE}{base}/calendarView");
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.query(&[
|
||||||
|
("startDateTime", start.to_string()),
|
||||||
|
("endDateTime", end.to_string()),
|
||||||
|
("$top", top.to_string()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: calendar_events_list request failed")?;
|
||||||
|
|
||||||
|
handle_json_response(resp, "calendar_events_list").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a calendar event.
|
||||||
|
pub async fn calendar_event_create(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
user_id: &str,
|
||||||
|
subject: &str,
|
||||||
|
start: &str,
|
||||||
|
end: &str,
|
||||||
|
attendees: &[String],
|
||||||
|
body_text: Option<&str>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let base = user_path(user_id);
|
||||||
|
let url = format!("{GRAPH_BASE}{base}/events");
|
||||||
|
|
||||||
|
let attendee_list: Vec<serde_json::Value> = attendees
|
||||||
|
.iter()
|
||||||
|
.map(|email| {
|
||||||
|
serde_json::json!({
|
||||||
|
"emailAddress": { "address": email },
|
||||||
|
"type": "required"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut payload = serde_json::json!({
|
||||||
|
"subject": subject,
|
||||||
|
"start": {
|
||||||
|
"dateTime": start,
|
||||||
|
"timeZone": "UTC"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"dateTime": end,
|
||||||
|
"timeZone": "UTC"
|
||||||
|
},
|
||||||
|
"attendees": attendee_list
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(text) = body_text {
|
||||||
|
payload["body"] = serde_json::json!({
|
||||||
|
"contentType": "Text",
|
||||||
|
"content": text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: calendar_event_create request failed")?;
|
||||||
|
|
||||||
|
let value = handle_json_response(resp, "calendar_event_create").await?;
|
||||||
|
let event_id = value["id"].as_str().unwrap_or("unknown").to_string();
|
||||||
|
Ok(event_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a calendar event by ID.
|
||||||
|
pub async fn calendar_event_delete(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
user_id: &str,
|
||||||
|
event_id: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let base = user_path(user_id);
|
||||||
|
let url = format!(
|
||||||
|
"{GRAPH_BASE}{base}/events/{}",
|
||||||
|
encode_path_segment(event_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: calendar_event_delete request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
tracing::debug!("ms365: calendar_event_delete raw error body: {body}");
|
||||||
|
anyhow::bail!("ms365: calendar_event_delete failed ({status}, code={code})");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List children of a OneDrive folder.
|
||||||
|
pub async fn onedrive_list(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
user_id: &str,
|
||||||
|
path: Option<&str>,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let base = user_path(user_id);
|
||||||
|
let url = match path {
|
||||||
|
Some(p) if !p.is_empty() => {
|
||||||
|
// Encode each path component individually, preserving '/' separators.
|
||||||
|
// Graph API expects literal '/' in the root:/{path}: syntax.
|
||||||
|
let encoded: String = p
|
||||||
|
.split('/')
|
||||||
|
.map(|seg| urlencoding::encode(seg).into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children")
|
||||||
|
}
|
||||||
|
_ => format!("{GRAPH_BASE}{base}/drive/root/children"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: onedrive_list request failed")?;
|
||||||
|
|
||||||
|
handle_json_response(resp, "onedrive_list").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a OneDrive item by ID, with a maximum size guard.
|
||||||
|
pub async fn onedrive_download(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
user_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
max_size: usize,
|
||||||
|
) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let base = user_path(user_id);
|
||||||
|
let url = format!(
|
||||||
|
"{GRAPH_BASE}{base}/drive/items/{}/content",
|
||||||
|
encode_path_segment(item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: onedrive_download request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
tracing::debug!("ms365: onedrive_download raw error body: {body}");
|
||||||
|
anyhow::bail!("ms365: onedrive_download failed ({status}, code={code})");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Content-Length header first to reject oversized files before
|
||||||
|
// buffering the entire response body into memory.
|
||||||
|
if let Some(content_length) = resp.content_length() {
|
||||||
|
if usize::try_from(content_length).unwrap_or(usize::MAX) > max_size {
|
||||||
|
anyhow::bail!(
|
||||||
|
"ms365: file Content-Length exceeds max_size ({content_length} > {max_size})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.context("ms365: failed to read download body")?;
|
||||||
|
if bytes.len() > max_size {
|
||||||
|
anyhow::bail!(
|
||||||
|
"ms365: downloaded file exceeds max_size ({} > {max_size})",
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search SharePoint for documents matching a query.
|
||||||
|
pub async fn sharepoint_search(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
query: &str,
|
||||||
|
top: u32,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let url = format!("{GRAPH_BASE}/search/query");
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"requests": [{
|
||||||
|
"entityTypes": ["driveItem", "listItem", "site"],
|
||||||
|
"query": {
|
||||||
|
"queryString": query
|
||||||
|
},
|
||||||
|
"from": 0,
|
||||||
|
"size": top
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("ms365: sharepoint_search request failed")?;
|
||||||
|
|
||||||
|
handle_json_response(resp, "sharepoint_search").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a short, safe error code from a Graph API JSON error body.
|
||||||
|
/// Returns `None` when the body is not a recognised Graph error envelope.
|
||||||
|
fn extract_graph_error_code(body: &str) -> Option<String> {
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||||
|
let code = parsed
|
||||||
|
.get("error")
|
||||||
|
.and_then(|e| e.get("code"))
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
code
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a JSON response body, returning an error on non-success status.
|
||||||
|
/// Raw Graph API error bodies are not propagated; only the HTTP status and a
|
||||||
|
/// short error code (when available) are surfaced to avoid leaking internal
|
||||||
|
/// API details.
|
||||||
|
async fn handle_json_response(
|
||||||
|
resp: reqwest::Response,
|
||||||
|
operation: &str,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
tracing::debug!("ms365: {operation} raw error body: {body}");
|
||||||
|
anyhow::bail!("ms365: {operation} failed ({status}, code={code})");
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.json()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("ms365: failed to parse {operation} response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_path_me() {
|
||||||
|
assert_eq!(user_path("me"), "/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_path_specific_user() {
|
||||||
|
assert_eq!(user_path("user@contoso.com"), "/users/user%40contoso.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mail_list_url_no_folder() {
|
||||||
|
let base = user_path("me");
|
||||||
|
let url = format!("{GRAPH_BASE}{base}/messages");
|
||||||
|
assert_eq!(url, "https://graph.microsoft.com/v1.0/me/messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mail_list_url_with_folder() {
|
||||||
|
let base = user_path("me");
|
||||||
|
let folder = "inbox";
|
||||||
|
let url = format!(
|
||||||
|
"{GRAPH_BASE}{base}/mailFolders/{}/messages",
|
||||||
|
encode_path_segment(folder)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calendar_view_url() {
|
||||||
|
let base = user_path("user@example.com");
|
||||||
|
let url = format!("{GRAPH_BASE}{base}/calendarView");
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://graph.microsoft.com/v1.0/users/user%40example.com/calendarView"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn teams_message_url() {
|
||||||
|
let url = format!(
|
||||||
|
"{GRAPH_BASE}/teams/{}/channels/{}/messages",
|
||||||
|
encode_path_segment("team-123"),
|
||||||
|
encode_path_segment("channel-456")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://graph.microsoft.com/v1.0/teams/team-123/channels/channel-456/messages"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn onedrive_root_url() {
|
||||||
|
let base = user_path("me");
|
||||||
|
let url = format!("{GRAPH_BASE}{base}/drive/root/children");
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://graph.microsoft.com/v1.0/me/drive/root/children"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn onedrive_path_url() {
|
||||||
|
let base = user_path("me");
|
||||||
|
let path = "Documents/Reports";
|
||||||
|
let encoded: String = path
|
||||||
|
.split('/')
|
||||||
|
.map(|seg| urlencoding::encode(seg).into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
let url = format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children");
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://graph.microsoft.com/v1.0/me/drive/root:/Documents/Reports:/children"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sharepoint_search_url() {
|
||||||
|
let url = format!("{GRAPH_BASE}/search/query");
|
||||||
|
assert_eq!(url, "https://graph.microsoft.com/v1.0/search/query");
|
||||||
|
}
|
||||||
|
}
|
||||||
567
src/tools/microsoft365/mod.rs
Normal file
567
src/tools/microsoft365/mod.rs
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
//! Microsoft 365 integration tool — Graph API access for Mail, Teams, Calendar,
|
||||||
|
//! OneDrive, and SharePoint via a single action-dispatched tool surface.
|
||||||
|
//!
|
||||||
|
//! Auth is handled through direct HTTP calls to the Microsoft identity platform
|
||||||
|
//! (client credentials or device code flow) with token caching.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod graph_client;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use crate::security::policy::ToolOperation;
|
||||||
|
use crate::security::SecurityPolicy;
|
||||||
|
use crate::tools::traits::{Tool, ToolResult};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Maximum download size for OneDrive files (10 MB).
|
||||||
|
const MAX_ONEDRIVE_DOWNLOAD_SIZE: usize = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Default number of items to return in list operations.
|
||||||
|
const DEFAULT_TOP: u32 = 25;
|
||||||
|
|
||||||
|
pub struct Microsoft365Tool {
|
||||||
|
config: types::Microsoft365ResolvedConfig,
|
||||||
|
security: Arc<SecurityPolicy>,
|
||||||
|
token_cache: Arc<auth::TokenCache>,
|
||||||
|
http_client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Microsoft365Tool {
|
||||||
|
pub fn new(
|
||||||
|
config: types::Microsoft365ResolvedConfig,
|
||||||
|
security: Arc<SecurityPolicy>,
|
||||||
|
zeroclaw_dir: &std::path::Path,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let http_client =
|
||||||
|
crate::config::build_runtime_proxy_client_with_timeouts("tool.microsoft365", 60, 10);
|
||||||
|
let token_cache = Arc::new(auth::TokenCache::new(config.clone(), zeroclaw_dir)?);
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
security,
|
||||||
|
token_cache,
|
||||||
|
http_client,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_token(&self) -> anyhow::Result<String> {
|
||||||
|
self.token_cache.get_token(&self.http_client).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_id(&self) -> &str {
|
||||||
|
&self.config.user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(&self, action: &str, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||||
|
match action {
|
||||||
|
"mail_list" => self.handle_mail_list(args).await,
|
||||||
|
"mail_send" => self.handle_mail_send(args).await,
|
||||||
|
"teams_message_list" => self.handle_teams_message_list(args).await,
|
||||||
|
"teams_message_send" => self.handle_teams_message_send(args).await,
|
||||||
|
"calendar_events_list" => self.handle_calendar_events_list(args).await,
|
||||||
|
"calendar_event_create" => self.handle_calendar_event_create(args).await,
|
||||||
|
"calendar_event_delete" => self.handle_calendar_event_delete(args).await,
|
||||||
|
"onedrive_list" => self.handle_onedrive_list(args).await,
|
||||||
|
"onedrive_download" => self.handle_onedrive_download(args).await,
|
||||||
|
"sharepoint_search" => self.handle_sharepoint_search(args).await,
|
||||||
|
_ => Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(format!("Unknown action: {action}")),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read actions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_mail_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Read, "microsoft365.mail_list")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let folder = args["folder"].as_str();
|
||||||
|
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
|
||||||
|
.unwrap_or(DEFAULT_TOP);
|
||||||
|
|
||||||
|
let result =
|
||||||
|
graph_client::mail_list(&self.http_client, &token, self.user_id(), folder, top).await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: serde_json::to_string_pretty(&result)?,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_teams_message_list(
|
||||||
|
&self,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Read, "microsoft365.teams_message_list")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let team_id = args["team_id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("team_id is required"))?;
|
||||||
|
let channel_id = args["channel_id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("channel_id is required"))?;
|
||||||
|
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
|
||||||
|
.unwrap_or(DEFAULT_TOP);
|
||||||
|
|
||||||
|
let result =
|
||||||
|
graph_client::teams_message_list(&self.http_client, &token, team_id, channel_id, top)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: serde_json::to_string_pretty(&result)?,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_calendar_events_list(
|
||||||
|
&self,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Read, "microsoft365.calendar_events_list")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let start = args["start"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("start datetime is required"))?;
|
||||||
|
let end = args["end"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("end datetime is required"))?;
|
||||||
|
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
|
||||||
|
.unwrap_or(DEFAULT_TOP);
|
||||||
|
|
||||||
|
let result = graph_client::calendar_events_list(
|
||||||
|
&self.http_client,
|
||||||
|
&token,
|
||||||
|
self.user_id(),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
top,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: serde_json::to_string_pretty(&result)?,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_onedrive_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_list")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let path = args["path"].as_str();
|
||||||
|
|
||||||
|
let result =
|
||||||
|
graph_client::onedrive_list(&self.http_client, &token, self.user_id(), path).await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: serde_json::to_string_pretty(&result)?,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_onedrive_download(
|
||||||
|
&self,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_download")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let item_id = args["item_id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("item_id is required"))?;
|
||||||
|
let max_size = args["max_size"]
|
||||||
|
.as_u64()
|
||||||
|
.and_then(|v| usize::try_from(v).ok())
|
||||||
|
.unwrap_or(MAX_ONEDRIVE_DOWNLOAD_SIZE)
|
||||||
|
.min(MAX_ONEDRIVE_DOWNLOAD_SIZE);
|
||||||
|
|
||||||
|
let bytes = graph_client::onedrive_download(
|
||||||
|
&self.http_client,
|
||||||
|
&token,
|
||||||
|
self.user_id(),
|
||||||
|
item_id,
|
||||||
|
max_size,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Return base64-encoded for binary safety.
|
||||||
|
use base64::Engine;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: format!(
|
||||||
|
"Downloaded {} bytes (base64 encoded):\n{encoded}",
|
||||||
|
bytes.len()
|
||||||
|
),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_sharepoint_search(
|
||||||
|
&self,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Read, "microsoft365.sharepoint_search")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let query = args["query"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("query is required"))?;
|
||||||
|
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
|
||||||
|
.unwrap_or(DEFAULT_TOP);
|
||||||
|
|
||||||
|
let result = graph_client::sharepoint_search(&self.http_client, &token, query, top).await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: serde_json::to_string_pretty(&result)?,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write actions ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_mail_send(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Act, "microsoft365.mail_send")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let to: Vec<String> = args["to"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("to must be an array of email addresses"))?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if to.is_empty() {
|
||||||
|
anyhow::bail!("to must contain at least one email address");
|
||||||
|
}
|
||||||
|
|
||||||
|
let subject = args["subject"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("subject is required"))?;
|
||||||
|
let body = args["body"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("body is required"))?;
|
||||||
|
|
||||||
|
graph_client::mail_send(
|
||||||
|
&self.http_client,
|
||||||
|
&token,
|
||||||
|
self.user_id(),
|
||||||
|
&to,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: format!("Email sent to: {}", to.join(", ")),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_teams_message_send(
|
||||||
|
&self,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Act, "microsoft365.teams_message_send")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let team_id = args["team_id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("team_id is required"))?;
|
||||||
|
let channel_id = args["channel_id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("channel_id is required"))?;
|
||||||
|
let body = args["body"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("body is required"))?;
|
||||||
|
|
||||||
|
graph_client::teams_message_send(&self.http_client, &token, team_id, channel_id, body)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: "Teams message sent".to_string(),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_calendar_event_create(
|
||||||
|
&self,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_create")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let subject = args["subject"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("subject is required"))?;
|
||||||
|
let start = args["start"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("start datetime is required"))?;
|
||||||
|
let end = args["end"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("end datetime is required"))?;
|
||||||
|
let attendees: Vec<String> = args["attendees"]
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let body_text = args["body"].as_str();
|
||||||
|
|
||||||
|
let event_id = graph_client::calendar_event_create(
|
||||||
|
&self.http_client,
|
||||||
|
&token,
|
||||||
|
self.user_id(),
|
||||||
|
subject,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
&attendees,
|
||||||
|
body_text,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: format!("Calendar event created (id: {event_id})"),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_calendar_event_delete(
|
||||||
|
&self,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
) -> anyhow::Result<ToolResult> {
|
||||||
|
self.security
|
||||||
|
.enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_delete")
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
let event_id = args["event_id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("event_id is required"))?;
|
||||||
|
|
||||||
|
graph_client::calendar_event_delete(&self.http_client, &token, self.user_id(), event_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: true,
|
||||||
|
output: format!("Calendar event {event_id} deleted"),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Microsoft365Tool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"microsoft365"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Microsoft 365 integration: manage Outlook mail, Teams messages, Calendar events, \
|
||||||
|
OneDrive files, and SharePoint search via Microsoft Graph API"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"required": ["action"],
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"mail_list",
|
||||||
|
"mail_send",
|
||||||
|
"teams_message_list",
|
||||||
|
"teams_message_send",
|
||||||
|
"calendar_events_list",
|
||||||
|
"calendar_event_create",
|
||||||
|
"calendar_event_delete",
|
||||||
|
"onedrive_list",
|
||||||
|
"onedrive_download",
|
||||||
|
"sharepoint_search"
|
||||||
|
],
|
||||||
|
"description": "The Microsoft 365 action to perform"
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Mail folder ID (for mail_list, e.g. 'inbox', 'sentitems')"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Recipient email addresses (for mail_send)"
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Email subject or calendar event subject"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Message body text"
|
||||||
|
},
|
||||||
|
"team_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Teams team ID (for teams_message_list/send)"
|
||||||
|
},
|
||||||
|
"channel_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Teams channel ID (for teams_message_list/send)"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Start datetime in ISO 8601 format (for calendar actions)"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "End datetime in ISO 8601 format (for calendar actions)"
|
||||||
|
},
|
||||||
|
"attendees": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Attendee email addresses (for calendar_event_create)"
|
||||||
|
},
|
||||||
|
"event_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Calendar event ID (for calendar_event_delete)"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "OneDrive folder path (for onedrive_list)"
|
||||||
|
},
|
||||||
|
"item_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "OneDrive item ID (for onedrive_download)"
|
||||||
|
},
|
||||||
|
"max_size": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum download size in bytes (for onedrive_download, default 10MB)"
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Search query (for sharepoint_search)"
|
||||||
|
},
|
||||||
|
"top": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of items to return (default 25)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||||
|
let action = match args["action"].as_str() {
|
||||||
|
Some(a) => a.to_string(),
|
||||||
|
None => {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("'action' parameter is required".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.dispatch(&action, &args).await {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(e) => Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(format!("microsoft365.{action} failed: {e}")),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_name_is_microsoft365() {
|
||||||
|
// Verify the schema is valid JSON with the expected structure.
|
||||||
|
let schema_str = r#"{"type":"object","required":["action"]}"#;
|
||||||
|
let _: serde_json::Value = serde_json::from_str(schema_str).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameters_schema_has_action_enum() {
|
||||||
|
let schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"required": ["action"],
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"mail_list",
|
||||||
|
"mail_send",
|
||||||
|
"teams_message_list",
|
||||||
|
"teams_message_send",
|
||||||
|
"calendar_events_list",
|
||||||
|
"calendar_event_create",
|
||||||
|
"calendar_event_delete",
|
||||||
|
"onedrive_list",
|
||||||
|
"onedrive_download",
|
||||||
|
"sharepoint_search"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
|
||||||
|
assert_eq!(actions.len(), 10);
|
||||||
|
assert!(actions.contains(&json!("mail_list")));
|
||||||
|
assert!(actions.contains(&json!("sharepoint_search")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn action_dispatch_table_is_exhaustive() {
|
||||||
|
let valid_actions = [
|
||||||
|
"mail_list",
|
||||||
|
"mail_send",
|
||||||
|
"teams_message_list",
|
||||||
|
"teams_message_send",
|
||||||
|
"calendar_events_list",
|
||||||
|
"calendar_event_create",
|
||||||
|
"calendar_event_delete",
|
||||||
|
"onedrive_list",
|
||||||
|
"onedrive_download",
|
||||||
|
"sharepoint_search",
|
||||||
|
];
|
||||||
|
assert_eq!(valid_actions.len(), 10);
|
||||||
|
assert!(!valid_actions.contains(&"invalid_action"));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/tools/microsoft365/types.rs
Normal file
55
src/tools/microsoft365/types.rs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Resolved Microsoft 365 configuration with all secrets decrypted and defaults applied.
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Microsoft365ResolvedConfig {
|
||||||
|
pub tenant_id: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
pub auth_flow: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub token_cache_encrypted: bool,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Microsoft365ResolvedConfig {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Microsoft365ResolvedConfig")
|
||||||
|
.field("tenant_id", &self.tenant_id)
|
||||||
|
.field("client_id", &self.client_id)
|
||||||
|
.field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
|
||||||
|
.field("auth_flow", &self.auth_flow)
|
||||||
|
.field("scopes", &self.scopes)
|
||||||
|
.field("token_cache_encrypted", &self.token_cache_encrypted)
|
||||||
|
.field("user_id", &self.user_id)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_config_serialization_roundtrip() {
|
||||||
|
let config = Microsoft365ResolvedConfig {
|
||||||
|
tenant_id: "test-tenant".into(),
|
||||||
|
client_id: "test-client".into(),
|
||||||
|
client_secret: Some("secret".into()),
|
||||||
|
auth_flow: "client_credentials".into(),
|
||||||
|
scopes: vec!["https://graph.microsoft.com/.default".into()],
|
||||||
|
token_cache_encrypted: false,
|
||||||
|
user_id: "me".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let parsed: Microsoft365ResolvedConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.tenant_id, "test-tenant");
|
||||||
|
assert_eq!(parsed.client_id, "test-client");
|
||||||
|
assert_eq!(parsed.client_secret.as_deref(), Some("secret"));
|
||||||
|
assert_eq!(parsed.auth_flow, "client_credentials");
|
||||||
|
assert_eq!(parsed.scopes.len(), 1);
|
||||||
|
assert_eq!(parsed.user_id, "me");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,6 +48,7 @@ pub mod mcp_transport;
|
|||||||
pub mod memory_forget;
|
pub mod memory_forget;
|
||||||
pub mod memory_recall;
|
pub mod memory_recall;
|
||||||
pub mod memory_store;
|
pub mod memory_store;
|
||||||
|
pub mod microsoft365;
|
||||||
pub mod model_routing_config;
|
pub mod model_routing_config;
|
||||||
pub mod node_tool;
|
pub mod node_tool;
|
||||||
pub mod pdf_read;
|
pub mod pdf_read;
|
||||||
@ -92,6 +93,7 @@ pub use mcp_tool::McpToolWrapper;
|
|||||||
pub use memory_forget::MemoryForgetTool;
|
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 microsoft365::Microsoft365Tool;
|
||||||
pub use model_routing_config::ModelRoutingConfigTool;
|
pub use model_routing_config::ModelRoutingConfigTool;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use node_tool::NodeTool;
|
pub use node_tool::NodeTool;
|
||||||
@ -356,6 +358,61 @@ pub fn all_tools_with_runtime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Microsoft 365 Graph API integration
|
||||||
|
if root_config.microsoft365.enabled {
|
||||||
|
let ms_cfg = &root_config.microsoft365;
|
||||||
|
let tenant_id = ms_cfg
|
||||||
|
.tenant_id
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let client_id = ms_cfg
|
||||||
|
.client_id
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if !tenant_id.is_empty() && !client_id.is_empty() {
|
||||||
|
// Fail fast: client_credentials flow requires a client_secret at registration time.
|
||||||
|
if ms_cfg.auth_flow.trim() == "client_credentials"
|
||||||
|
&& ms_cfg
|
||||||
|
.client_secret
|
||||||
|
.as_deref()
|
||||||
|
.map_or(true, |s| s.trim().is_empty())
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"microsoft365: client_credentials auth_flow requires a non-empty client_secret"
|
||||||
|
);
|
||||||
|
return (boxed_registry_from_arcs(tool_arcs), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = microsoft365::types::Microsoft365ResolvedConfig {
|
||||||
|
tenant_id,
|
||||||
|
client_id,
|
||||||
|
client_secret: ms_cfg.client_secret.clone(),
|
||||||
|
auth_flow: ms_cfg.auth_flow.clone(),
|
||||||
|
scopes: ms_cfg.scopes.clone(),
|
||||||
|
token_cache_encrypted: ms_cfg.token_cache_encrypted,
|
||||||
|
user_id: ms_cfg.user_id.as_deref().unwrap_or("me").to_string(),
|
||||||
|
};
|
||||||
|
// Store token cache in the config directory (next to config.toml),
|
||||||
|
// not the workspace directory, to keep bearer tokens out of the
|
||||||
|
// project tree.
|
||||||
|
let cache_dir = root_config.config_path.parent().unwrap_or(workspace_dir);
|
||||||
|
match Microsoft365Tool::new(resolved, security.clone(), cache_dir) {
|
||||||
|
Ok(tool) => tool_arcs.push(Arc::new(tool)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("microsoft365: failed to initialize tool: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"microsoft365: skipped registration because tenant_id or client_id is empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add delegation tool when agents are configured
|
// Add delegation tool when agents are configured
|
||||||
let delegate_handle: Option<DelegateParentToolsHandle> = if agents.is_empty() {
|
let delegate_handle: Option<DelegateParentToolsHandle> = if agents.is_empty() {
|
||||||
None
|
None
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user