feat(channel): add per-channel proxy_url support for HTTP/SOCKS5 proxies (#3345)

* feat(channel): add per-channel proxy_url support for HTTP/SOCKS5 proxies

Allow each channel to optionally specify a `proxy_url` in its config,
enabling users behind restrictive networks to route channel traffic
through HTTP or SOCKS5 proxies. When set, the per-channel proxy takes
precedence over the global `[proxy]` config; when absent, the channel
falls back to the existing runtime proxy behavior.

Adds `proxy_url: Option<String>` to all 12 channel config structs
(Telegram, Discord, Slack, Mattermost, Signal, WhatsApp, Wati,
NextcloudTalk, DingTalk, QQ, Lark, Feishu) and introduces
`build_channel_proxy_client`, `build_channel_proxy_client_with_timeouts`,
and `apply_channel_proxy_to_builder` helpers that normalize proxy URLs
and integrate with the existing client cache.

Closes #3262

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(channel): add missing proxy_url fields in test initializers

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: argenis de la rosa <theonlyhennygod@gmail.com>
This commit is contained in:
SimianAstronaut7
2026-03-21 07:53:20 -04:00
committed by Roman Tataurov
parent b4c6d1f485
commit 6ede4f7567
18 changed files with 447 additions and 52 deletions
+10 -1
View File
@@ -18,6 +18,8 @@ pub struct DingTalkChannel {
/// Per-chat session webhooks for sending replies (chatID -> webhook URL).
/// DingTalk provides a unique webhook URL with each incoming message.
session_webhooks: Arc<RwLock<HashMap<String, String>>>,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
}
/// Response from DingTalk gateway connection registration.
@@ -34,11 +36,18 @@ impl DingTalkChannel {
client_secret,
allowed_users,
session_webhooks: Arc::new(RwLock::new(HashMap::new())),
proxy_url: None,
}
}
/// Set a per-channel proxy URL that overrides the global proxy config.
pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
self.proxy_url = proxy_url;
self
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.dingtalk")
crate::config::build_channel_proxy_client("channel.dingtalk", self.proxy_url.as_deref())
}
fn is_user_allowed(&self, user_id: &str) -> bool {
+10 -1
View File
@@ -18,6 +18,8 @@ pub struct DiscordChannel {
listen_to_bots: bool,
mention_only: bool,
typing_handles: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
}
impl DiscordChannel {
@@ -35,11 +37,18 @@ impl DiscordChannel {
listen_to_bots,
mention_only,
typing_handles: Mutex::new(HashMap::new()),
proxy_url: None,
}
}
/// Set a per-channel proxy URL that overrides the global proxy config.
pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
self.proxy_url = proxy_url;
self
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.discord")
crate::config::build_channel_proxy_client("channel.discord", self.proxy_url.as_deref())
}
/// Check if a Discord user ID is in the allowlist.
+16 -1
View File
@@ -380,6 +380,8 @@ pub struct LarkChannel {
tenant_token: Arc<RwLock<Option<CachedTenantToken>>>,
/// Dedup set: WS message_ids seen in last ~30 min to prevent double-dispatch
ws_seen_ids: Arc<RwLock<HashMap<String, Instant>>>,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
}
impl LarkChannel {
@@ -423,6 +425,7 @@ impl LarkChannel {
receive_mode: crate::config::schema::LarkReceiveMode::default(),
tenant_token: Arc::new(RwLock::new(None)),
ws_seen_ids: Arc::new(RwLock::new(HashMap::new())),
proxy_url: None,
}
}
@@ -444,6 +447,7 @@ impl LarkChannel {
platform,
);
ch.receive_mode = config.receive_mode.clone();
ch.proxy_url = config.proxy_url.clone();
ch
}
@@ -461,6 +465,7 @@ impl LarkChannel {
LarkPlatform::Lark,
);
ch.receive_mode = config.receive_mode.clone();
ch.proxy_url = config.proxy_url.clone();
ch
}
@@ -476,11 +481,15 @@ impl LarkChannel {
LarkPlatform::Feishu,
);
ch.receive_mode = config.receive_mode.clone();
ch.proxy_url = config.proxy_url.clone();
ch
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client(self.platform.proxy_service_key())
crate::config::build_channel_proxy_client(
self.platform.proxy_service_key(),
self.proxy_url.as_deref(),
)
}
fn channel_name(&self) -> &'static str {
@@ -2113,6 +2122,7 @@ mod tests {
use_feishu: false,
receive_mode: LarkReceiveMode::default(),
port: None,
proxy_url: None,
};
let json = serde_json::to_string(&lc).unwrap();
let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
@@ -2135,6 +2145,7 @@ mod tests {
use_feishu: false,
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
proxy_url: None,
};
let toml_str = toml::to_string(&lc).unwrap();
let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
@@ -2169,6 +2180,7 @@ mod tests {
use_feishu: false,
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
proxy_url: None,
};
let ch = LarkChannel::from_config(&cfg);
@@ -2193,6 +2205,7 @@ mod tests {
use_feishu: true,
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
proxy_url: None,
};
let ch = LarkChannel::from_lark_config(&cfg);
@@ -2214,6 +2227,7 @@ mod tests {
allowed_users: vec!["*".into()],
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
proxy_url: None,
};
let ch = LarkChannel::from_feishu_config(&cfg);
@@ -2386,6 +2400,7 @@ mod tests {
allowed_users: vec!["*".into()],
receive_mode: crate::config::schema::LarkReceiveMode::Webhook,
port: Some(9898),
proxy_url: None,
};
let ch_feishu = LarkChannel::from_feishu_config(&feishu_cfg);
assert_eq!(
+10 -1
View File
@@ -17,6 +17,8 @@ pub struct MattermostChannel {
mention_only: bool,
/// Handle for the background typing-indicator loop (aborted on stop_typing).
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
}
impl MattermostChannel {
@@ -38,11 +40,18 @@ impl MattermostChannel {
thread_replies,
mention_only,
typing_handle: Mutex::new(None),
proxy_url: None,
}
}
/// Set a per-channel proxy URL that overrides the global proxy config.
pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
self.proxy_url = proxy_url;
self
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.mattermost")
crate::config::build_channel_proxy_client("channel.mattermost", self.proxy_url.as_deref())
}
/// Check if a user ID is in the allowlist.
+61 -39
View File
@@ -3700,7 +3700,8 @@ fn collect_configured_channels(
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
.with_transcription(config.transcription.clone())
.with_tts(config.tts.clone())
.with_workspace_dir(config.workspace_dir.clone()),
.with_workspace_dir(config.workspace_dir.clone())
.with_proxy_url(tg.proxy_url.clone()),
),
});
}
@@ -3708,13 +3709,16 @@ fn collect_configured_channels(
if let Some(ref dc) = config.channels_config.discord {
channels.push(ConfiguredChannel {
display_name: "Discord",
channel: Arc::new(DiscordChannel::new(
dc.bot_token.clone(),
dc.guild_id.clone(),
dc.allowed_users.clone(),
dc.listen_to_bots,
dc.mention_only,
)),
channel: Arc::new(
DiscordChannel::new(
dc.bot_token.clone(),
dc.guild_id.clone(),
dc.allowed_users.clone(),
dc.listen_to_bots,
dc.mention_only,
)
.with_proxy_url(dc.proxy_url.clone()),
),
});
}
@@ -3731,7 +3735,8 @@ fn collect_configured_channels(
)
.with_thread_replies(sl.thread_replies.unwrap_or(true))
.with_group_reply_policy(sl.mention_only, Vec::new())
.with_workspace_dir(config.workspace_dir.clone()),
.with_workspace_dir(config.workspace_dir.clone())
.with_proxy_url(sl.proxy_url.clone()),
),
});
}
@@ -3739,14 +3744,17 @@ fn collect_configured_channels(
if let Some(ref mm) = config.channels_config.mattermost {
channels.push(ConfiguredChannel {
display_name: "Mattermost",
channel: Arc::new(MattermostChannel::new(
mm.url.clone(),
mm.bot_token.clone(),
mm.channel_id.clone(),
mm.allowed_users.clone(),
mm.thread_replies.unwrap_or(true),
mm.mention_only.unwrap_or(false),
)),
channel: Arc::new(
MattermostChannel::new(
mm.url.clone(),
mm.bot_token.clone(),
mm.channel_id.clone(),
mm.allowed_users.clone(),
mm.thread_replies.unwrap_or(true),
mm.mention_only.unwrap_or(false),
)
.with_proxy_url(mm.proxy_url.clone()),
),
});
}
@@ -3784,14 +3792,17 @@ fn collect_configured_channels(
if let Some(ref sig) = config.channels_config.signal {
channels.push(ConfiguredChannel {
display_name: "Signal",
channel: Arc::new(SignalChannel::new(
sig.http_url.clone(),
sig.account.clone(),
sig.group_id.clone(),
sig.allowed_from.clone(),
sig.ignore_attachments,
sig.ignore_stories,
)),
channel: Arc::new(
SignalChannel::new(
sig.http_url.clone(),
sig.account.clone(),
sig.group_id.clone(),
sig.allowed_from.clone(),
sig.ignore_attachments,
sig.ignore_stories,
)
.with_proxy_url(sig.proxy_url.clone()),
),
});
}
@@ -3808,12 +3819,15 @@ fn collect_configured_channels(
if wa.is_cloud_config() {
channels.push(ConfiguredChannel {
display_name: "WhatsApp",
channel: Arc::new(WhatsAppChannel::new(
wa.access_token.clone().unwrap_or_default(),
wa.phone_number_id.clone().unwrap_or_default(),
wa.verify_token.clone().unwrap_or_default(),
wa.allowed_numbers.clone(),
)),
channel: Arc::new(
WhatsAppChannel::new(
wa.access_token.clone().unwrap_or_default(),
wa.phone_number_id.clone().unwrap_or_default(),
wa.verify_token.clone().unwrap_or_default(),
wa.allowed_numbers.clone(),
)
.with_proxy_url(wa.proxy_url.clone()),
),
});
} else {
tracing::warn!("WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)");
@@ -3870,11 +3884,12 @@ fn collect_configured_channels(
if let Some(ref wati_cfg) = config.channels_config.wati {
channels.push(ConfiguredChannel {
display_name: "WATI",
channel: Arc::new(WatiChannel::new(
channel: Arc::new(WatiChannel::new_with_proxy(
wati_cfg.api_token.clone(),
wati_cfg.api_url.clone(),
wati_cfg.tenant_id.clone(),
wati_cfg.allowed_numbers.clone(),
wati_cfg.proxy_url.clone(),
)),
});
}
@@ -3882,10 +3897,11 @@ fn collect_configured_channels(
if let Some(ref nc) = config.channels_config.nextcloud_talk {
channels.push(ConfiguredChannel {
display_name: "Nextcloud Talk",
channel: Arc::new(NextcloudTalkChannel::new(
channel: Arc::new(NextcloudTalkChannel::new_with_proxy(
nc.base_url.clone(),
nc.app_token.clone(),
nc.allowed_users.clone(),
nc.proxy_url.clone(),
)),
});
}
@@ -3957,11 +3973,14 @@ fn collect_configured_channels(
if let Some(ref dt) = config.channels_config.dingtalk {
channels.push(ConfiguredChannel {
display_name: "DingTalk",
channel: Arc::new(DingTalkChannel::new(
dt.client_id.clone(),
dt.client_secret.clone(),
dt.allowed_users.clone(),
)),
channel: Arc::new(
DingTalkChannel::new(
dt.client_id.clone(),
dt.client_secret.clone(),
dt.allowed_users.clone(),
)
.with_proxy_url(dt.proxy_url.clone()),
),
});
}
@@ -3974,7 +3993,8 @@ fn collect_configured_channels(
qq.app_secret.clone(),
qq.allowed_users.clone(),
)
.with_workspace_dir(config.workspace_dir.clone()),
.with_workspace_dir(config.workspace_dir.clone())
.with_proxy_url(qq.proxy_url.clone()),
),
});
}
@@ -8760,6 +8780,7 @@ This is an example JSON object for profile settings."#;
thread_replies: Some(true),
mention_only: Some(false),
interrupt_on_new_message: false,
proxy_url: None,
});
let channels = collect_configured_channels(&config, "test");
@@ -9658,6 +9679,7 @@ This is an example JSON object for profile settings."#;
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
});
match build_channel_by_id(&config, "telegram") {
Ok(channel) => assert_eq!(channel.name(), "telegram"),
+13 -1
View File
@@ -17,11 +17,23 @@ pub struct NextcloudTalkChannel {
impl NextcloudTalkChannel {
pub fn new(base_url: String, app_token: String, allowed_users: Vec<String>) -> Self {
Self::new_with_proxy(base_url, app_token, allowed_users, None)
}
pub fn new_with_proxy(
base_url: String,
app_token: String,
allowed_users: Vec<String>,
proxy_url: Option<String>,
) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
app_token,
allowed_users,
client: reqwest::Client::new(),
client: crate::config::build_channel_proxy_client(
"channel.nextcloud_talk",
proxy_url.as_deref(),
),
}
}
+10 -1
View File
@@ -285,6 +285,8 @@ pub struct QQChannel {
upload_cache: Arc<RwLock<HashMap<String, UploadCacheEntry>>>,
/// Passive reply tracker for QQ API rate limiting.
reply_tracker: Arc<RwLock<HashMap<String, ReplyRecord>>>,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
}
impl QQChannel {
@@ -298,6 +300,7 @@ impl QQChannel {
workspace_dir: None,
upload_cache: Arc::new(RwLock::new(HashMap::new())),
reply_tracker: Arc::new(RwLock::new(HashMap::new())),
proxy_url: None,
}
}
@@ -307,8 +310,14 @@ impl QQChannel {
self
}
/// Set a per-channel proxy URL that overrides the global proxy config.
pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
self.proxy_url = proxy_url;
self
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.qq")
crate::config::build_channel_proxy_client("channel.qq", self.proxy_url.as_deref())
}
fn is_user_allowed(&self, user_id: &str) -> bool {
+14 -1
View File
@@ -28,6 +28,8 @@ pub struct SignalChannel {
allowed_from: Vec<String>,
ignore_attachments: bool,
ignore_stories: bool,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
}
// ── signal-cli SSE event JSON shapes ────────────────────────────
@@ -87,12 +89,23 @@ impl SignalChannel {
allowed_from,
ignore_attachments,
ignore_stories,
proxy_url: None,
}
}
/// Set a per-channel proxy URL that overrides the global proxy config.
pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
self.proxy_url = proxy_url;
self
}
fn http_client(&self) -> Client {
let builder = Client::builder().connect_timeout(Duration::from_secs(10));
let builder = crate::config::apply_runtime_proxy_to_builder(builder, "channel.signal");
let builder = crate::config::apply_channel_proxy_to_builder(
builder,
"channel.signal",
self.proxy_url.as_deref(),
);
builder.build().expect("Signal HTTP client should build")
}
+17 -2
View File
@@ -32,6 +32,8 @@ pub struct SlackChannel {
workspace_dir: Option<PathBuf>,
/// Maps channel_id -> thread_ts for active assistant threads (used for status indicators).
active_assistant_thread: Mutex<HashMap<String, String>>,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
}
const SLACK_HISTORY_MAX_RETRIES: u32 = 3;
@@ -122,6 +124,7 @@ impl SlackChannel {
user_display_name_cache: Mutex::new(HashMap::new()),
workspace_dir: None,
active_assistant_thread: Mutex::new(HashMap::new()),
proxy_url: None,
}
}
@@ -149,8 +152,19 @@ impl SlackChannel {
self
}
/// Set a per-channel proxy URL that overrides the global proxy config.
pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
self.proxy_url = proxy_url;
self
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client_with_timeouts("channel.slack", 30, 10)
crate::config::build_channel_proxy_client_with_timeouts(
"channel.slack",
self.proxy_url.as_deref(),
30,
10,
)
}
/// Check if a Slack user ID is in the allowlist.
@@ -805,12 +819,13 @@ impl SlackChannel {
}
fn slack_media_http_client_no_redirect(&self) -> anyhow::Result<reqwest::Client> {
let builder = crate::config::apply_runtime_proxy_to_builder(
let builder = crate::config::apply_channel_proxy_to_builder(
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10)),
"channel.slack",
self.proxy_url.as_deref(),
);
builder
.build()
+10 -1
View File
@@ -337,6 +337,8 @@ pub struct TelegramChannel {
voice_chats: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
pending_voice:
Arc<std::sync::Mutex<std::collections::HashMap<String, (String, std::time::Instant)>>>,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -379,6 +381,7 @@ impl TelegramChannel {
tts_config: None,
voice_chats: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
pending_voice: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
proxy_url: None,
}
}
@@ -388,6 +391,12 @@ impl TelegramChannel {
self
}
/// Set a per-channel proxy URL that overrides the global proxy config.
pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
self.proxy_url = proxy_url;
self
}
/// Configure workspace directory for saving downloaded attachments.
pub fn with_workspace_dir(mut self, dir: std::path::PathBuf) -> Self {
self.workspace_dir = Some(dir);
@@ -478,7 +487,7 @@ impl TelegramChannel {
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.telegram")
crate::config::build_channel_proxy_client("channel.telegram", self.proxy_url.as_deref())
}
fn normalize_identity(value: &str) -> String {
+11 -1
View File
@@ -22,13 +22,23 @@ impl WatiChannel {
api_url: String,
tenant_id: Option<String>,
allowed_numbers: Vec<String>,
) -> Self {
Self::new_with_proxy(api_token, api_url, tenant_id, allowed_numbers, None)
}
pub fn new_with_proxy(
api_token: String,
api_url: String,
tenant_id: Option<String>,
allowed_numbers: Vec<String>,
proxy_url: Option<String>,
) -> Self {
Self {
api_token,
api_url,
tenant_id,
allowed_numbers,
client: crate::config::build_runtime_proxy_client("channel.wati"),
client: crate::config::build_channel_proxy_client("channel.wati", proxy_url.as_deref()),
}
}
+10 -1
View File
@@ -27,6 +27,8 @@ pub struct WhatsAppChannel {
endpoint_id: String,
verify_token: String,
allowed_numbers: Vec<String>,
/// Per-channel proxy URL override.
proxy_url: Option<String>,
}
impl WhatsAppChannel {
@@ -41,11 +43,18 @@ impl WhatsAppChannel {
endpoint_id,
verify_token,
allowed_numbers,
proxy_url: None,
}
}
/// Set a per-channel proxy URL that overrides the global proxy config.
pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
self.proxy_url = proxy_url;
self
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.whatsapp")
crate::config::build_channel_proxy_client("channel.whatsapp", self.proxy_url.as_deref())
}
/// Check if a phone number is allowed (E.164 format: +1234567890)
+7 -1
View File
@@ -4,7 +4,8 @@ pub mod workspace;
#[allow(unused_imports)]
pub use schema::{
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
apply_channel_proxy_to_builder, apply_runtime_proxy_to_builder, build_channel_proxy_client,
build_channel_proxy_client_with_timeouts, build_runtime_proxy_client,
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
AgentConfig, AssemblyAiSttConfig, AuditConfig, AutonomyConfig, BackupConfig,
BrowserComputerUseConfig, BrowserConfig, BuiltinHooksConfig, ChannelsConfig,
@@ -58,6 +59,7 @@ mod tests {
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
};
let discord = DiscordConfig {
@@ -67,6 +69,7 @@ mod tests {
listen_to_bots: false,
interrupt_on_new_message: false,
mention_only: false,
proxy_url: None,
};
let lark = LarkConfig {
@@ -79,6 +82,7 @@ mod tests {
use_feishu: false,
receive_mode: crate::config::schema::LarkReceiveMode::Websocket,
port: None,
proxy_url: None,
};
let feishu = FeishuConfig {
app_id: "app-id".into(),
@@ -88,6 +92,7 @@ mod tests {
allowed_users: vec![],
receive_mode: crate::config::schema::LarkReceiveMode::Websocket,
port: None,
proxy_url: None,
};
let nextcloud_talk = NextcloudTalkConfig {
@@ -95,6 +100,7 @@ mod tests {
app_token: "app-token".into(),
webhook_secret: None,
allowed_users: vec!["*".into()],
proxy_url: None,
};
assert_eq!(telegram.allowed_users.len(), 1);
+221
View File
@@ -3418,6 +3418,116 @@ pub fn build_runtime_proxy_client_with_timeouts(
client
}
/// Build an HTTP client for a channel, using an explicit per-channel proxy URL
/// when configured. Falls back to the global runtime proxy when `proxy_url` is
/// `None` or empty.
pub fn build_channel_proxy_client(service_key: &str, proxy_url: Option<&str>) -> reqwest::Client {
match normalize_proxy_url_option(proxy_url) {
Some(url) => build_explicit_proxy_client(service_key, &url, None, None),
None => build_runtime_proxy_client(service_key),
}
}
/// Build an HTTP client for a channel with custom timeouts, using an explicit
/// per-channel proxy URL when configured. Falls back to the global runtime
/// proxy when `proxy_url` is `None` or empty.
pub fn build_channel_proxy_client_with_timeouts(
service_key: &str,
proxy_url: Option<&str>,
timeout_secs: u64,
connect_timeout_secs: u64,
) -> reqwest::Client {
match normalize_proxy_url_option(proxy_url) {
Some(url) => build_explicit_proxy_client(
service_key,
&url,
Some(timeout_secs),
Some(connect_timeout_secs),
),
None => build_runtime_proxy_client_with_timeouts(
service_key,
timeout_secs,
connect_timeout_secs,
),
}
}
/// Apply an explicit proxy URL to a `reqwest::ClientBuilder`, returning the
/// modified builder. Used by channels that specify a per-channel `proxy_url`.
pub fn apply_channel_proxy_to_builder(
builder: reqwest::ClientBuilder,
service_key: &str,
proxy_url: Option<&str>,
) -> reqwest::ClientBuilder {
match normalize_proxy_url_option(proxy_url) {
Some(url) => apply_explicit_proxy_to_builder(builder, service_key, &url),
None => apply_runtime_proxy_to_builder(builder, service_key),
}
}
/// Build a client with a single explicit proxy URL (http+https via `Proxy::all`).
fn build_explicit_proxy_client(
service_key: &str,
proxy_url: &str,
timeout_secs: Option<u64>,
connect_timeout_secs: Option<u64>,
) -> reqwest::Client {
let cache_key = format!(
"explicit|{}|{}|timeout={}|connect_timeout={}",
service_key.trim().to_ascii_lowercase(),
proxy_url,
timeout_secs
.map(|v| v.to_string())
.unwrap_or_else(|| "none".to_string()),
connect_timeout_secs
.map(|v| v.to_string())
.unwrap_or_else(|| "none".to_string()),
);
if let Some(client) = runtime_proxy_cached_client(&cache_key) {
return client;
}
let mut builder = reqwest::Client::builder();
if let Some(t) = timeout_secs {
builder = builder.timeout(std::time::Duration::from_secs(t));
}
if let Some(ct) = connect_timeout_secs {
builder = builder.connect_timeout(std::time::Duration::from_secs(ct));
}
builder = apply_explicit_proxy_to_builder(builder, service_key, proxy_url);
let client = builder.build().unwrap_or_else(|error| {
tracing::warn!(
service_key,
proxy_url,
"Failed to build channel proxy client: {error}"
);
reqwest::Client::new()
});
set_runtime_proxy_cached_client(cache_key, client.clone());
client
}
/// Apply a single explicit proxy URL to a builder via `Proxy::all`.
fn apply_explicit_proxy_to_builder(
mut builder: reqwest::ClientBuilder,
service_key: &str,
proxy_url: &str,
) -> reqwest::ClientBuilder {
match reqwest::Proxy::all(proxy_url) {
Ok(proxy) => {
builder = builder.proxy(proxy);
}
Err(error) => {
tracing::warn!(
proxy_url,
service_key,
"Ignoring invalid channel proxy_url: {error}"
);
}
}
builder
}
fn parse_proxy_scope(raw: &str) -> Option<ProxyScope> {
match raw.trim().to_ascii_lowercase().as_str() {
"environment" | "env" => Some(ProxyScope::Environment),
@@ -4922,6 +5032,10 @@ pub struct TelegramConfig {
/// explicitly, it takes precedence.
#[serde(default)]
pub ack_reactions: Option<bool>,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for TelegramConfig {
@@ -4955,6 +5069,10 @@ pub struct DiscordConfig {
/// Other messages in the guild are silently ignored.
#[serde(default)]
pub mention_only: bool,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for DiscordConfig {
@@ -4991,6 +5109,10 @@ pub struct SlackConfig {
/// Direct messages remain allowed.
#[serde(default)]
pub mention_only: bool,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for SlackConfig {
@@ -5026,6 +5148,10 @@ pub struct MattermostConfig {
/// cancels the in-flight request and starts a fresh response with preserved history.
#[serde(default)]
pub interrupt_on_new_message: bool,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for MattermostConfig {
@@ -5138,6 +5264,10 @@ pub struct SignalConfig {
/// Skip incoming story messages.
#[serde(default)]
pub ignore_stories: bool,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for SignalConfig {
@@ -5232,6 +5362,10 @@ pub struct WhatsAppConfig {
/// user's own self-chat (Notes to Self). Defaults to false.
#[serde(default)]
pub self_chat_mode: bool,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for WhatsAppConfig {
@@ -5280,6 +5414,10 @@ pub struct WatiConfig {
/// Allowed phone numbers (E.164 format) or "*" for all.
#[serde(default)]
pub allowed_numbers: Vec<String>,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
fn default_wati_api_url() -> String {
@@ -5310,6 +5448,10 @@ pub struct NextcloudTalkConfig {
/// Allowed Nextcloud actor IDs (`[]` = deny all, `"*"` = allow all).
#[serde(default)]
pub allowed_users: Vec<String>,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for NextcloudTalkConfig {
@@ -5437,6 +5579,10 @@ pub struct LarkConfig {
/// Not required (and ignored) for websocket mode.
#[serde(default)]
pub port: Option<u16>,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for LarkConfig {
@@ -5471,6 +5617,10 @@ pub struct FeishuConfig {
/// Not required (and ignored) for websocket mode.
#[serde(default)]
pub port: Option<u16>,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for FeishuConfig {
@@ -5932,6 +6082,10 @@ pub struct DingTalkConfig {
/// Allowed user IDs (staff IDs). Empty = deny all, "*" = allow all
#[serde(default)]
pub allowed_users: Vec<String>,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for DingTalkConfig {
@@ -5972,6 +6126,10 @@ pub struct QQConfig {
/// Allowed user IDs. Empty = deny all, "*" = allow all
#[serde(default)]
pub allowed_users: Vec<String>,
/// Per-channel proxy URL (http, https, socks5, socks5h).
/// Overrides the global `[proxy]` setting for this channel only.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl ChannelConfig for QQConfig {
@@ -9371,6 +9529,7 @@ default_temperature = 0.7
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
}),
discord: None,
slack: None,
@@ -9825,6 +9984,7 @@ tool_dispatcher = "xml"
allowed_users: vec!["*".into()],
receive_mode: LarkReceiveMode::Websocket,
port: None,
proxy_url: None,
});
config.agents.insert(
@@ -9967,6 +10127,7 @@ tool_dispatcher = "xml"
interrupt_on_new_message: true,
mention_only: false,
ack_reactions: None,
proxy_url: None,
};
let json = serde_json::to_string(&tc).unwrap();
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
@@ -9995,6 +10156,7 @@ tool_dispatcher = "xml"
listen_to_bots: false,
interrupt_on_new_message: false,
mention_only: false,
proxy_url: None,
};
let json = serde_json::to_string(&dc).unwrap();
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
@@ -10011,6 +10173,7 @@ tool_dispatcher = "xml"
listen_to_bots: false,
interrupt_on_new_message: false,
mention_only: false,
proxy_url: None,
};
let json = serde_json::to_string(&dc).unwrap();
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
@@ -10112,6 +10275,7 @@ allowed_users = ["@ops:matrix.org"]
allowed_from: vec!["+1111111111".into()],
ignore_attachments: true,
ignore_stories: false,
proxy_url: None,
};
let json = serde_json::to_string(&sc).unwrap();
let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
@@ -10132,6 +10296,7 @@ allowed_users = ["@ops:matrix.org"]
allowed_from: vec!["*".into()],
ignore_attachments: false,
ignore_stories: true,
proxy_url: None,
};
let toml_str = toml::to_string(&sc).unwrap();
let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
@@ -10362,6 +10527,7 @@ channel_id = "C123"
dm_policy: WhatsAppChatPolicy::default(),
group_policy: WhatsAppChatPolicy::default(),
self_chat_mode: false,
proxy_url: None,
};
let json = serde_json::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
@@ -10386,6 +10552,7 @@ channel_id = "C123"
dm_policy: WhatsAppChatPolicy::default(),
group_policy: WhatsAppChatPolicy::default(),
self_chat_mode: false,
proxy_url: None,
};
let toml_str = toml::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
@@ -10415,6 +10582,7 @@ channel_id = "C123"
dm_policy: WhatsAppChatPolicy::default(),
group_policy: WhatsAppChatPolicy::default(),
self_chat_mode: false,
proxy_url: None,
};
let toml_str = toml::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
@@ -10436,6 +10604,7 @@ channel_id = "C123"
dm_policy: WhatsAppChatPolicy::default(),
group_policy: WhatsAppChatPolicy::default(),
self_chat_mode: false,
proxy_url: None,
};
assert!(wc.is_ambiguous_config());
assert_eq!(wc.backend_type(), "cloud");
@@ -10456,6 +10625,7 @@ channel_id = "C123"
dm_policy: WhatsAppChatPolicy::default(),
group_policy: WhatsAppChatPolicy::default(),
self_chat_mode: false,
proxy_url: None,
};
assert!(!wc.is_ambiguous_config());
assert_eq!(wc.backend_type(), "web");
@@ -10486,6 +10656,7 @@ channel_id = "C123"
dm_policy: WhatsAppChatPolicy::default(),
group_policy: WhatsAppChatPolicy::default(),
self_chat_mode: false,
proxy_url: None,
}),
linq: None,
wati: None,
@@ -11464,6 +11635,50 @@ default_model = "legacy-model"
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
async fn load_or_init_decrypts_feishu_channel_secrets() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
let config_dir = temp_home.join(".zeroclaw");
let config_path = config_dir.join("config.toml");
fs::create_dir_all(&config_dir).await.unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
std::env::remove_var("ZEROCLAW_WORKSPACE");
let mut config = Config::default();
config.config_path = config_path.clone();
config.workspace_dir = config_dir.join("workspace");
config.secrets.encrypt = true;
config.channels_config.feishu = Some(FeishuConfig {
app_id: "cli_feishu_123".into(),
app_secret: "feishu-secret".into(),
encrypt_key: Some("feishu-encrypt".into()),
verification_token: Some("feishu-verify".into()),
allowed_users: vec!["*".into()],
receive_mode: LarkReceiveMode::Websocket,
port: None,
proxy_url: None,
});
config.save().await.unwrap();
let loaded = Box::pin(Config::load_or_init()).await.unwrap();
let feishu = loaded.channels_config.feishu.as_ref().unwrap();
assert_eq!(feishu.app_secret, "feishu-secret");
assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
async fn load_or_init_uses_persisted_active_workspace_marker() {
let _env_guard = env_override_lock().await;
@@ -12158,6 +12373,7 @@ default_model = "persisted-profile"
use_feishu: true,
receive_mode: LarkReceiveMode::Websocket,
port: None,
proxy_url: None,
};
let json = serde_json::to_string(&lc).unwrap();
let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
@@ -12181,6 +12397,7 @@ default_model = "persisted-profile"
use_feishu: false,
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
proxy_url: None,
};
let toml_str = toml::to_string(&lc).unwrap();
let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
@@ -12227,6 +12444,7 @@ default_model = "persisted-profile"
allowed_users: vec!["user_123".into(), "user_456".into()],
receive_mode: LarkReceiveMode::Websocket,
port: None,
proxy_url: None,
};
let json = serde_json::to_string(&fc).unwrap();
let parsed: FeishuConfig = serde_json::from_str(&json).unwrap();
@@ -12247,6 +12465,7 @@ default_model = "persisted-profile"
allowed_users: vec!["*".into()],
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
proxy_url: None,
};
let toml_str = toml::to_string(&fc).unwrap();
let parsed: FeishuConfig = toml::from_str(&toml_str).unwrap();
@@ -12274,6 +12493,7 @@ default_model = "persisted-profile"
app_token: "app-token".into(),
webhook_secret: Some("webhook-secret".into()),
allowed_users: vec!["user_a".into(), "*".into()],
proxy_url: None,
};
let json = serde_json::to_string(&nc).unwrap();
@@ -12506,6 +12726,7 @@ require_otp_to_resume = true
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
});
// Save (triggers encryption)
+7
View File
@@ -646,6 +646,7 @@ mod tests {
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
});
assert!(has_supervised_channels(&config));
}
@@ -657,6 +658,7 @@ mod tests {
client_id: "client_id".into(),
client_secret: "client_secret".into(),
allowed_users: vec!["*".into()],
proxy_url: None,
});
assert!(has_supervised_channels(&config));
}
@@ -672,6 +674,7 @@ mod tests {
thread_replies: Some(true),
mention_only: Some(false),
interrupt_on_new_message: false,
proxy_url: None,
});
assert!(has_supervised_channels(&config));
}
@@ -683,6 +686,7 @@ mod tests {
app_id: "app-id".into(),
app_secret: "app-secret".into(),
allowed_users: vec!["*".into()],
proxy_url: None,
});
assert!(has_supervised_channels(&config));
}
@@ -695,6 +699,7 @@ mod tests {
app_token: "app-token".into(),
webhook_secret: None,
allowed_users: vec!["*".into()],
proxy_url: None,
});
assert!(has_supervised_channels(&config));
}
@@ -761,6 +766,7 @@ mod tests {
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
});
let target = resolve_heartbeat_delivery(&config).unwrap();
@@ -778,6 +784,7 @@ mod tests {
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
});
let target = resolve_heartbeat_delivery(&config).unwrap();
+4
View File
@@ -1457,6 +1457,7 @@ mod tests {
api_url: "https://live-mt-server.wati.io".to_string(),
tenant_id: None,
allowed_numbers: vec![],
proxy_url: None,
});
cfg.channels_config.feishu = Some(crate::config::schema::FeishuConfig {
app_id: "cli_aabbcc".to_string(),
@@ -1466,6 +1467,7 @@ mod tests {
allowed_users: vec!["*".to_string()],
receive_mode: crate::config::schema::LarkReceiveMode::Websocket,
port: None,
proxy_url: None,
});
cfg.channels_config.email = Some(crate::channels::email_channel::EmailConfig {
imap_host: "imap.example.com".to_string(),
@@ -1591,6 +1593,7 @@ mod tests {
api_url: "https://live-mt-server.wati.io".to_string(),
tenant_id: None,
allowed_numbers: vec![],
proxy_url: None,
});
current.channels_config.feishu = Some(crate::config::schema::FeishuConfig {
app_id: "cli_current".to_string(),
@@ -1600,6 +1603,7 @@ mod tests {
allowed_users: vec!["*".to_string()],
receive_mode: crate::config::schema::LarkReceiveMode::Websocket,
port: None,
proxy_url: None,
});
current.channels_config.email = Some(crate::channels::email_channel::EmailConfig {
imap_host: "imap.example.com".to_string(),
+1
View File
@@ -841,6 +841,7 @@ mod tests {
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
});
let entries = all_integrations();
let tg = entries.iter().find(|e| e.name == "Telegram").unwrap();
+15
View File
@@ -3801,6 +3801,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
});
}
ChannelMenuChoice::Discord => {
@@ -3901,6 +3902,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
listen_to_bots: false,
interrupt_on_new_message: false,
mention_only: false,
proxy_url: None,
});
}
ChannelMenuChoice::Slack => {
@@ -4031,6 +4033,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
interrupt_on_new_message: false,
thread_replies: None,
mention_only: false,
proxy_url: None,
});
}
ChannelMenuChoice::IMessage => {
@@ -4282,6 +4285,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
allowed_from,
ignore_attachments,
ignore_stories,
proxy_url: None,
});
println!(" {} Signal configured", style("").green().bold());
@@ -4383,6 +4387,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
dm_policy: WhatsAppChatPolicy::default(),
group_policy: WhatsAppChatPolicy::default(),
self_chat_mode: false,
proxy_url: None,
});
println!(
@@ -4488,6 +4493,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
dm_policy: WhatsAppChatPolicy::default(),
group_policy: WhatsAppChatPolicy::default(),
self_chat_mode: false,
proxy_url: None,
});
}
ChannelMenuChoice::Linq => {
@@ -4821,6 +4827,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
Some(webhook_secret.trim().to_string())
},
allowed_users,
proxy_url: None,
});
println!(" {} Nextcloud Talk configured", style("").green().bold());
@@ -4893,6 +4900,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
client_id,
client_secret,
allowed_users,
proxy_url: None,
});
}
ChannelMenuChoice::QqOfficial => {
@@ -4969,6 +4977,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
app_id,
app_secret,
allowed_users,
proxy_url: None,
});
}
ChannelMenuChoice::Lark | ChannelMenuChoice::Feishu => {
@@ -5158,6 +5167,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
use_feishu: is_feishu,
receive_mode,
port,
proxy_url: None,
});
}
#[cfg(feature = "channel-nostr")]
@@ -7522,6 +7532,7 @@ mod tests {
allowed_from: vec!["*".into()],
ignore_attachments: false,
ignore_stories: true,
proxy_url: None,
});
assert!(has_launchable_channels(&channels));
@@ -7534,6 +7545,7 @@ mod tests {
thread_replies: Some(true),
mention_only: Some(false),
interrupt_on_new_message: false,
proxy_url: None,
});
assert!(has_launchable_channels(&channels));
@@ -7542,6 +7554,7 @@ mod tests {
app_id: "app-id".into(),
app_secret: "app-secret".into(),
allowed_users: vec!["*".into()],
proxy_url: None,
});
assert!(has_launchable_channels(&channels));
@@ -7551,6 +7564,7 @@ mod tests {
app_token: "token".into(),
webhook_secret: Some("secret".into()),
allowed_users: vec!["*".into()],
proxy_url: None,
});
assert!(has_launchable_channels(&channels));
@@ -7563,6 +7577,7 @@ mod tests {
allowed_users: vec!["*".into()],
receive_mode: crate::config::schema::LarkReceiveMode::Websocket,
port: None,
proxy_url: None,
});
assert!(has_launchable_channels(&channels));
}