feat(proxy): add scoped proxy configuration and docs runbooks

- add scope-aware proxy schema and runtime wiring for providers/channels/tools

- add agent callable proxy_config tool for fast proxy setup

- standardize docs system with index, template, and playbooks
This commit is contained in:
Chummy 2026-02-18 21:09:01 +08:00
parent 13ee9e6398
commit ce104bed45
36 changed files with 2025 additions and 323 deletions

View file

@ -15,7 +15,6 @@ pub struct DingTalkChannel {
client_id: String,
client_secret: String,
allowed_users: Vec<String>,
client: reqwest::Client,
/// 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>>>,
@ -34,11 +33,14 @@ impl DingTalkChannel {
client_id,
client_secret,
allowed_users,
client: reqwest::Client::new(),
session_webhooks: Arc::new(RwLock::new(HashMap::new())),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.dingtalk")
}
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
@ -86,7 +88,7 @@ impl DingTalkChannel {
});
let resp = self
.client
.http_client()
.post("https://api.dingtalk.com/v1.0/gateway/connections/open")
.json(&body)
.send()
@ -128,7 +130,12 @@ impl Channel for DingTalkChannel {
}
});
let resp = self.client.post(webhook_url).json(&body).send().await?;
let resp = self
.http_client()
.post(webhook_url)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();

View file

