mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
Add Telegram webhook support with credential injection
Enable instant message delivery for Telegram via webhooks instead of polling.
Key changes:
- Add tunnel URL configuration for local development (ngrok, cloudflare)
- Auto-register webhook with Telegram API on startup using setWebhook
- Implement webhook secret validation via X-Telegram-Bot-Api-Secret-Token header
- Add credential injection for bot token via URL placeholder substitution
- Fix metadata preservation in respond() to route replies correctly
- Fix serde flatten with Option<T> issue in capabilities schema parsing
The credential injection pattern replaces {TELEGRAM_BOT_TOKEN} placeholders
in URLs with the actual token from the secrets store, keeping credentials
out of WASM module memory until the HTTP request is made.
Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
4ab20ff939
commit
7955c9742e
@@ -74,6 +74,12 @@ pub enum WasmChannelError {
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Webhook registration failed for channel {name}: {reason}")]
|
||||
WebhookRegistration { name: String, reason: String },
|
||||
|
||||
#[error("HTTP request error: {0}")]
|
||||
HttpRequest(String),
|
||||
}
|
||||
|
||||
impl From<crate::tools::wasm::WasmError> for WasmChannelError {
|
||||
|
||||
@@ -59,7 +59,28 @@ impl WasmChannelLoader {
|
||||
let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?;
|
||||
|
||||
// Debug: log raw capabilities
|
||||
tracing::debug!(
|
||||
channel = name,
|
||||
raw_capabilities = ?cap_file.capabilities,
|
||||
"Parsed capabilities file"
|
||||
);
|
||||
|
||||
let caps = cap_file.to_capabilities();
|
||||
|
||||
// Debug: log resulting capabilities
|
||||
tracing::info!(
|
||||
channel = name,
|
||||
http_allowed = caps.tool_capabilities.http.is_some(),
|
||||
http_allowlist_count = caps
|
||||
.tool_capabilities
|
||||
.http
|
||||
.as_ref()
|
||||
.map(|h| h.allowlist.len())
|
||||
.unwrap_or(0),
|
||||
"Channel capabilities loaded"
|
||||
);
|
||||
|
||||
let config = cap_file.config_json();
|
||||
let desc = cap_file.description.clone();
|
||||
|
||||
|
||||
@@ -99,4 +99,4 @@ pub use router::{
|
||||
};
|
||||
pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||
pub use schema::{ChannelCapabilitiesFile, ChannelConfig};
|
||||
pub use wrapper::{HttpResponse, WasmChannel};
|
||||
pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel};
|
||||
|
||||
+65
-12
@@ -180,7 +180,6 @@ async fn health_handler(State(state): State<RouterState>) -> impl IntoResponse {
|
||||
}
|
||||
|
||||
/// Generic webhook handler that routes to the appropriate WASM channel.
|
||||
#[allow(dead_code)]
|
||||
async fn webhook_handler(
|
||||
State(state): State<RouterState>,
|
||||
method: Method,
|
||||
@@ -191,10 +190,21 @@ async fn webhook_handler(
|
||||
) -> impl IntoResponse {
|
||||
let full_path = format!("/webhook/{}", path);
|
||||
|
||||
tracing::info!(
|
||||
method = %method,
|
||||
path = %full_path,
|
||||
body_len = body.len(),
|
||||
"Webhook request received"
|
||||
);
|
||||
|
||||
// Find the channel for this path
|
||||
let channel = match state.router.get_channel_for_path(&full_path).await {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
path = %full_path,
|
||||
"No channel registered for webhook path"
|
||||
);
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
@@ -205,21 +215,48 @@ async fn webhook_handler(
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel.channel_name(),
|
||||
"Found channel for webhook"
|
||||
);
|
||||
|
||||
let channel_name = channel.channel_name();
|
||||
|
||||
// Check if secret is required
|
||||
if state.router.requires_secret(channel_name).await {
|
||||
// Try to get secret from query param or header
|
||||
let provided_secret = query.get("secret").cloned().or_else(|| {
|
||||
headers
|
||||
.get("X-Webhook-Secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
// Telegram uses X-Telegram-Bot-Api-Secret-Token header
|
||||
let provided_secret = query
|
||||
.get("secret")
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("X-Telegram-Bot-Api-Secret-Token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to generic header
|
||||
headers
|
||||
.get("X-Webhook-Secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
channel = %channel_name,
|
||||
has_provided_secret = provided_secret.is_some(),
|
||||
provided_secret_len = provided_secret.as_ref().map(|s| s.len()),
|
||||
"Checking webhook secret"
|
||||
);
|
||||
|
||||
match provided_secret {
|
||||
Some(secret) => {
|
||||
if !state.router.validate_secret(channel_name, &secret).await {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
"Webhook secret validation failed"
|
||||
);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
@@ -227,8 +264,13 @@ async fn webhook_handler(
|
||||
})),
|
||||
);
|
||||
}
|
||||
tracing::debug!(channel = %channel_name, "Webhook secret validated");
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
"Webhook secret required but not provided"
|
||||
);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
@@ -251,6 +293,13 @@ async fn webhook_handler(
|
||||
|
||||
// Call the WASM channel
|
||||
let secret_validated = state.router.requires_secret(channel_name).await;
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
secret_validated = secret_validated,
|
||||
"Calling WASM channel on_http_request"
|
||||
);
|
||||
|
||||
match channel
|
||||
.call_on_http_request(
|
||||
method.as_str(),
|
||||
@@ -266,6 +315,13 @@ async fn webhook_handler(
|
||||
let status =
|
||||
StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
status = %status,
|
||||
body_len = response.body.len(),
|
||||
"WASM channel on_http_request completed successfully"
|
||||
);
|
||||
|
||||
// Build response with headers
|
||||
let body_json: serde_json::Value = serde_json::from_slice(&response.body)
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -296,25 +352,22 @@ async fn webhook_handler(
|
||||
/// Create an Axum router for WASM channel webhooks.
|
||||
///
|
||||
/// This router can be merged with the existing HTTP channel router.
|
||||
#[allow(dead_code)]
|
||||
pub fn create_wasm_channel_router(router: Arc<WasmChannelRouter>) -> Router {
|
||||
let state = RouterState::new(router);
|
||||
|
||||
Router::new()
|
||||
.route("/wasm-channels/health", get(health_handler))
|
||||
// Catch-all for webhook paths
|
||||
.route("/webhook/*path", get(webhook_handler))
|
||||
.route("/webhook/*path", post(webhook_handler))
|
||||
.route("/webhook/{*path}", get(webhook_handler))
|
||||
.route("/webhook/{*path}", post(webhook_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// HTTP server for WASM channel webhooks.
|
||||
#[allow(dead_code)]
|
||||
pub struct WasmChannelServer {
|
||||
router: Arc<WasmChannelRouter>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl WasmChannelServer {
|
||||
/// Create a new server.
|
||||
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
|
||||
|
||||
@@ -101,8 +101,10 @@ impl ChannelCapabilitiesFile {
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ChannelCapabilitiesSchema {
|
||||
/// Tool capabilities (HTTP, secrets, workspace_read).
|
||||
/// Note: Using the struct directly (not Option) because #[serde(flatten)]
|
||||
/// with Option<T> doesn't work correctly when T has all-optional fields.
|
||||
#[serde(flatten)]
|
||||
pub tool: Option<ToolCapabilitiesFile>,
|
||||
pub tool: ToolCapabilitiesFile,
|
||||
|
||||
/// Channel-specific capabilities.
|
||||
#[serde(default)]
|
||||
@@ -112,11 +114,7 @@ pub struct ChannelCapabilitiesSchema {
|
||||
impl ChannelCapabilitiesSchema {
|
||||
/// Convert to runtime ChannelCapabilities.
|
||||
pub fn to_channel_capabilities(&self, channel_name: &str) -> ChannelCapabilities {
|
||||
let tool_caps = self
|
||||
.tool
|
||||
.as_ref()
|
||||
.map(|t| t.to_capabilities())
|
||||
.unwrap_or_default();
|
||||
let tool_caps = self.tool.to_capabilities();
|
||||
|
||||
let mut caps =
|
||||
ChannelCapabilities::for_channel(channel_name).with_tool_capabilities(tool_caps);
|
||||
|
||||
+752
-465
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user