use super::*;
use tandem_channels::channel_registry::{find_channel, registered_channels, ChannelSpec};
fn parse_allowed_users(value: Option<&Value>) -> Vec<String> {
let mut users = value
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if users.is_empty() {
users.push("*".to_string());
}
users
}
fn mask_saved_token(has_token: bool) -> Option<&'static str> {
if has_token {
Some("****")
} else {
None
}
}
fn trim_optional_string(value: Option<String>) -> Option<String> {
value
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn find_channel_spec(name: &str) -> Option<&'static ChannelSpec> {
let normalized = name.trim().to_ascii_lowercase();
find_channel(&normalized)
}
fn normalize_channel_config_obj<'a>(
channels: Option<&'a serde_json::Map<String, Value>>,
spec: &'static ChannelSpec,
) -> serde_json::Map<String, Value> {
let mut entry = serde_json::Map::new();
let channel = channels
.and_then(|channels| channels.get(spec.config_key))
.and_then(Value::as_object);
let has_token = channel
.and_then(|cfg| cfg.get("bot_token"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
entry.insert("has_token".to_string(), serde_json::Value::Bool(has_token));
entry.insert(
"token_masked".to_string(),
mask_saved_token(has_token).map_or(Value::Null, |value| Value::String(value.to_string())),
);
entry.insert(
"allowed_users".to_string(),
Value::Array(
parse_allowed_users(channel.and_then(|cfg| cfg.get("allowed_users")))
.into_iter()
.map(Value::String)
.collect(),
),
);
let mention_only = channel
.and_then(|cfg| cfg.get("mention_only"))
.and_then(Value::as_bool)
.unwrap_or(match spec.name {
"discord" => true,
_ => false,
});
entry.insert("mention_only".to_string(), Value::Bool(mention_only));
entry.insert(
"model_provider_id".to_string(),
channel
.and_then(|cfg| cfg.get("model_provider_id"))
.and_then(Value::as_str)
.map(|value| Value::String(value.to_string()))
.unwrap_or(Value::Null),
);
entry.insert(
"model_id".to_string(),
channel
.and_then(|cfg| cfg.get("model_id"))
.and_then(Value::as_str)
.map(|value| Value::String(value.to_string()))
.unwrap_or(Value::Null),
);
entry.insert(
"security_profile".to_string(),
Value::String(
channel
.and_then(|cfg| cfg.get("security_profile"))
.and_then(Value::as_str)
.unwrap_or("operator")
.to_string(),
),
);
match spec.name {
"telegram" => {
entry.insert(
"style_profile".to_string(),
Value::String(
channel
.and_then(|cfg| cfg.get("style_profile"))
.and_then(Value::as_str)
.unwrap_or("default")
.to_string(),
),
);
}
"discord" => {
entry.insert(
"guild_id".to_string(),
channel
.and_then(|cfg| cfg.get("guild_id"))
.and_then(Value::as_str)
.map(|value| Value::String(value.to_string()))
.unwrap_or(Value::Null),
);
}
"slack" => {
entry.insert(
"channel_id".to_string(),
channel
.and_then(|cfg| cfg.get("channel_id"))
.and_then(Value::as_str)
.map(|value| Value::String(value.to_string()))
.unwrap_or(Value::Null),
);
}
_ => {}
}
entry
}
fn existing_channel_value(
channels: Option<&serde_json::Map<String, Value>>,
spec: &ChannelSpec,
key: &str,
) -> Option<String> {
channels
.and_then(|obj| obj.get(spec.config_key))
.and_then(Value::as_object)
.and_then(|cfg| cfg.get(key))
.and_then(Value::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn existing_channel_token(
channels: Option<&serde_json::Map<String, Value>>,
spec: &ChannelSpec,
) -> Option<String> {
existing_channel_value(channels, spec, "bot_token").or_else(|| {
std::env::var(spec.token_env_key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}
fn existing_channel_id(
channels: Option<&serde_json::Map<String, Value>>,
spec: &ChannelSpec,
) -> Option<String> {
let env_key = match spec.channel_id_env_key {
Some(env_key) => env_key,
None => return None,
};
existing_channel_value(channels, spec, "channel_id").or_else(|| {
std::env::var(env_key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}
pub(super) async fn channels_config(State(state): State<AppState>) -> Json<Value> {
let effective = state.config.get_effective_value().await;
let channels = effective.get("channels").and_then(Value::as_object);
let mut entries = serde_json::Map::new();
for spec in registered_channels() {
entries.insert(
spec.config_key.to_string(),
Value::Object(normalize_channel_config_obj(channels, spec)),
);
}
Json(Value::Object(entries))
}
pub(super) async fn channels_status(State(state): State<AppState>) -> Json<Value> {
Json(json!(state.channel_statuses().await))
}
pub(super) async fn channels_verify(
State(state): State<AppState>,
Path(name): Path<String>,
input: Option<Json<Value>>,
) -> Result<Json<Value>, StatusCode> {
let normalized = name.to_ascii_lowercase();
let Some(spec) = find_channel_spec(&normalized) else {
return Err(StatusCode::NOT_FOUND);
};
let payload = input.map(|Json(v)| v).unwrap_or_else(|| json!({}));
match spec.name {
"discord" => Ok(Json(discord_channel_verify(&state, &payload).await)),
_ => Err(StatusCode::NOT_FOUND),
}
}
const DISCORD_FLAG_GATEWAY_PRESENCE: u64 = 1 << 12;
const DISCORD_FLAG_GATEWAY_PRESENCE_LIMITED: u64 = 1 << 13;
const DISCORD_FLAG_GATEWAY_MEMBERS: u64 = 1 << 14;
const DISCORD_FLAG_GATEWAY_MEMBERS_LIMITED: u64 = 1 << 15;
const DISCORD_FLAG_GATEWAY_MESSAGE_CONTENT: u64 = 1 << 18;
const DISCORD_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED: u64 = 1 << 19;
async fn discord_channel_verify(state: &AppState, payload: &Value) -> Value {
let provided_token = payload
.get("bot_token")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
let effective = state.config.get_effective_value().await;
let saved_token = effective
.get("channels")
.and_then(Value::as_object)
.and_then(|obj| obj.get("discord"))
.and_then(Value::as_object)
.and_then(|cfg| cfg.get("bot_token"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
let token = provided_token.or(saved_token).unwrap_or_default();
let has_token = !token.is_empty();
let mut hints: Vec<String> = Vec::new();
if !has_token {
hints.push("Add your Discord bot token, then click Save or Verify again.".to_string());
return json!({
"ok": false,
"channel": "discord",
"checks": {
"has_token": false,
"token_auth_ok": false,
"gateway_ok": false,
"message_content_intent_ok": false
},
"status_codes": {
"users_me": null,
"gateway_bot": null,
"application_me": null
},
"hints": hints,
"details": {}
});
}
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
{
Ok(c) => c,
Err(e) => {
return json!({
"ok": false,
"channel": "discord",
"checks": {
"has_token": true,
"token_auth_ok": false,
"gateway_ok": false,
"message_content_intent_ok": false
},
"status_codes": {
"users_me": null,
"gateway_bot": null,
"application_me": null
},
"hints": ["Local HTTP client setup failed. Restart Tandem and retry verification."],
"details": {
"error": e.to_string()
}
});
}
};
let auth_header = format!("Bot {token}");
let users_resp = client
.get("https://discord.com/api/v10/users/@me")
.header("Authorization", auth_header.clone())
.send()
.await;
let gateway_resp = client
.get("https://discord.com/api/v10/gateway/bot")
.header("Authorization", auth_header.clone())
.send()
.await;
let app_resp = client
.get("https://discord.com/api/v10/applications/@me")
.header("Authorization", auth_header)
.send()
.await;
let users_status = users_resp.as_ref().ok().map(|r| r.status().as_u16());
let gateway_status = gateway_resp.as_ref().ok().map(|r| r.status().as_u16());
let app_status = app_resp.as_ref().ok().map(|r| r.status().as_u16());
let token_auth_ok = users_status == Some(200);
let gateway_ok = gateway_status == Some(200);
let mut bot_username: Option<String> = None;
let mut bot_id: Option<String> = None;
if let Ok(resp) = users_resp {
if resp.status().is_success() {
if let Ok(v) = resp.json::<Value>().await {
bot_username = v
.get("username")
.and_then(Value::as_str)
.map(ToString::to_string);
bot_id = v.get("id").and_then(Value::as_str).map(ToString::to_string);
}
}
}
let mut app_flags: Option<u64> = None;
if let Ok(resp) = app_resp {
if resp.status().is_success() {
if let Ok(v) = resp.json::<Value>().await {
app_flags = v.get("flags").and_then(Value::as_u64);
}
}
}
let message_content_intent_ok = app_flags.is_some_and(|flags| {
flags
& (DISCORD_FLAG_GATEWAY_MESSAGE_CONTENT | DISCORD_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED)
!= 0
});
let presence_intent_enabled = app_flags.is_some_and(|flags| {
flags & (DISCORD_FLAG_GATEWAY_PRESENCE | DISCORD_FLAG_GATEWAY_PRESENCE_LIMITED) != 0
});
let server_members_intent_enabled = app_flags.is_some_and(|flags| {
flags & (DISCORD_FLAG_GATEWAY_MEMBERS | DISCORD_FLAG_GATEWAY_MEMBERS_LIMITED) != 0
});
if !token_auth_ok {
if users_status == Some(401) {
hints.push("Discord rejected this token (401). Regenerate bot token in Developer Portal -> Bot and update Tandem.".to_string());
} else {
hints.push("Could not authenticate bot token with Discord `/users/@me`.".to_string());
}
}
if !gateway_ok {
if gateway_status == Some(429) {
hints.push("Discord gateway verification is rate-limited right now. Wait a few seconds and verify again.".to_string());
} else {
hints.push("Discord `/gateway/bot` check failed. Verify outbound network access to discord.com.".to_string());
}
}
if token_auth_ok && gateway_ok && !message_content_intent_ok {
hints.push("Enable `Message Content Intent` in Discord Developer Portal -> Bot -> Privileged Gateway Intents.".to_string());
}
if hints.is_empty() {
hints.push("Discord checks passed. If replies are still missing, verify channel/thread permissions: View Channel, Send Messages, Read Message History, Send Messages in Threads.".to_string());
}
let ok = token_auth_ok && gateway_ok && message_content_intent_ok;
json!({
"ok": ok,
"channel": "discord",
"checks": {
"has_token": has_token,
"token_auth_ok": token_auth_ok,
"gateway_ok": gateway_ok,
"message_content_intent_ok": message_content_intent_ok,
"presence_intent_enabled": presence_intent_enabled,
"server_members_intent_enabled": server_members_intent_enabled
},
"status_codes": {
"users_me": users_status,
"gateway_bot": gateway_status,
"application_me": app_status
},
"hints": hints,
"details": {
"bot_username": bot_username,
"bot_id": bot_id,
"application_flags": app_flags
}
})
}
pub(super) async fn channels_put(
State(state): State<AppState>,
Path(name): Path<String>,
Json(mut input): Json<Value>,
) -> Result<Json<Value>, StatusCode> {
let normalized = name.to_ascii_lowercase();
let Some(spec) = find_channel_spec(&normalized) else {
return Err(StatusCode::NOT_FOUND);
};
let effective = state.config.get_effective_value().await;
let statuses = state.channel_statuses().await;
let existing_channel_cfg = |spec: &ChannelSpec| -> Option<&serde_json::Map<String, Value>> {
effective
.get("channels")
.and_then(Value::as_object)
.and_then(|obj| obj.get(spec.config_key))
.and_then(Value::as_object)
};
let channel_is_connected = |spec: &ChannelSpec| -> bool {
statuses
.get(spec.name)
.map(|status| status.connected)
.unwrap_or(false)
};
let existing_bot_token = |spec: &ChannelSpec| -> Option<String> {
existing_channel_cfg(spec)
.and_then(|cfg| cfg.get("bot_token"))
.and_then(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
std::env::var(spec.token_env_key)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
};
let existing_channel_id = |spec: &ChannelSpec| -> Option<String> {
let Some(env_key) = spec.channel_id_env_key else {
return None;
};
existing_channel_cfg(spec)
.and_then(|cfg| cfg.get("channel_id"))
.and_then(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
std::env::var(env_key)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
};
let mut project = state.config.get_project_value().await;
let Some(root) = project.as_object_mut() else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
let channels = root
.entry("channels".to_string())
.or_insert_with(|| json!({}));
let Some(channels_obj) = channels.as_object_mut() else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
match spec.name {
"telegram" => {
if let Some(cfg) = input.as_object_mut() {
if cfg
.get("bot_token")
.and_then(Value::as_str)
.is_none_or(|v| v.trim().is_empty())
{
if let Some(existing) = existing_bot_token(spec) {
cfg.insert("bot_token".to_string(), Value::String(existing));
}
}
}
let mut cfg: TelegramConfigFile =
serde_json::from_value(input).map_err(|_| StatusCode::BAD_REQUEST)?;
cfg.allowed_users = crate::normalize_allowed_users_or_wildcard(cfg.allowed_users);
cfg.model_provider_id = trim_optional_string(cfg.model_provider_id);
cfg.model_id = trim_optional_string(cfg.model_id);
if cfg.bot_token.trim().is_empty() {
cfg.bot_token = existing_bot_token(spec).unwrap_or_default();
}
if cfg.bot_token.trim().is_empty() && !channel_is_connected(spec) {
return Err(StatusCode::BAD_REQUEST);
}
channels_obj.insert(spec.config_key.to_string(), json!(cfg));
}
"discord" => {
if let Some(cfg) = input.as_object_mut() {
if cfg
.get("bot_token")
.and_then(Value::as_str)
.is_none_or(|v| v.trim().is_empty())
{
if let Some(existing) = existing_bot_token(spec) {
cfg.insert("bot_token".to_string(), Value::String(existing));
}
}
}
let mut cfg: DiscordConfigFile =
serde_json::from_value(input).map_err(|_| StatusCode::BAD_REQUEST)?;
cfg.allowed_users = crate::normalize_allowed_users_or_wildcard(cfg.allowed_users);
cfg.model_provider_id = trim_optional_string(cfg.model_provider_id);
cfg.model_id = trim_optional_string(cfg.model_id);
if cfg.bot_token.trim().is_empty() {
cfg.bot_token = existing_bot_token(spec).unwrap_or_default();
}
if cfg.bot_token.trim().is_empty() && !channel_is_connected(spec) {
return Err(StatusCode::BAD_REQUEST);
}
channels_obj.insert(spec.config_key.to_string(), json!(cfg));
}
"slack" => {
if let Some(cfg) = input.as_object_mut() {
if cfg
.get("bot_token")
.and_then(Value::as_str)
.is_none_or(|v| v.trim().is_empty())
{
if let Some(existing) = existing_bot_token(spec) {
cfg.insert("bot_token".to_string(), Value::String(existing));
}
}
if cfg
.get("channel_id")
.and_then(Value::as_str)
.is_none_or(|v| v.trim().is_empty())
{
if let Some(existing) = existing_channel_id(spec) {
cfg.insert("channel_id".to_string(), Value::String(existing));
}
}
}
let mut cfg: SlackConfigFile =
serde_json::from_value(input).map_err(|_| StatusCode::BAD_REQUEST)?;
cfg.allowed_users = crate::normalize_allowed_users_or_wildcard(cfg.allowed_users);
cfg.model_provider_id = trim_optional_string(cfg.model_provider_id);
cfg.model_id = trim_optional_string(cfg.model_id);
if cfg.bot_token.trim().is_empty() {
cfg.bot_token = existing_bot_token(spec).unwrap_or_default();
}
if cfg.channel_id.trim().is_empty() {
cfg.channel_id = existing_channel_id(spec).unwrap_or_default();
}
if (cfg.bot_token.trim().is_empty() || cfg.channel_id.trim().is_empty())
&& !channel_is_connected(spec)
{
return Err(StatusCode::BAD_REQUEST);
}
channels_obj.insert(spec.config_key.to_string(), json!(cfg));
}
_ => return Err(StatusCode::NOT_FOUND),
}
state
.config
.replace_project_value(project)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state
.restart_channel_listeners()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({"ok": true})))
}
pub(super) async fn channels_delete(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let Some(spec) = find_channel_spec(&name.to_ascii_lowercase()) else {
return Err(StatusCode::NOT_FOUND);
};
if let Some(secret_id) = tandem_core::channel_secret_store_id(spec.name) {
let _ = tandem_core::delete_provider_auth(&secret_id);
}
let mut project = state.config.get_project_value().await;
let Some(root) = project.as_object_mut() else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
let channels = root
.entry("channels".to_string())
.or_insert_with(|| json!({}));
let Some(channels_obj) = channels.as_object_mut() else {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
};
channels_obj.remove(spec.config_key);
state
.config
.replace_project_value(project)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state
.restart_channel_listeners()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({"ok": true})))
}
pub(super) async fn admin_reload_config(
State(state): State<AppState>,
) -> Result<Json<Value>, StatusCode> {
state
.providers
.reload(state.config.get().await.into())
.await;
state
.restart_channel_listeners()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({"ok": true})))
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
pub struct ChannelToolPreferences {
#[serde(default)]
pub enabled_tools: Vec<String>,
#[serde(default)]
pub disabled_tools: Vec<String>,
#[serde(default)]
pub enabled_mcp_servers: Vec<String>,
}
const PUBLIC_DEMO_ALLOWED_TOOLS: &[&str] = &[
"websearch",
"webfetch",
"webfetch_html",
"memory_search",
"memory_store",
"memory_list",
];
fn unique_strings(values: Vec<String>) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.filter(|value| seen.insert(value.clone()))
.collect()
}
fn parse_channel_security_profile(
raw: Option<&str>,
) -> tandem_channels::config::ChannelSecurityProfile {
match raw.map(|value| value.trim().to_ascii_lowercase()) {
Some(value) if value == "trusted_team" || value == "trusted-team" => {
tandem_channels::config::ChannelSecurityProfile::TrustedTeam
}
Some(value) if value == "public_demo" || value == "public-demo" => {
tandem_channels::config::ChannelSecurityProfile::PublicDemo
}
_ => tandem_channels::config::ChannelSecurityProfile::Operator,
}
}
fn channel_security_profile_from_config(
effective: &Value,
channel: &str,
) -> tandem_channels::config::ChannelSecurityProfile {
let raw = effective
.get("channels")
.and_then(Value::as_object)
.and_then(|channels| channels.get(channel))
.and_then(Value::as_object)
.and_then(|cfg| cfg.get("security_profile"))
.and_then(Value::as_str);
parse_channel_security_profile(raw)
}
fn sanitize_tool_preferences_for_security_profile(
prefs: ChannelToolPreferences,
security_profile: tandem_channels::config::ChannelSecurityProfile,
) -> ChannelToolPreferences {
let enabled_tools = unique_strings(prefs.enabled_tools);
let disabled_tools = unique_strings(prefs.disabled_tools);
let enabled_mcp_servers = unique_strings(prefs.enabled_mcp_servers);
if security_profile != tandem_channels::config::ChannelSecurityProfile::PublicDemo {
return ChannelToolPreferences {
enabled_tools,
disabled_tools,
enabled_mcp_servers,
};
}
ChannelToolPreferences {
enabled_tools: enabled_tools
.into_iter()
.filter(|tool| {
PUBLIC_DEMO_ALLOWED_TOOLS
.iter()
.any(|allowed| allowed == tool)
})
.collect(),
disabled_tools,
enabled_mcp_servers: Vec::new(),
}
}
fn tool_preferences_path() -> PathBuf {
let base = std::env::var("TANDEM_STATE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
if let Some(data_dir) = dirs::data_dir() {
return data_dir.join("tandem").join("data");
}
dirs::home_dir()
.map(|home| home.join(".tandem").join("data"))
.unwrap_or_else(|| PathBuf::from(".tandem"))
});
base.join("channel_tool_preferences.json")
}
type ToolPreferencesMap = std::collections::HashMap<String, ChannelToolPreferences>;
async fn load_tool_preferences_map() -> std::collections::HashMap<String, ChannelToolPreferences> {
let path = tool_preferences_path();
let Ok(bytes) = tokio::fs::read(&path).await else {
return std::collections::HashMap::new();
};
serde_json::from_slice(&bytes).unwrap_or_default()
}
async fn save_tool_preferences_map(map: &ToolPreferencesMap) {
let path = tool_preferences_path();
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if let Ok(json) = serde_json::to_vec_pretty(map) {
let _ = tokio::fs::write(&path, json).await;
}
}
pub(super) async fn channel_tool_preferences_get(
State(state): State<AppState>,
Path(channel): Path<String>,
) -> Result<Json<ChannelToolPreferences>, StatusCode> {
let key = channel.to_string();
let mut map = load_tool_preferences_map().await;
let prefs = map.get(&key).cloned().unwrap_or_default();
let effective = state.config.get_effective_value().await;
let security_profile = channel_security_profile_from_config(&effective, &key);
let sanitized = sanitize_tool_preferences_for_security_profile(prefs.clone(), security_profile);
if sanitized != prefs {
map.insert(key, sanitized.clone());
save_tool_preferences_map(&map).await;
}
Ok(Json(sanitized))
}
#[derive(Debug, serde::Deserialize)]
pub struct ChannelToolPreferencesInput {
pub enabled_tools: Option<Vec<String>>,
pub disabled_tools: Option<Vec<String>>,
pub enabled_mcp_servers: Option<Vec<String>>,
pub reset: Option<bool>,
}
pub(super) async fn channel_tool_preferences_put(
State(state): State<AppState>,
Path(channel): Path<String>,
Json(input): Json<ChannelToolPreferencesInput>,
) -> Result<Json<ChannelToolPreferences>, StatusCode> {
let mut map = load_tool_preferences_map().await;
let key = channel.to_string();
let effective = state.config.get_effective_value().await;
let security_profile = channel_security_profile_from_config(&effective, &key);
let new_prefs = if input.reset.unwrap_or(false) {
ChannelToolPreferences::default()
} else {
let existing = map.get(&key).cloned().unwrap_or_default();
ChannelToolPreferences {
enabled_tools: input.enabled_tools.unwrap_or(existing.enabled_tools),
disabled_tools: input.disabled_tools.unwrap_or(existing.disabled_tools),
enabled_mcp_servers: input
.enabled_mcp_servers
.unwrap_or(existing.enabled_mcp_servers),
}
};
let new_prefs = sanitize_tool_preferences_for_security_profile(new_prefs, security_profile);
map.insert(key, new_prefs.clone());
save_tool_preferences_map(&map).await;
Ok(Json(new_prefs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_demo_sanitizes_enabled_tools_and_mcp_servers() {
let prefs = ChannelToolPreferences {
enabled_tools: vec![
"websearch".to_string(),
"bash".to_string(),
"webfetch_html".to_string(),
"bash".to_string(),
],
disabled_tools: vec!["read".to_string(), "read".to_string()],
enabled_mcp_servers: vec!["github".to_string(), "slack".to_string()],
};
let sanitized = sanitize_tool_preferences_for_security_profile(
prefs,
tandem_channels::config::ChannelSecurityProfile::PublicDemo,
);
assert_eq!(
sanitized.enabled_tools,
vec!["websearch".to_string(), "webfetch_html".to_string()]
);
assert_eq!(sanitized.disabled_tools, vec!["read".to_string()]);
assert!(sanitized.enabled_mcp_servers.is_empty());
}
#[test]
fn operator_keeps_existing_tool_preferences() {
let prefs = ChannelToolPreferences {
enabled_tools: vec!["bash".to_string(), "bash".to_string()],
disabled_tools: vec!["read".to_string(), "".to_string()],
enabled_mcp_servers: vec!["github".to_string(), "github".to_string()],
};
let sanitized = sanitize_tool_preferences_for_security_profile(
prefs,
tandem_channels::config::ChannelSecurityProfile::Operator,
);
assert_eq!(sanitized.enabled_tools, vec!["bash".to_string()]);
assert_eq!(sanitized.disabled_tools, vec!["read".to_string()]);
assert_eq!(sanitized.enabled_mcp_servers, vec!["github".to_string()]);
}
}