@ -13,7 +13,6 @@ pub struct DiscordChannel {
allowed_users: Vec<String>,
listen_to_bots: bool,
mention_only: bool,
client: reqwest::Client,
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
@ -31,11 +30,14 @@ impl DiscordChannel {
allowed_users,
listen_to_bots,
mention_only,
client: reqwest::Client::new(),
typing_handle: Mutex::new(None),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.discord")
}
/// Check if a Discord user ID is in the allowlist.
/// Empty list means deny everyone until explicitly configured.
/// `"*"` means allow everyone.
@ -198,7 +200,7 @@ impl Channel for DiscordChannel {
let body = json!({ "content": chunk });
let resp = self
.client
.http_client()
.post(&url)
.header("Authorization", format!("Bot {}", self.bot_token))
.json(&body)
@ -229,7 +231,7 @@ impl Channel for DiscordChannel {
// Get Gateway URL
let gw_resp: serde_json::Value = self
.client
.http_client()
.get("https://discord.com/api/v10/gateway/bot")
.header("Authorization", format!("Bot {}", self.bot_token))
.send()
@ -424,7 +426,7 @@ impl Channel for DiscordChannel {
}
async fn health_check(&self) -> bool {
self.client
self.http_client()
.get("https://discord.com/api/v10/users/@me")
.header("Authorization", format!("Bot {}", self.bot_token))
.send()
@ -436,7 +438,7 @@ impl Channel for DiscordChannel {
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
self.stop_typing(recipient).await?;
let client = self.client.clone();
let client = self.http_client();
let token = self.bot_token.clone();
let channel_id = recipient.to_string();

View file

@ -142,7 +142,6 @@ pub struct LarkChannel {
use_feishu: bool,
/// How to receive events: WebSocket long-connection or HTTP webhook.
receive_mode: crate::config::schema::LarkReceiveMode,
client: reqwest::Client,
/// Cached tenant access token
tenant_token: Arc<RwLock<Option<String>>>,
/// Dedup set: WS message_ids seen in last ~30 min to prevent double-dispatch
@ -165,7 +164,6 @@ impl LarkChannel {
allowed_users,
use_feishu: true,
receive_mode: crate::config::schema::LarkReceiveMode::default(),
client: reqwest::Client::new(),
tenant_token: Arc::new(RwLock::new(None)),
ws_seen_ids: Arc::new(RwLock::new(HashMap::new())),
}
@ -185,6 +183,10 @@ impl LarkChannel {
ch
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.lark")
}
fn api_base(&self) -> &'static str {
if self.use_feishu {
FEISHU_BASE_URL
@ -212,7 +214,7 @@ impl LarkChannel {
/// POST /callback/ws/endpoint → (wss_url, client_config)
async fn get_ws_endpoint(&self) -> anyhow::Result<(String, WsClientConfig)> {
let resp = self
.client
.http_client()
.post(format!("{}/callback/ws/endpoint", self.ws_base()))
.header("locale", if self.use_feishu { "zh" } else { "en" })
.json(&serde_json::json!({
@ -488,7 +490,7 @@ impl LarkChannel {
"app_secret": self.app_secret,
});
let resp = self.client.post(&url).json(&body).send().await?;
let resp = self.http_client().post(&url).json(&body).send().await?;
let data: serde_json::Value = resp.json().await?;
let code = data.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
@ -642,7 +644,7 @@ impl Channel for LarkChannel {
});
let resp = self
.client
.http_client()
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json; charset=utf-8")
@ -655,7 +657,7 @@ impl Channel for LarkChannel {
self.invalidate_token().await;
let new_token = self.get_tenant_access_token().await?;
let retry_resp = self
.client
.http_client()
.post(&url)
.header("Authorization", format!("Bearer {new_token}"))
.header("Content-Type", "application/json; charset=utf-8")

View file

@ -12,7 +12,6 @@ pub struct MatrixChannel {
access_token: String,
room_id: String,
allowed_users: Vec<String>,
client: Client,
}
#[derive(Debug, Deserialize)]
@ -79,10 +78,13 @@ impl MatrixChannel {
access_token,
room_id,
allowed_users,
client: Client::new(),
}
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client("channel.matrix")
}
fn is_user_allowed(&self, sender: &str) -> bool {
if self.allowed_users.iter().any(|u| u == "*") {
return true;
@ -95,7 +97,7 @@ impl MatrixChannel {
async fn get_my_user_id(&self) -> anyhow::Result<String> {
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
let resp = self
.client
.http_client()
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()
@ -130,7 +132,7 @@ impl Channel for MatrixChannel {
});
let resp = self
.client
.http_client()
.put(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.json(&body)
@ -157,7 +159,7 @@ impl Channel for MatrixChannel {
);
let resp = self
.client
.http_client()
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()
@ -179,7 +181,7 @@ impl Channel for MatrixChannel {
);
let resp = self
.client
.http_client()
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()
@ -250,7 +252,7 @@ impl Channel for MatrixChannel {
async fn health_check(&self) -> bool {
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
let Ok(resp) = self
.client
.http_client()
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()

View file

@ -15,7 +15,6 @@ pub struct MattermostChannel {
thread_replies: bool,
/// When true, only respond to messages that @-mention the bot.
mention_only: bool,
client: reqwest::Client,
/// Handle for the background typing-indicator loop (aborted on stop_typing).
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
@ -38,11 +37,14 @@ impl MattermostChannel {
allowed_users,
thread_replies,
mention_only,
client: reqwest::Client::new(),
typing_handle: Mutex::new(None),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.mattermost")
}
/// Check if a user ID is in the allowlist.
/// Empty list means deny everyone. "*" means allow everyone.
fn is_user_allowed(&self, user_id: &str) -> bool {
@ -53,7 +55,7 @@ impl MattermostChannel {
/// and detect @-mentions by username.
async fn get_bot_identity(&self) -> (String, String) {
let resp: Option<serde_json::Value> = async {
self.client
self.http_client()
.get(format!("{}/api/v4/users/me", self.base_url))
.bearer_auth(&self.bot_token)
.send()
@ -109,7 +111,7 @@ impl Channel for MattermostChannel {
}
let resp = self
.client
.http_client()
.post(format!("{}/api/v4/posts", self.base_url))
.bearer_auth(&self.bot_token)
.json(&body_map)
@ -147,7 +149,7 @@ impl Channel for MattermostChannel {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let resp = match self
.client
.http_client()
.get(format!(
"{}/api/v4/channels/{}/posts",
self.base_url, channel_id
@ -202,7 +204,7 @@ impl Channel for MattermostChannel {
}
async fn health_check(&self) -> bool {
self.client
self.http_client()
.get(format!("{}/api/v4/users/me", self.base_url))
.bearer_auth(&self.bot_token)
.send()
@ -215,7 +217,7 @@ impl Channel for MattermostChannel {
// Cancel any existing typing loop before starting a new one.
self.stop_typing(recipient).await?;
let client = self.client.clone();
let client = self.http_client();
let token = self.bot_token.clone();
let base_url = self.base_url.clone();

View file

@ -20,7 +20,6 @@ pub struct QQChannel {
app_id: String,
app_secret: String,
allowed_users: Vec<String>,
client: reqwest::Client,
/// Cached access token + expiry timestamp.
token_cache: Arc<RwLock<Option<(String, u64)>>>,
/// Message deduplication set.
@ -33,12 +32,15 @@ impl QQChannel {
app_id,
app_secret,
allowed_users,
client: reqwest::Client::new(),
token_cache: Arc::new(RwLock::new(None)),
dedup: Arc::new(RwLock::new(HashSet::new())),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.qq")
}
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
@ -50,7 +52,12 @@ impl QQChannel {
"clientSecret": self.app_secret,
});
let resp = self.client.post(QQ_AUTH_URL).json(&body).send().await?;
let resp = self
.http_client()
.post(QQ_AUTH_URL)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
@ -109,7 +116,7 @@ impl QQChannel {
/// Get the WebSocket gateway URL.
async fn get_gateway_url(&self, token: &str) -> anyhow::Result<String> {
let resp = self
.client
.http_client()
.get(format!("{QQ_API_BASE}/gateway"))
.header("Authorization", format!("QQBot {token}"))
.send()
@ -190,7 +197,7 @@ impl Channel for QQChannel {
};
let resp = self
.client
.http_client()
.post(&url)
.header("Authorization", format!("QQBot {token}"))
.json(&body)

View file

@ -28,7 +28,6 @@ pub struct SignalChannel {
allowed_from: Vec<String>,
ignore_attachments: bool,
ignore_stories: bool,
client: Client,
}
// ── signal-cli SSE event JSON shapes ────────────────────────────
@ -81,10 +80,6 @@ impl SignalChannel {
ignore_stories: bool,
) -> Self {
let http_url = http_url.trim_end_matches('/').to_string();
let client = Client::builder()
.connect_timeout(Duration::from_secs(10))
.build()
.expect("Signal HTTP client should build");
Self {
http_url,
account,
@ -92,10 +87,15 @@ impl SignalChannel {
allowed_from,
ignore_attachments,
ignore_stories,
client,
}
}
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");
builder.build().expect("Signal HTTP client should build")
}
/// Effective sender: prefer `sourceNumber` (E.164), fall back to `source`.
fn sender(envelope: &Envelope) -> Option<String> {
envelope
@ -178,7 +178,7 @@ impl SignalChannel {
});
let resp = self
.client
.http_client()
.post(&url)
.timeout(Duration::from_secs(30))
.header("Content-Type", "application/json")
@ -298,7 +298,7 @@ impl Channel for SignalChannel {
loop {
let resp = self
.client
.http_client()
.get(url.clone())
.header("Accept", "text/event-stream")
.send()
@ -408,7 +408,7 @@ impl Channel for SignalChannel {
async fn health_check(&self) -> bool {
let url = format!("{}/api/v1/check", self.http_url);
let Ok(resp) = self
.client
.http_client()
.get(&url)
.timeout(Duration::from_secs(10))
.send()

View file

@ -6,7 +6,6 @@ pub struct SlackChannel {
bot_token: String,
channel_id: Option<String>,
allowed_users: Vec<String>,
client: reqwest::Client,
}
impl SlackChannel {
@ -15,10 +14,13 @@ impl SlackChannel {
bot_token,
channel_id,
allowed_users,
client: reqwest::Client::new(),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.slack")
}
/// Check if a Slack user ID is in the allowlist.
/// Empty list means deny everyone until explicitly configured.
/// `"*"` means allow everyone.
@ -29,7 +31,7 @@ impl SlackChannel {
/// Get the bot's own user ID so we can ignore our own messages
async fn get_bot_user_id(&self) -> Option<String> {
let resp: serde_json::Value = self
.client
.http_client()
.get("https://slack.com/api/auth.test")
.bearer_auth(&self.bot_token)
.send()
@ -58,7 +60,7 @@ impl Channel for SlackChannel {
});
let resp = self
.client
.http_client()
.post("https://slack.com/api/chat.postMessage")
.bearer_auth(&self.bot_token)
.json(&body)
@ -108,7 +110,7 @@ impl Channel for SlackChannel {
}
let resp = match self
.client
.http_client()
.get("https://slack.com/api/conversations.history")
.bearer_auth(&self.bot_token)
.query(&params)
@ -179,7 +181,7 @@ impl Channel for SlackChannel {
}
async fn health_check(&self) -> bool {
self.client
self.http_client()
.get("https://slack.com/api/auth.test")
.bearer_auth(&self.bot_token)
.send()

View file

@ -357,6 +357,10 @@ impl TelegramChannel {
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.telegram")
}
fn normalize_identity(value: &str) -> String {
value.trim().trim_start_matches('@').to_string()
}
@ -448,7 +452,7 @@ impl TelegramChannel {
}
async fn fetch_bot_username(&self) -> anyhow::Result<String> {
let resp = self.client.get(self.api_url("getMe")).send().await?;
let resp = self.http_client().get(self.api_url("getMe")).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Failed to fetch bot info: {}", resp.status());
@ -857,7 +861,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let markdown_resp = self
.client
.http_client()
.post(self.api_url("sendMessage"))
.json(&markdown_body)
.send()
@ -887,7 +891,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
}
let plain_resp = self
.client
.http_client()
.post(self.api_url("sendMessage"))
.json(&plain_body)
.send()
@ -936,7 +940,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url(method))
.json(&body)
.send()
@ -1029,7 +1033,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendDocument"))
.multipart(form)
.send()
@ -1068,7 +1072,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendDocument"))
.multipart(form)
.send()
@ -1112,7 +1116,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendPhoto"))
.multipart(form)
.send()
@ -1151,7 +1155,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendPhoto"))
.multipart(form)
.send()
@ -1195,7 +1199,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendVideo"))
.multipart(form)
.send()
@ -1239,7 +1243,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendAudio"))
.multipart(form)
.send()
@ -1283,7 +1287,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendVoice"))
.multipart(form)
.send()
@ -1320,7 +1324,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendDocument"))
.json(&body)
.send()
@ -1357,7 +1361,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendPhoto"))
.json(&body)
.send()
@ -1685,7 +1689,7 @@ impl Channel for TelegramChannel {
"allowed_updates": ["message"]
});
let resp = match self.client.post(&url).json(&body).send().await {
let resp = match self.http_client().post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!("Telegram poll error: {e}");
@ -1750,7 +1754,7 @@ Ensure only one `zeroclaw` process is using this bot token."
"action": "typing"
});
let _ = self
.client
.http_client()
.post(self.api_url("sendChatAction"))
.json(&typing_body)
.send()
@ -1769,7 +1773,7 @@ Ensure only one `zeroclaw` process is using this bot token."
match tokio::time::timeout(
timeout_duration,
self.client.get(self.api_url("getMe")).send(),
self.http_client().get(self.api_url("getMe")).send(),
)
.await
{
@ -1788,7 +1792,7 @@ Ensure only one `zeroclaw` process is using this bot token."
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
self.stop_typing(recipient).await?;
let client = self.client.clone();
let client = self.http_client();
let url = self.api_url("sendChatAction");
let chat_id = recipient.to_string();

View file

@ -13,7 +13,6 @@ pub struct WhatsAppChannel {
endpoint_id: String,
verify_token: String,
allowed_numbers: Vec<String>,
client: reqwest::Client,
}
impl WhatsAppChannel {
@ -28,10 +27,13 @@ impl WhatsAppChannel {
endpoint_id,
verify_token,
allowed_numbers,
client: reqwest::Client::new(),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.whatsapp")
}
/// Check if a phone number is allowed (E.164 format: +1234567890)
fn is_number_allowed(&self, phone: &str) -> bool {
self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
@ -164,7 +166,7 @@ impl Channel for WhatsAppChannel {
});
let resp = self
.client
.http_client()
.post(&url)
.bearer_auth(&self.access_token)
.header("Content-Type", "application/json")
@ -201,7 +203,7 @@ impl Channel for WhatsAppChannel {
// Check if we can reach the WhatsApp API
let url = format!("https://graph.facebook.com/v18.0/{}", self.endpoint_id);
self.client
self.http_client()
.get(&url)
.bearer_auth(&self.access_token)
.send()