Add owner-only access control for Telegram bot

Restrict the bot so only the configured owner_id can interact with it.
Non-owner messages are silently dropped with a debug log, keeping the
bot invisible to strangers. Owner ID is persisted to workspace storage
in on_start so stateless WASM callbacks can read it.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-05 21:52:35 -08:00
co-authored by Claude Opus 4.6
parent ae3c86a7ea
commit 48ab73574e
2 changed files with 89 additions and 5 deletions
+88 -5
View File
@@ -157,6 +157,9 @@ struct SentMessage {
/// Workspace path for storing polling state.
const POLLING_STATE_PATH: &str = "state/last_update_id";
/// Workspace path for persisting owner_id across WASM callbacks.
const OWNER_ID_PATH: &str = "state/owner_id";
// ============================================================================
// Channel Metadata
// ============================================================================
@@ -188,6 +191,11 @@ struct TelegramConfig {
#[serde(default)]
bot_username: Option<String>,
/// Telegram user ID of the bot owner. When set, only messages from this
/// user are processed. All others are silently dropped.
#[serde(default)]
owner_id: Option<i64>,
/// Whether to respond to all group messages (not just mentions).
#[serde(default)]
respond_to_all_group_messages: bool,
@@ -228,6 +236,27 @@ impl Guest for TelegramChannel {
);
}
// Persist owner_id so subsequent callbacks (on_http_request, on_poll) can read it
if let Some(owner_id) = config.owner_id {
if let Err(e) = channel_host::workspace_write(OWNER_ID_PATH, &owner_id.to_string()) {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to persist owner_id: {}", e),
);
}
channel_host::log(
channel_host::LogLevel::Info,
&format!("Owner restriction enabled: user {}", owner_id),
);
} else {
// Clear any stale owner_id from a previous config
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
channel_host::log(
channel_host::LogLevel::Warn,
"No owner_id configured, bot is open to all users",
);
}
// Mode is determined by whether the host injected a tunnel_url
// If tunnel is configured, use webhooks. Otherwise, use polling.
let webhook_mode = config.tunnel_url.is_some();
@@ -584,14 +613,15 @@ fn delete_webhook() -> Result<(), String> {
return Err(format!("HTTP {}: {}", response.status, body_str));
}
let api_response: TelegramApiResponse<bool> =
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse response: {}", e))?;
let api_response: TelegramApiResponse<bool> = serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse response: {}", e))?;
if !api_response.ok {
return Err(format!(
"Telegram API error: {}",
api_response.description.unwrap_or_else(|| "unknown".to_string())
api_response
.description
.unwrap_or_else(|| "unknown".to_string())
));
}
@@ -622,7 +652,8 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
body["secret_token"] = serde_json::Value::String(secret.to_string());
}
let body_bytes = serde_json::to_vec(&body).map_err(|e| format!("Failed to serialize body: {}", e))?;
let body_bytes =
serde_json::to_vec(&body).map_err(|e| format!("Failed to serialize body: {}", e))?;
let headers = serde_json::json!({
"Content-Type": "application/json"
@@ -705,6 +736,24 @@ fn handle_message(message: TelegramMessage) {
return;
}
// Owner validation: silently drop messages from non-owner users
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
if !owner_id_str.is_empty() {
if let Ok(owner_id) = owner_id_str.parse::<i64>() {
if from.id != owner_id {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Dropping message from non-owner user {} (owner: {})",
from.id, owner_id
),
);
return;
}
}
}
}
let is_private = message.chat.chat_type == "private";
// For group chats, check if the bot was mentioned
@@ -831,6 +880,40 @@ mod tests {
assert_eq!(clean_message_text(" spaced "), "spaced");
}
#[test]
fn test_config_with_owner_id() {
let json = r#"{"owner_id": 123456789}"#;
let config: TelegramConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.owner_id, Some(123456789));
}
#[test]
fn test_config_without_owner_id() {
let json = r#"{}"#;
let config: TelegramConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.owner_id, None);
}
#[test]
fn test_config_with_null_owner_id() {
let json = r#"{"owner_id": null}"#;
let config: TelegramConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.owner_id, None);
}
#[test]
fn test_config_full() {
let json = r#"{
"bot_username": "my_bot",
"owner_id": 42,
"respond_to_all_group_messages": true
}"#;
let config: TelegramConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.bot_username, Some("my_bot".to_string()));
assert_eq!(config.owner_id, Some(42));
assert!(config.respond_to_all_group_messages);
}
#[test]
fn test_parse_update() {
let json = r#"{
@@ -35,6 +35,7 @@
},
"config": {
"bot_username": null,
"owner_id": null,
"respond_to_all_group_messages": false,
"polling_enabled": false,
"poll_interval_ms": 30000