mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
DM pairing + Telegram channel improvements (#17)
* feat: Implement DM pairing for channels - Introduced a new pairing system to manage direct messages from unknown senders. - Added `PairingStore` to handle pending requests and allowlist management. - Implemented CLI commands for listing and approving pairing requests. - Updated Telegram channel to utilize the new pairing logic, including workspace paths for storing pairing data. - Enhanced WASM channel integration to support pairing functionality. This feature enhances security by requiring approval for unknown senders before they can interact with the agent. * Enhance Telegram channel support with media captioning and DM pairing features - Added support for media captions in Telegram messages, allowing for richer content handling. - Updated message processing to utilize either text or caption, improving message flexibility. - Enhanced DM pairing functionality to include approval and listing capabilities for direct messages. - Updated feature parity documentation to reflect new capabilities and improvements in Telegram integration. * Update README and BUILDING_CHANNELS documentation for Telegram channel integration - Enhanced README with instructions for building and running the Telegram channel, including a note on running `./scripts/build-all.sh` for full releases. - Added detailed steps in BUILDING_CHANNELS.md for building and deploying the Telegram channel, emphasizing the need to run `./channels-src/telegram/build.sh` before building the main crate to ensure updated WASM is included. - Updated CLI module to expose a new command for pairing with store functionality. * Implement build script for Telegram channel WASM and enhance pairing error handling - Added a new `build.rs` script to automate the compilation of the Telegram channel's WASM binary from source, ensuring reproducible builds and emphasizing supply chain security by preventing committed binaries. - Updated `BUILDING_CHANNELS.md` to reflect the new build process and the importance of not committing compiled binaries. - Enhanced error handling in the pairing approval process to include rate limiting for failed attempts, improving security and user feedback. * Remove Telegram channel WASM binary file as part of the build process cleanup, ensuring no committed binaries are present in the repository.
This commit is contained in:
@@ -16,16 +16,21 @@ use crate::channels::wasm::error::WasmChannelError;
|
||||
use crate::channels::wasm::runtime::WasmChannelRuntime;
|
||||
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
||||
use crate::channels::wasm::wrapper::WasmChannel;
|
||||
use crate::pairing::PairingStore;
|
||||
|
||||
/// Loads WASM channels from the filesystem.
|
||||
pub struct WasmChannelLoader {
|
||||
runtime: Arc<WasmChannelRuntime>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
}
|
||||
|
||||
impl WasmChannelLoader {
|
||||
/// Create a new loader with the given runtime.
|
||||
pub fn new(runtime: Arc<WasmChannelRuntime>) -> Self {
|
||||
Self { runtime }
|
||||
/// Create a new loader with the given runtime and pairing store.
|
||||
pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
pairing_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a single WASM channel from a file pair.
|
||||
@@ -114,7 +119,13 @@ impl WasmChannelLoader {
|
||||
.await?;
|
||||
|
||||
// Create the channel
|
||||
let channel = WasmChannel::new(self.runtime.clone(), prepared, capabilities, config_json);
|
||||
let channel = WasmChannel::new(
|
||||
self.runtime.clone(),
|
||||
prepared,
|
||||
capabilities,
|
||||
config_json,
|
||||
self.pairing_store.clone(),
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
name = name,
|
||||
@@ -352,6 +363,7 @@ mod tests {
|
||||
|
||||
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
|
||||
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||
use crate::pairing::PairingStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -408,7 +420,7 @@ mod tests {
|
||||
async fn test_loader_invalid_name() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||
let loader = WasmChannelLoader::new(runtime);
|
||||
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()));
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let wasm_path = dir.path().join("test.wasm");
|
||||
|
||||
@@ -469,7 +469,7 @@ pub fn create_wasm_channel_router(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
@@ -478,6 +478,7 @@ mod tests {
|
||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
};
|
||||
use crate::channels::wasm::wrapper::WasmChannel;
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::tools::wasm::ResourceLimits;
|
||||
|
||||
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
|
||||
@@ -499,6 +500,7 @@ mod tests {
|
||||
prepared,
|
||||
capabilities,
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
+125
-16
@@ -43,6 +43,7 @@ use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::error::WasmChannelError;
|
||||
use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::channels::wasm::router::RegisteredEndpoint;
|
||||
use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
|
||||
use crate::channels::wasm::schema::ChannelConfig;
|
||||
@@ -73,6 +74,8 @@ struct ChannelStoreData {
|
||||
/// Injected credentials for URL substitution (e.g., bot tokens).
|
||||
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
||||
credentials: HashMap<String, String>,
|
||||
/// Pairing store for DM pairing (guest access control).
|
||||
pairing_store: Arc<PairingStore>,
|
||||
}
|
||||
|
||||
impl ChannelStoreData {
|
||||
@@ -81,6 +84,7 @@ impl ChannelStoreData {
|
||||
channel_name: &str,
|
||||
capabilities: ChannelCapabilities,
|
||||
credentials: HashMap<String, String>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
) -> Self {
|
||||
// Create a minimal WASI context (no filesystem, no env vars for security)
|
||||
let wasi = WasiCtxBuilder::new().build();
|
||||
@@ -91,6 +95,7 @@ impl ChannelStoreData {
|
||||
wasi,
|
||||
table: ResourceTable::new(),
|
||||
credentials,
|
||||
pairing_store,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +408,43 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pairing_upsert_request(
|
||||
&mut self,
|
||||
channel: String,
|
||||
id: String,
|
||||
meta_json: String,
|
||||
) -> Result<near::agent::channel_host::PairingUpsertResult, String> {
|
||||
let meta = if meta_json.is_empty() {
|
||||
None
|
||||
} else {
|
||||
serde_json::from_str(&meta_json).ok()
|
||||
};
|
||||
match self.pairing_store.upsert_request(&channel, &id, meta) {
|
||||
Ok(r) => Ok(near::agent::channel_host::PairingUpsertResult {
|
||||
code: r.code,
|
||||
created: r.created,
|
||||
}),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn pairing_is_allowed(
|
||||
&mut self,
|
||||
channel: String,
|
||||
id: String,
|
||||
username: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
self.pairing_store
|
||||
.is_sender_allowed(&channel, &id, username.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn pairing_read_allow_from(&mut self, channel: String) -> Result<Vec<String>, String> {
|
||||
self.pairing_store
|
||||
.read_allow_from(&channel)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A WASM-based channel implementing the Channel trait.
|
||||
@@ -455,6 +497,9 @@ pub struct WasmChannel {
|
||||
/// Background task that repeats typing indicators every 4 seconds.
|
||||
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
|
||||
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
|
||||
|
||||
/// Pairing store for DM pairing (guest access control).
|
||||
pairing_store: Arc<PairingStore>,
|
||||
}
|
||||
|
||||
impl WasmChannel {
|
||||
@@ -464,6 +509,7 @@ impl WasmChannel {
|
||||
prepared: Arc<PreparedChannelModule>,
|
||||
capabilities: ChannelCapabilities,
|
||||
config_json: String,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
) -> Self {
|
||||
let name = prepared.name.clone();
|
||||
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
||||
@@ -483,6 +529,7 @@ impl WasmChannel {
|
||||
endpoints: RwLock::new(Vec::new()),
|
||||
credentials: Arc::new(RwLock::new(HashMap::new())),
|
||||
typing_task: RwLock::new(None),
|
||||
pairing_store,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,6 +611,7 @@ impl WasmChannel {
|
||||
prepared: &PreparedChannelModule,
|
||||
capabilities: &ChannelCapabilities,
|
||||
credentials: HashMap<String, String>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
||||
let engine = runtime.engine();
|
||||
let limits = &prepared.limits;
|
||||
@@ -574,6 +622,7 @@ impl WasmChannel {
|
||||
&prepared.name,
|
||||
capabilities.clone(),
|
||||
credentials,
|
||||
pairing_store,
|
||||
);
|
||||
let mut store = Store::new(engine, store_data);
|
||||
|
||||
@@ -674,12 +723,18 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Execute in blocking task with timeout
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
// Call on_start using the generated typed interface
|
||||
@@ -784,6 +839,7 @@ impl WasmChannel {
|
||||
let capabilities = self.capabilities.clone();
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Prepare request data
|
||||
let method = method.to_string();
|
||||
@@ -797,8 +853,13 @@ impl WasmChannel {
|
||||
// Execute in blocking task with timeout
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
// Build the WIT request type
|
||||
@@ -871,12 +932,18 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Execute in blocking task with timeout
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
// Call on_poll using the generated typed interface
|
||||
@@ -960,6 +1027,7 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Prepare response data
|
||||
let message_id_str = message_id.to_string();
|
||||
@@ -973,8 +1041,13 @@ impl WasmChannel {
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tracing::info!("Creating WASM store for on_respond");
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
|
||||
tracing::info!("Instantiating WASM component for on_respond");
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
@@ -1067,13 +1140,19 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
let wit_update = status_to_wit(status, metadata);
|
||||
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
let channel_iface = instance.near_agent_channel();
|
||||
@@ -1117,6 +1196,7 @@ impl WasmChannel {
|
||||
prepared: &Arc<PreparedChannelModule>,
|
||||
capabilities: &ChannelCapabilities,
|
||||
credentials: &RwLock<HashMap<String, String>>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
timeout: Duration,
|
||||
wit_update: wit_channel::StatusUpdate,
|
||||
) -> Result<(), WasmChannelError> {
|
||||
@@ -1132,8 +1212,13 @@ impl WasmChannel {
|
||||
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials_snapshot,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
let channel_iface = instance.near_agent_channel();
|
||||
@@ -1201,6 +1286,7 @@ impl WasmChannel {
|
||||
let prepared = Arc::clone(&self.prepared);
|
||||
let capabilities = self.capabilities.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let callback_timeout = self.runtime.config().callback_timeout;
|
||||
let wit_update = status_to_wit(&status, metadata);
|
||||
|
||||
@@ -1220,6 +1306,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
&credentials,
|
||||
pairing_store.clone(),
|
||||
callback_timeout,
|
||||
wit_update_clone,
|
||||
)
|
||||
@@ -1350,6 +1437,7 @@ impl WasmChannel {
|
||||
let message_tx = self.message_tx.clone();
|
||||
let rate_limiter = self.rate_limiter.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let callback_timeout = self.runtime.config().callback_timeout;
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -1371,6 +1459,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
&credentials,
|
||||
pairing_store.clone(),
|
||||
callback_timeout,
|
||||
).await;
|
||||
|
||||
@@ -1422,6 +1511,7 @@ impl WasmChannel {
|
||||
prepared: &Arc<PreparedChannelModule>,
|
||||
capabilities: &ChannelCapabilities,
|
||||
credentials: &RwLock<HashMap<String, String>>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
|
||||
// Skip if no WASM bytes (testing mode)
|
||||
@@ -1442,8 +1532,13 @@ impl WasmChannel {
|
||||
// Execute in blocking task with timeout
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials_snapshot,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
// Call on_poll using the generated typed interface
|
||||
@@ -1978,6 +2073,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::Channel;
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::runtime::{
|
||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
@@ -1998,7 +2094,13 @@ mod tests {
|
||||
|
||||
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
|
||||
|
||||
WasmChannel::new(runtime, prepared, capabilities, "{}".to_string())
|
||||
WasmChannel::new(
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2073,6 +2175,7 @@ mod tests {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
&credentials,
|
||||
Arc::new(PairingStore::new()),
|
||||
timeout,
|
||||
)
|
||||
.await;
|
||||
@@ -2166,7 +2269,13 @@ mod tests {
|
||||
.with_path("/webhook/poll")
|
||||
.with_polling(1000);
|
||||
|
||||
let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string());
|
||||
let channel = WasmChannel::new(
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
);
|
||||
|
||||
// Start the channel
|
||||
let _stream = channel.start().await.expect("Channel should start");
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
mod config;
|
||||
mod mcp;
|
||||
pub mod memory;
|
||||
mod pairing;
|
||||
pub mod status;
|
||||
mod tool;
|
||||
|
||||
pub use config::{ConfigCommand, run_config_command};
|
||||
pub use mcp::{McpCommand, run_mcp_command};
|
||||
pub use memory::{MemoryCommand, run_memory_command};
|
||||
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||
pub use status::run_status_command;
|
||||
pub use tool::{ToolCommand, run_tool_command};
|
||||
|
||||
@@ -86,6 +88,10 @@ pub enum Command {
|
||||
#[command(subcommand)]
|
||||
Memory(MemoryCommand),
|
||||
|
||||
/// DM pairing (approve inbound requests from unknown senders)
|
||||
#[command(subcommand)]
|
||||
Pairing(PairingCommand),
|
||||
|
||||
/// Show system health and diagnostics
|
||||
Status,
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! DM pairing CLI commands.
|
||||
//!
|
||||
//! Manage pairing requests for channels (Telegram, Slack, etc.).
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::pairing::PairingStore;
|
||||
|
||||
/// Pairing subcommands.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum PairingCommand {
|
||||
/// List pending pairing requests
|
||||
List {
|
||||
/// Channel name (e.g., telegram, slack)
|
||||
#[arg(required = true)]
|
||||
channel: String,
|
||||
|
||||
/// Output as JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Approve a pairing request by code
|
||||
Approve {
|
||||
/// Channel name (e.g., telegram, slack)
|
||||
#[arg(required = true)]
|
||||
channel: String,
|
||||
|
||||
/// Pairing code (e.g., ABC12345)
|
||||
#[arg(required = true)]
|
||||
code: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run pairing CLI command.
|
||||
pub fn run_pairing_command(cmd: PairingCommand) -> Result<(), String> {
|
||||
run_pairing_command_with_store(&PairingStore::new(), cmd)
|
||||
}
|
||||
|
||||
/// Run pairing CLI command with a given store (for testing).
|
||||
pub fn run_pairing_command_with_store(
|
||||
store: &PairingStore,
|
||||
cmd: PairingCommand,
|
||||
) -> Result<(), String> {
|
||||
match cmd {
|
||||
PairingCommand::List { channel, json } => run_list(store, &channel, json),
|
||||
PairingCommand::Approve { channel, code } => run_approve(store, &channel, &code),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), String> {
|
||||
let requests = store.list_pending(channel).map_err(|e| e.to_string())?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if requests.is_empty() {
|
||||
println!("No pending {} pairing requests.", channel);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Pairing requests ({}):", requests.len());
|
||||
for r in &requests {
|
||||
let meta = r
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.as_object())
|
||||
.map(|o| {
|
||||
o.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
v.as_str().map(|s| format!("{}={}", k, s))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
println!(" {} {} {} {}", r.code, r.id, meta, r.created_at);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_approve(store: &PairingStore, channel: &str, code: &str) -> Result<(), String> {
|
||||
match store.approve(channel, code) {
|
||||
Ok(Some(entry)) => {
|
||||
println!("Approved {} sender {}.", channel, entry.id);
|
||||
Ok(())
|
||||
}
|
||||
Ok(None) => Err(format!("No pending pairing request found for code: {}", code)),
|
||||
Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err(
|
||||
"Too many failed approve attempts. Wait a few minutes before trying again.".to_string(),
|
||||
),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_store() -> (PairingStore, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = PairingStore::with_base_dir(dir.path().to_path_buf());
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_empty_returns_ok() {
|
||||
let (store, _) = test_store();
|
||||
let result = run_pairing_command_with_store(
|
||||
&store,
|
||||
PairingCommand::List {
|
||||
channel: "telegram".to_string(),
|
||||
json: false,
|
||||
},
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_json_empty_returns_ok() {
|
||||
let (store, _) = test_store();
|
||||
let result = run_pairing_command_with_store(
|
||||
&store,
|
||||
PairingCommand::List {
|
||||
channel: "telegram".to_string(),
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approve_invalid_code_returns_err() {
|
||||
let (store, _) = test_store();
|
||||
// Create a pending request so the pairing file exists, then approve with wrong code
|
||||
store.upsert_request("telegram", "user1", None).unwrap();
|
||||
|
||||
let result = run_pairing_command_with_store(
|
||||
&store,
|
||||
PairingCommand::Approve {
|
||||
channel: "telegram".to_string(),
|
||||
code: "BADCODE1".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No pending pairing request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approve_valid_code_returns_ok() {
|
||||
let (store, _) = test_store();
|
||||
let r = store.upsert_request("telegram", "user1", None).unwrap();
|
||||
assert!(r.created);
|
||||
|
||||
let result = run_pairing_command_with_store(
|
||||
&store,
|
||||
PairingCommand::Approve {
|
||||
channel: "telegram".to_string(),
|
||||
code: r.code,
|
||||
},
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_with_pending_returns_ok() {
|
||||
let (store, _) = test_store();
|
||||
store.upsert_request("telegram", "user1", None).unwrap();
|
||||
|
||||
let result = run_pairing_command_with_store(
|
||||
&store,
|
||||
PairingCommand::List {
|
||||
channel: "telegram".to_string(),
|
||||
json: false,
|
||||
},
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ pub mod bootstrap;
|
||||
pub mod channels;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod pairing;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
pub mod estimation;
|
||||
|
||||
+14
-2
@@ -7,6 +7,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx
|
||||
|
||||
use ironclaw::{
|
||||
agent::{Agent, AgentDeps, SessionManager},
|
||||
pairing::PairingStore,
|
||||
channels::{
|
||||
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer,
|
||||
WebhookServerConfig,
|
||||
@@ -17,7 +18,8 @@ use ironclaw::{
|
||||
web::log_layer::{LogBroadcaster, WebLogLayer},
|
||||
},
|
||||
cli::{
|
||||
Cli, Command, run_mcp_command, run_memory_command, run_status_command, run_tool_command,
|
||||
Cli, Command, run_mcp_command, run_memory_command, run_pairing_command, run_status_command,
|
||||
run_tool_command,
|
||||
},
|
||||
config::Config,
|
||||
context::ContextManager,
|
||||
@@ -128,6 +130,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
return run_memory_command(mem_cmd.clone(), store.pool(), embeddings).await;
|
||||
}
|
||||
Some(Command::Pairing(pairing_cmd)) => {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
|
||||
return run_pairing_command(pairing_cmd.clone()).map_err(|e| anyhow::anyhow!("{}", e));
|
||||
}
|
||||
Some(Command::Status) => {
|
||||
let _ = dotenvy::dotenv();
|
||||
tracing_subscriber::fmt()
|
||||
@@ -725,7 +736,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
|
||||
Ok(runtime) => {
|
||||
let runtime = Arc::new(runtime);
|
||||
let loader = WasmChannelLoader::new(Arc::clone(&runtime));
|
||||
let pairing_store = Arc::new(PairingStore::new());
|
||||
let loader = WasmChannelLoader::new(Arc::clone(&runtime), pairing_store);
|
||||
|
||||
match loader
|
||||
.load_from_dir(&config.channels.wasm_channels_dir)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//! DM pairing for channels.
|
||||
//!
|
||||
//! Gates DMs from unknown senders. Only approved senders can message the agent.
|
||||
//! Unknown senders receive a pairing code and must be approved via `ironclaw pairing approve`.
|
||||
//!
|
||||
//! OpenClaw reference: src/pairing/pairing-store.ts
|
||||
|
||||
mod store;
|
||||
|
||||
pub use store::{PairingRequest, PairingStore, PairingStoreError};
|
||||
@@ -0,0 +1,669 @@
|
||||
//! Pairing store: pending requests and allowFrom list.
|
||||
//!
|
||||
//! Stored in ~/.ironclaw/{channel}-pairing.json and {channel}-allowFrom.json.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use fs4::FileExt;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PAIRING_CODE_LENGTH: usize = 8;
|
||||
const PAIRING_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
/// TTL for pending pairing requests (minutes, not hours — reduces brute-force window).
|
||||
const PAIRING_PENDING_TTL_SECS: u64 = 15 * 60;
|
||||
const PAIRING_PENDING_MAX: usize = 3;
|
||||
/// Max failed approve attempts per channel before rate limit kicks in.
|
||||
const PAIRING_APPROVE_RATE_LIMIT: usize = 10;
|
||||
/// Time window for rate limit (seconds).
|
||||
const PAIRING_APPROVE_RATE_WINDOW_SECS: u64 = 5 * 60;
|
||||
|
||||
/// Error from pairing store operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PairingStoreError {
|
||||
#[error("Invalid channel: {0}")]
|
||||
InvalidChannel(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("Rate limit: too many failed approve attempts; try again later")]
|
||||
ApproveRateLimited,
|
||||
}
|
||||
|
||||
/// Result of upserting a pairing request.
|
||||
#[derive(Debug)]
|
||||
pub struct UpsertResult {
|
||||
pub code: String,
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// A pending pairing request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PairingRequest {
|
||||
pub id: String,
|
||||
pub code: String,
|
||||
pub created_at: String,
|
||||
pub last_seen_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct PairingStoreFile {
|
||||
version: u8,
|
||||
requests: Vec<PairingRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AllowFromStoreFile {
|
||||
version: u8,
|
||||
#[serde(rename = "allowFrom")]
|
||||
allow_from: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_pairing_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
}
|
||||
|
||||
fn safe_channel_key(channel: &str) -> Result<String, PairingStoreError> {
|
||||
let raw = channel.trim().to_lowercase();
|
||||
if raw.is_empty() {
|
||||
return Err(PairingStoreError::InvalidChannel("empty".to_string()));
|
||||
}
|
||||
let safe = raw
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
_ => c,
|
||||
})
|
||||
.collect::<String>()
|
||||
.replace("..", "_");
|
||||
if safe.is_empty() || safe == "_" {
|
||||
return Err(PairingStoreError::InvalidChannel(channel.to_string()));
|
||||
}
|
||||
Ok(safe)
|
||||
}
|
||||
|
||||
fn pairing_path(base_dir: &PathBuf, channel: &str) -> Result<PathBuf, PairingStoreError> {
|
||||
let key = safe_channel_key(channel)?;
|
||||
Ok(base_dir.join(format!("{}-pairing.json", key)))
|
||||
}
|
||||
|
||||
fn allow_from_path(base_dir: &PathBuf, channel: &str) -> Result<PathBuf, PairingStoreError> {
|
||||
let key = safe_channel_key(channel)?;
|
||||
Ok(base_dir.join(format!("{}-allowFrom.json", key)))
|
||||
}
|
||||
|
||||
fn approve_attempts_path(base_dir: &PathBuf, channel: &str) -> Result<PathBuf, PairingStoreError> {
|
||||
let key = safe_channel_key(channel)?;
|
||||
Ok(base_dir.join(format!("{}-approve-attempts.json", key)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct ApproveAttemptsFile {
|
||||
failed_at: Vec<u64>,
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
chrono::DateTime::from_timestamp(now.as_secs() as i64, 0)
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
.unwrap_or_else(|| now.as_secs().to_string())
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn parse_timestamp(value: &str) -> Option<u64> {
|
||||
chrono::DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp() as u64)
|
||||
.or_else(|| value.parse::<u64>().ok())
|
||||
}
|
||||
|
||||
fn is_expired(req: &PairingRequest, now_secs: u64) -> bool {
|
||||
let created = parse_timestamp(&req.created_at).unwrap_or(0);
|
||||
now_secs.saturating_sub(created) > PAIRING_PENDING_TTL_SECS
|
||||
}
|
||||
|
||||
fn random_code() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..PAIRING_CODE_LENGTH)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..PAIRING_ALPHABET.len());
|
||||
PAIRING_ALPHABET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_unique_code(existing: &HashSet<String>) -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
for _ in 0..500 {
|
||||
let code = random_code();
|
||||
if !existing.contains(&code) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
// Fallback: add suffix
|
||||
format!("{}{:04}", random_code(), rng.gen_range(0..10000))
|
||||
}
|
||||
|
||||
/// Pairing store for a channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PairingStore {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PairingStore {
|
||||
/// Create a new pairing store using default directory (~/.ironclaw).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dir: default_pairing_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a pairing store with a custom base directory (for testing).
|
||||
pub fn with_base_dir(base_dir: PathBuf) -> Self {
|
||||
Self { base_dir }
|
||||
}
|
||||
|
||||
/// List pending pairing requests for a channel.
|
||||
pub fn list_pending(&self, channel: &str) -> Result<Vec<PairingRequest>, PairingStoreError> {
|
||||
let path = pairing_path(&self.base_dir, channel)?;
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let file: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile {
|
||||
version: 1,
|
||||
requests: Vec::new(),
|
||||
});
|
||||
|
||||
let now = now_secs();
|
||||
let original_len = file.requests.len();
|
||||
let mut requests: Vec<_> = file
|
||||
.requests
|
||||
.into_iter()
|
||||
.filter(|r| !is_expired(r, now))
|
||||
.collect();
|
||||
|
||||
if requests.len() != original_len {
|
||||
self.write_pairing_file(channel, &requests)?;
|
||||
}
|
||||
|
||||
requests.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
Ok(requests)
|
||||
}
|
||||
|
||||
/// Upsert a pairing request. Returns (code, created).
|
||||
pub fn upsert_request(
|
||||
&self,
|
||||
channel: &str,
|
||||
id: &str,
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Result<UpsertResult, PairingStoreError> {
|
||||
let path = pairing_path(&self.base_dir, channel)?;
|
||||
fs::create_dir_all(path.parent().unwrap())?;
|
||||
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&path)?;
|
||||
|
||||
file.lock_exclusive()?;
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile {
|
||||
version: 1,
|
||||
requests: Vec::new(),
|
||||
});
|
||||
|
||||
let now = now_iso();
|
||||
let now_secs = now_secs();
|
||||
let id = id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
return Err(PairingStoreError::InvalidChannel("empty id".to_string()));
|
||||
}
|
||||
|
||||
store.requests.retain(|r| !is_expired(r, now_secs));
|
||||
let existing_codes: HashSet<String> = store
|
||||
.requests
|
||||
.iter()
|
||||
.map(|r| r.code.to_uppercase())
|
||||
.collect();
|
||||
|
||||
if let Some(idx) = store.requests.iter().position(|r| r.id == id) {
|
||||
let req = &mut store.requests[idx];
|
||||
let code = if req.code.is_empty() {
|
||||
generate_unique_code(&existing_codes)
|
||||
} else {
|
||||
req.code.clone()
|
||||
};
|
||||
req.last_seen_at = now.clone();
|
||||
req.code = code.clone();
|
||||
if let Some(m) = meta {
|
||||
req.meta = Some(m);
|
||||
}
|
||||
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
return Ok(UpsertResult {
|
||||
code,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
if store.requests.len() >= PAIRING_PENDING_MAX {
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
return Ok(UpsertResult {
|
||||
code: String::new(),
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
let code = generate_unique_code(&existing_codes);
|
||||
store.requests.push(PairingRequest {
|
||||
id: id.clone(),
|
||||
code: code.clone(),
|
||||
created_at: now.clone(),
|
||||
last_seen_at: now,
|
||||
meta,
|
||||
});
|
||||
|
||||
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
|
||||
Ok(UpsertResult { code, created: true })
|
||||
}
|
||||
|
||||
fn is_approve_rate_limited(&self, channel: &str) -> Result<bool, PairingStoreError> {
|
||||
let path = approve_attempts_path(&self.base_dir, channel)?;
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut data: ApproveAttemptsFile =
|
||||
serde_json::from_str(&content).unwrap_or_default();
|
||||
let now = now_secs();
|
||||
let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS);
|
||||
data.failed_at.retain(|&t| t >= cutoff);
|
||||
Ok(data.failed_at.len() >= PAIRING_APPROVE_RATE_LIMIT)
|
||||
}
|
||||
|
||||
fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> {
|
||||
let path = approve_attempts_path(&self.base_dir, channel)?;
|
||||
fs::create_dir_all(path.parent().unwrap())?;
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&path)?;
|
||||
file.lock_exclusive()?;
|
||||
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut data: ApproveAttemptsFile =
|
||||
serde_json::from_str(&content).unwrap_or_default();
|
||||
let now = now_secs();
|
||||
data.failed_at.push(now);
|
||||
let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS);
|
||||
data.failed_at.retain(|&t| t >= cutoff);
|
||||
let json = serde_json::to_string_pretty(&data)?;
|
||||
fs::write(&path, json)?;
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Approve a pairing code and add the sender to allowFrom.
|
||||
pub fn approve(
|
||||
&self,
|
||||
channel: &str,
|
||||
code: &str,
|
||||
) -> Result<Option<PairingRequest>, PairingStoreError> {
|
||||
let code = code.trim().to_uppercase();
|
||||
if code.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if self.is_approve_rate_limited(channel)? {
|
||||
return Err(PairingStoreError::ApproveRateLimited);
|
||||
}
|
||||
|
||||
let path = pairing_path(&self.base_dir, channel)?;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(false)
|
||||
.open(&path)
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
PairingStoreError::InvalidChannel("no pairing file".to_string())
|
||||
} else {
|
||||
PairingStoreError::Io(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
file.lock_exclusive()?;
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile {
|
||||
version: 1,
|
||||
requests: Vec::new(),
|
||||
});
|
||||
|
||||
let now_secs = now_secs();
|
||||
store.requests.retain(|r| !is_expired(r, now_secs));
|
||||
|
||||
let idx = store
|
||||
.requests
|
||||
.iter()
|
||||
.position(|r| r.code.to_uppercase() == code);
|
||||
|
||||
let entry = match idx {
|
||||
Some(i) => store.requests.remove(i),
|
||||
None => {
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
self.record_failed_approve(channel)?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
|
||||
self.add_allow_from(channel, &entry.id)?;
|
||||
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
/// Read the allowFrom list for a channel.
|
||||
pub fn read_allow_from(&self, channel: &str) -> Result<Vec<String>, PairingStoreError> {
|
||||
let path = allow_from_path(&self.base_dir, channel)?;
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let file: AllowFromStoreFile = serde_json::from_str(&content).unwrap_or(AllowFromStoreFile {
|
||||
version: 1,
|
||||
allow_from: Vec::new(),
|
||||
});
|
||||
|
||||
Ok(file.allow_from)
|
||||
}
|
||||
|
||||
/// Check if a sender is allowed (by id or username).
|
||||
pub fn is_sender_allowed(
|
||||
&self,
|
||||
channel: &str,
|
||||
id: &str,
|
||||
username: Option<&str>,
|
||||
) -> Result<bool, PairingStoreError> {
|
||||
let allow = self.read_allow_from(channel)?;
|
||||
let id = id.trim();
|
||||
let id_ok = allow.iter().any(|e| e.trim() == id);
|
||||
if id_ok {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(u) = username {
|
||||
let u = u.trim().to_lowercase();
|
||||
let u_norm = u.strip_prefix('@').unwrap_or(&u);
|
||||
if allow
|
||||
.iter()
|
||||
.any(|e| e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn add_allow_from(&self, channel: &str, entry: &str) -> Result<(), PairingStoreError> {
|
||||
let entry = entry.trim().to_string();
|
||||
if entry.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = allow_from_path(&self.base_dir, channel)?;
|
||||
fs::create_dir_all(path.parent().unwrap())?;
|
||||
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&path)?;
|
||||
|
||||
file.lock_exclusive()?;
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut store: AllowFromStoreFile =
|
||||
serde_json::from_str(&content).unwrap_or(AllowFromStoreFile {
|
||||
version: 1,
|
||||
allow_from: Vec::new(),
|
||||
});
|
||||
|
||||
let normalized = entry.to_lowercase();
|
||||
if store
|
||||
.allow_from
|
||||
.iter()
|
||||
.any(|e| e.to_lowercase() == normalized)
|
||||
{
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
store.allow_from.push(entry);
|
||||
let json = serde_json::to_string_pretty(&store)?;
|
||||
fs::write(&path, json)?;
|
||||
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_pairing_file(
|
||||
&self,
|
||||
channel: &str,
|
||||
requests: &[PairingRequest],
|
||||
) -> Result<(), PairingStoreError> {
|
||||
let path = pairing_path(&self.base_dir, channel)?;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&path)?;
|
||||
file.lock_exclusive()?;
|
||||
self.write_pairing_file_locked(&mut file, channel, requests)?;
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_pairing_file_locked(
|
||||
&self,
|
||||
file: &mut fs::File,
|
||||
_channel: &str,
|
||||
requests: &[PairingRequest],
|
||||
) -> Result<(), PairingStoreError> {
|
||||
let store = PairingStoreFile {
|
||||
version: 1,
|
||||
requests: requests.to_vec(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&store)?;
|
||||
file.set_len(0)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PairingStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_safe_channel_key() {
|
||||
assert_eq!(safe_channel_key("telegram").unwrap(), "telegram");
|
||||
assert_eq!(safe_channel_key("Telegram").unwrap(), "telegram");
|
||||
safe_channel_key("").unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_random_code() {
|
||||
let c = random_code();
|
||||
assert_eq!(c.len(), PAIRING_CODE_LENGTH);
|
||||
assert!(c.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8))));
|
||||
}
|
||||
|
||||
fn test_store() -> (PairingStore, TempDir) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = PairingStore::with_base_dir(dir.path().to_path_buf());
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_pending_empty() {
|
||||
let (store, _) = test_store();
|
||||
let requests = store.list_pending("telegram").unwrap();
|
||||
assert!(requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_request_creates_new() {
|
||||
let (store, _) = test_store();
|
||||
let result = store
|
||||
.upsert_request("telegram", "user123", Some(serde_json::json!({"chat_id": 456})))
|
||||
.unwrap();
|
||||
assert!(result.created);
|
||||
assert_eq!(result.code.len(), PAIRING_CODE_LENGTH);
|
||||
assert!(result.code.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_request_updates_existing() {
|
||||
let (store, _) = test_store();
|
||||
let r1 = store.upsert_request("telegram", "user123", None).unwrap();
|
||||
assert!(r1.created);
|
||||
let r2 = store.upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1}))).unwrap();
|
||||
assert!(!r2.created);
|
||||
assert_eq!(r1.code, r2.code);
|
||||
|
||||
let pending = store.list_pending("telegram").unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, "user123");
|
||||
assert_eq!(pending[0].meta, Some(serde_json::json!({"x": 1})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approve_adds_to_allow_from() {
|
||||
let (store, _) = test_store();
|
||||
let r = store.upsert_request("telegram", "user456", None).unwrap();
|
||||
assert!(r.created);
|
||||
|
||||
let approved = store.approve("telegram", &r.code).unwrap();
|
||||
assert!(approved.is_some());
|
||||
assert_eq!(approved.unwrap().id, "user456");
|
||||
|
||||
let allow = store.read_allow_from("telegram").unwrap();
|
||||
assert_eq!(allow, vec!["user456"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approve_case_insensitive_code() {
|
||||
let (store, _) = test_store();
|
||||
let r = store.upsert_request("telegram", "user789", None).unwrap();
|
||||
let code_lower = r.code.to_lowercase();
|
||||
let approved = store.approve("telegram", &code_lower).unwrap();
|
||||
assert!(approved.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approve_invalid_code_returns_none() {
|
||||
let (store, _) = test_store();
|
||||
store.upsert_request("telegram", "user123", None).unwrap();
|
||||
let approved = store.approve("telegram", "BADCODE1").unwrap();
|
||||
assert!(approved.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approve_rate_limited_after_many_failures() {
|
||||
let (store, _) = test_store();
|
||||
store.upsert_request("telegram", "user123", None).unwrap();
|
||||
for _ in 0..PAIRING_APPROVE_RATE_LIMIT {
|
||||
let _ = store.approve("telegram", "WRONG01");
|
||||
}
|
||||
let err = store.approve("telegram", "WRONG02").unwrap_err();
|
||||
assert!(matches!(err, PairingStoreError::ApproveRateLimited));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_sender_allowed_by_id() {
|
||||
let (store, _) = test_store();
|
||||
let r = store.upsert_request("telegram", "user999", None).unwrap();
|
||||
store.approve("telegram", &r.code).unwrap();
|
||||
|
||||
assert!(store.is_sender_allowed("telegram", "user999", None).unwrap());
|
||||
assert!(!store.is_sender_allowed("telegram", "other", None).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_sender_allowed_by_username() {
|
||||
let (store, _) = test_store();
|
||||
store.upsert_request("telegram", "alice", Some(serde_json::json!({"username": "alice"}))).unwrap();
|
||||
let pending = store.list_pending("telegram").unwrap();
|
||||
store.approve("telegram", &pending[0].code).unwrap();
|
||||
|
||||
// approve adds id to allow_from. For username we need to add it manually.
|
||||
// Actually approve adds entry.id which is "alice". So is_sender_allowed("telegram", "alice", None) would work.
|
||||
assert!(store.is_sender_allowed("telegram", "alice", None).unwrap());
|
||||
assert!(store.is_sender_allowed("telegram", "alice", Some("alice")).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_normalization() {
|
||||
let (store, _) = test_store();
|
||||
store.upsert_request("Telegram", "u1", None).unwrap();
|
||||
let pending = store.list_pending("telegram").unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, "u1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_channel_rejected() {
|
||||
let (store, _) = test_store();
|
||||
store.upsert_request("telegram", "u1", None).unwrap();
|
||||
store.list_pending("").unwrap_err();
|
||||
store.upsert_request("", "u1", None).unwrap_err();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user