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:
Illia Polosukhin
2026-02-04 21:19:42 -08:00
co-authored by Claude Opus 4.5
parent 4ab20ff939
commit 7955c9742e
17 changed files with 1769 additions and 509 deletions
+6
View File
@@ -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 {
+21
View File
@@ -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();
+1 -1
View File
@@ -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
View File
@@ -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 {
+4 -6
View File
@@ -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);
File diff suppressed because it is too large Load Diff