mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +00:00
Apply Telegram channel learnings to WhatsApp implementation
- Fix metadata flow: store sender_phone for response routing
- Add credential injection in headers (Bearer {WHATSAPP_ACCESS_TOKEN})
- Add secret_validated check for webhook defense in depth
- Add status message filtering to prevent loops
- Add proper WhatsApp API error response parsing
- Create whatsapp.capabilities.json with setup/secrets/rate limits
- Add docs/BUILDING_CHANNELS.md with patterns and examples
Also fix UTF-8 truncation bugs across codebase:
- wrapper.rs: content preview, response body, webhook body logging
- agent_loop.rs: params truncation for approval display
- shell.rs: truncate_for_error() helper
Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
ce87ec1dbe
commit
e6946172f7
@@ -333,8 +333,11 @@ impl Agent {
|
||||
// Format approval request for user
|
||||
let params_preview = serde_json::to_string_pretty(¶meters)
|
||||
.unwrap_or_else(|_| parameters.to_string());
|
||||
let params_truncated = if params_preview.len() > 200 {
|
||||
format!("{}...", ¶ms_preview[..200])
|
||||
let params_truncated = if params_preview.chars().count() > 200 {
|
||||
format!(
|
||||
"{}...",
|
||||
params_preview.chars().take(200).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
params_preview
|
||||
};
|
||||
|
||||
@@ -93,19 +93,22 @@ impl ChannelStoreData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject credentials into a URL by replacing placeholders.
|
||||
/// Inject credentials into a string by replacing placeholders.
|
||||
///
|
||||
/// Replaces patterns like `{TELEGRAM_BOT_TOKEN}` with actual values from
|
||||
/// the injected credentials map. This allows WASM channels to reference
|
||||
/// credentials without ever seeing the actual values.
|
||||
fn inject_credentials_into_url(&self, url: &str) -> String {
|
||||
let mut result = url.to_string();
|
||||
/// Replaces patterns like `{TELEGRAM_BOT_TOKEN}` or `{WHATSAPP_ACCESS_TOKEN}`
|
||||
/// with actual values from the injected credentials map. This allows WASM
|
||||
/// channels to reference credentials without ever seeing the actual values.
|
||||
///
|
||||
/// Works on URLs, headers, or any string with credential placeholders.
|
||||
fn inject_credentials(&self, input: &str, context: &str) -> String {
|
||||
let mut result = input.to_string();
|
||||
|
||||
tracing::debug!(
|
||||
url = %url,
|
||||
input_preview = %input.chars().take(100).collect::<String>(),
|
||||
context = %context,
|
||||
credential_count = self.credentials.len(),
|
||||
credential_names = ?self.credentials.keys().collect::<Vec<_>>(),
|
||||
"Injecting credentials into URL"
|
||||
"Injecting credentials"
|
||||
);
|
||||
|
||||
// Replace all known placeholders from the credentials map
|
||||
@@ -114,6 +117,7 @@ impl ChannelStoreData {
|
||||
if result.contains(&placeholder) {
|
||||
tracing::debug!(
|
||||
placeholder = %placeholder,
|
||||
context = %context,
|
||||
"Found and replacing credential placeholder"
|
||||
);
|
||||
result = result.replace(&placeholder, value);
|
||||
@@ -122,11 +126,16 @@ impl ChannelStoreData {
|
||||
|
||||
// Check if any placeholders remain (indicates missing credential)
|
||||
if result.contains('{') && result.contains('}') {
|
||||
tracing::warn!(
|
||||
original_url = %url,
|
||||
result_url = %result,
|
||||
"URL may contain unresolved placeholders"
|
||||
);
|
||||
// Only warn if it looks like an unresolved placeholder (not JSON braces)
|
||||
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
|
||||
if let Some(re) = brace_pattern {
|
||||
if re.is_match(&result) {
|
||||
tracing::warn!(
|
||||
context = %context,
|
||||
"String may contain unresolved credential placeholders"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
@@ -186,15 +195,11 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
);
|
||||
|
||||
// Inject credentials into URL (e.g., replace {TELEGRAM_BOT_TOKEN} with actual token)
|
||||
let injected_url = self.inject_credentials_into_url(&url);
|
||||
let injected_url = self.inject_credentials(&url, "url");
|
||||
|
||||
// Log whether injection happened (without revealing the token)
|
||||
let url_changed = injected_url != url;
|
||||
tracing::info!(
|
||||
url_changed = url_changed,
|
||||
has_bot_token = injected_url.contains("/bot") && !injected_url.contains("{"),
|
||||
"URL after credential injection"
|
||||
);
|
||||
tracing::info!(url_changed = url_changed, "URL after credential injection");
|
||||
|
||||
// Check if HTTP is allowed for this URL
|
||||
self.host_state
|
||||
@@ -210,11 +215,29 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
format!("Rate limit exceeded: {}", e)
|
||||
})?;
|
||||
|
||||
// Parse headers
|
||||
let headers: std::collections::HashMap<String, String> =
|
||||
// Parse headers and inject credentials into header values
|
||||
// This allows patterns like "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
|
||||
let raw_headers: std::collections::HashMap<String, String> =
|
||||
serde_json::from_str(&headers_json).unwrap_or_default();
|
||||
|
||||
tracing::debug!(header_count = headers.len(), "Parsed request headers");
|
||||
let headers: std::collections::HashMap<String, String> = raw_headers
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.clone(),
|
||||
self.inject_credentials(&v, &format!("header:{}", k)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let headers_changed = headers
|
||||
.values()
|
||||
.any(|v| v.contains("Bearer ") && !v.contains('{'));
|
||||
tracing::debug!(
|
||||
header_count = headers.len(),
|
||||
headers_changed = headers_changed,
|
||||
"Parsed and injected request headers"
|
||||
);
|
||||
|
||||
let url = injected_url;
|
||||
|
||||
@@ -273,10 +296,10 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
"HTTP response received"
|
||||
);
|
||||
|
||||
// Log response body for debugging (truncated)
|
||||
// Log response body for debugging (truncated at char boundary)
|
||||
if let Ok(body_str) = std::str::from_utf8(&body) {
|
||||
let truncated = if body_str.len() > 500 {
|
||||
format!("{}...", &body_str[..500])
|
||||
let truncated = if body_str.chars().count() > 500 {
|
||||
format!("{}...", body_str.chars().take(500).collect::<String>())
|
||||
} else {
|
||||
body_str.to_string()
|
||||
};
|
||||
@@ -647,10 +670,10 @@ impl WasmChannel {
|
||||
"call_on_http_request invoked (webhook received)"
|
||||
);
|
||||
|
||||
// Log the body for debugging (if it looks like JSON)
|
||||
// Log the body for debugging (truncated at char boundary)
|
||||
if let Ok(body_str) = std::str::from_utf8(body) {
|
||||
let truncated = if body_str.len() > 1000 {
|
||||
format!("{}...", &body_str[..1000])
|
||||
let truncated = if body_str.chars().count() > 1000 {
|
||||
format!("{}...", body_str.chars().take(1000).collect::<String>())
|
||||
} else {
|
||||
body_str.to_string()
|
||||
};
|
||||
@@ -884,8 +907,10 @@ impl WasmChannel {
|
||||
metadata_json,
|
||||
};
|
||||
|
||||
// Truncate at char boundary for logging (avoid panic on multi-byte UTF-8)
|
||||
let content_preview: String = content.chars().take(50).collect();
|
||||
tracing::info!(
|
||||
content_preview = %if content.len() > 50 { &content[..50] } else { &content },
|
||||
content_preview = %content_preview,
|
||||
"Calling WASM on_respond"
|
||||
);
|
||||
|
||||
|
||||
@@ -403,12 +403,12 @@ fn truncate_output(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate command for error messages.
|
||||
/// Truncate command for error messages (char-aware to avoid UTF-8 boundary panics).
|
||||
fn truncate_for_error(s: &str) -> String {
|
||||
if s.len() <= 100 {
|
||||
if s.chars().count() <= 100 {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..100])
|
||||
format!("{}...", s.chars().take(100).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user