mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: add pairing/permission system to all WASM channels and fix extension registry (#286)
Port Telegram's permission model (owner_id, dm_policy, allow_from, pairing codes)
to Discord, Slack, and WhatsApp WASM channels. Add web UI for configuration and
pairing approval. Fix extension registry issues preventing Discord install and
causing Slack activation to hit the wrong endpoint.
WASM channels:
- Discord: add DiscordConfig, permission checks, ephemeral pairing replies,
fix capabilities.json (header_name→name), downgrade wit-bindgen to 0.36
- Slack: expand SlackConfig with permission fields, add check_sender_permission
and send_pairing_reply via chat.postMessage
- WhatsApp: expand WhatsAppConfig with permission fields, add permission checks
and pairing reply via Cloud API
- Telegram: reformat capabilities.json, add setup.required_secrets
Extension system:
- Add Discord to KNOWN_CHANNELS in bundled.rs and to extension registry
- Rename "slack" MCP→"slack-mcp", "slack-channel"→"slack" to fix name collision
- Add ExtensionSource::Bundled variant handling in discovery.rs
- Add get_setup_schema/save_setup_secrets to ExtensionManager
- Add needs_setup field to InstalledExtension
Web gateway:
- Add GET/POST /api/extensions/{name}/setup for configuration modal
- Add GET /api/pairing/{channel} and POST /api/pairing/{channel}/approve
- Add configure modal UI (password fields, provided badges, auto-generate hints)
- Add pairing request UI on active WASM channel cards
- Show "Restart to activate" label instead of Activate button for WASM channels
Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
48b5323ec9
commit
b3bf50f10e
Generated
+401
@@ -0,0 +1,401 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "discord-channel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "leb128"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "spdx"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
|
||||
dependencies = [
|
||||
"leb128",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"spdx",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
|
||||
dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -9,7 +9,7 @@ publish = false
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.41.0"
|
||||
wit-bindgen = "0.36"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
"type": "channel",
|
||||
"name": "discord",
|
||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "discord_bot_token",
|
||||
"prompt": "Enter your Discord Bot Token (from Developer Portal)",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
@@ -10,7 +19,7 @@
|
||||
"credentials": {
|
||||
"discord_bot_token": {
|
||||
"secret_name": "discord_bot_token",
|
||||
"location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " },
|
||||
"location": { "type": "header", "name": "Authorization", "prefix": "Bot " },
|
||||
"host_patterns": ["discord.com"]
|
||||
}
|
||||
},
|
||||
@@ -34,6 +43,9 @@
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"require_signature_verification": true
|
||||
"require_signature_verification": true,
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
@@ -124,12 +124,57 @@ struct DiscordMessageMetadata {
|
||||
thread_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||
/// Channel name for pairing store (used by pairing host APIs).
|
||||
const CHANNEL_NAME: &str = "discord";
|
||||
|
||||
/// Channel configuration from capabilities file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscordConfig {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
require_signature_verification: bool,
|
||||
#[serde(default)]
|
||||
owner_id: Option<String>,
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
struct DiscordChannel;
|
||||
|
||||
impl Guest for DiscordChannel {
|
||||
fn on_start(_config_json: String) -> Result<ChannelConfig, String> {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
let config: DiscordConfig = serde_json::from_str(&config_json)
|
||||
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
||||
|
||||
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
|
||||
|
||||
// Persist owner_id so subsequent callbacks can read it
|
||||
if let Some(ref owner_id) = config.owner_id {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Owner restriction enabled: user {}", owner_id),
|
||||
);
|
||||
} else {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
||||
}
|
||||
|
||||
// Persist dm_policy and allow_from for DM pairing
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||
|
||||
Ok(ChannelConfig {
|
||||
display_name: "Discord".to_string(),
|
||||
http_endpoints: vec![HttpEndpointConfig {
|
||||
@@ -169,17 +214,22 @@ impl Guest for DiscordChannel {
|
||||
|
||||
// Application Command (slash command)
|
||||
2 => {
|
||||
handle_slash_command(&interaction);
|
||||
if handle_slash_command(&interaction) {
|
||||
json_response(200, serde_json::json!({"type": 5}))
|
||||
} else {
|
||||
// Permission denied — ephemeral response
|
||||
json_response(
|
||||
200,
|
||||
serde_json::json!({
|
||||
"type": 5,
|
||||
"type": 4,
|
||||
"data": {
|
||||
"content": "🤔 Thinking..."
|
||||
"content": "You are not authorized to use this bot.",
|
||||
"flags": 64
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Message Component (buttons, selects)
|
||||
3 => {
|
||||
@@ -270,7 +320,8 @@ impl Guest for DiscordChannel {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_slash_command(interaction: &DiscordInteraction) {
|
||||
/// Returns true if the message was emitted, false if permission denied.
|
||||
fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
|
||||
let user = interaction
|
||||
.member
|
||||
.as_ref()
|
||||
@@ -287,6 +338,22 @@ fn handle_slash_command(interaction: &DiscordInteraction) {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// DM if no guild member context (only direct user field set)
|
||||
let is_dm = interaction.member.is_none();
|
||||
|
||||
// Permission check
|
||||
if !check_sender_permission(
|
||||
&user_id,
|
||||
Some(&user_name),
|
||||
is_dm,
|
||||
Some(&PairingReplyCtx {
|
||||
application_id: interaction.application_id.clone(),
|
||||
token: interaction.token.clone(),
|
||||
}),
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let channel_id = interaction.channel_id.clone().unwrap_or_default();
|
||||
|
||||
let command_name = interaction
|
||||
@@ -322,14 +389,13 @@ fn handle_slash_command(interaction: &DiscordInteraction) {
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize metadata: {}", e),
|
||||
);
|
||||
// Attempt to notify user of internal error
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
interaction.application_id, interaction.token
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"content": "❌ Internal Error: Failed to process command metadata.",
|
||||
"flags": 64 // Ephemeral
|
||||
"flags": 64
|
||||
});
|
||||
let _ = channel_host::http_request(
|
||||
"POST",
|
||||
@@ -338,7 +404,7 @@ fn handle_slash_command(interaction: &DiscordInteraction) {
|
||||
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
return true; // Error, but not a permission denial
|
||||
}
|
||||
};
|
||||
|
||||
@@ -349,10 +415,10 @@ fn handle_slash_command(interaction: &DiscordInteraction) {
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
|
||||
// Check member first (for server contexts), then user (for DMs)
|
||||
let user = interaction
|
||||
.member
|
||||
.as_ref()
|
||||
@@ -369,6 +435,11 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_dm = interaction.member.is_none();
|
||||
if !check_sender_permission(&user_id, Some(&user_name), is_dm, None) {
|
||||
return;
|
||||
}
|
||||
|
||||
let channel_id = message.channel_id.clone();
|
||||
|
||||
let metadata = DiscordMessageMetadata {
|
||||
@@ -399,6 +470,145 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission & Pairing
|
||||
// ============================================================================
|
||||
|
||||
/// Context needed to send a pairing reply via Discord webhook followup.
|
||||
struct PairingReplyCtx {
|
||||
application_id: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Check if a sender is permitted to interact with the bot.
|
||||
/// Returns true if allowed, false if denied (pairing reply sent if applicable).
|
||||
fn check_sender_permission(
|
||||
user_id: &str,
|
||||
username: Option<&str>,
|
||||
is_dm: bool,
|
||||
reply_ctx: Option<&PairingReplyCtx>,
|
||||
) -> bool {
|
||||
// 1. Owner check (highest priority, applies to all contexts)
|
||||
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
if let Some(ref owner) = owner_id {
|
||||
if user_id != owner {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping interaction from non-owner user {} (owner: {})",
|
||||
user_id, owner
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. DM policy (only for DMs when no owner_id)
|
||||
if !is_dm {
|
||||
return true; // Guild interactions bypass DM policy
|
||||
}
|
||||
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy == "open" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Build merged allow list: config allow_from + pairing store
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||
allowed.extend(store_allowed);
|
||||
}
|
||||
|
||||
// 4. Check sender against allow list
|
||||
let is_allowed = allowed.contains(&"*".to_string())
|
||||
|| allowed.contains(&user_id.to_string())
|
||||
|| username.is_some_and(|u| allowed.contains(&u.to_string()));
|
||||
|
||||
if is_allowed {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Not allowed — handle by policy
|
||||
if dm_policy == "pairing" {
|
||||
let meta = serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for user {}: code {}",
|
||||
user_id, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
if let Some(ctx) = reply_ctx {
|
||||
let _ = send_pairing_reply(ctx, &result.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send a pairing code as an ephemeral Discord followup message.
|
||||
fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
ctx.application_id, ctx.token
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"content": format!(
|
||||
"To pair with this bot, run: `ironclaw pairing approve discord {}`",
|
||||
code
|
||||
),
|
||||
"flags": 64 // Ephemeral — only visible to the sender
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
|
||||
Ok(response) => {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
Err(format!(
|
||||
"Discord API error: {} - {}",
|
||||
response.status, body_str
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
"type": "channel",
|
||||
"name": "slack",
|
||||
"description": "Slack Events API channel for receiving and responding to Slack messages",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "slack_bot_token",
|
||||
"prompt": "Enter your Slack Bot OAuth Token (xoxb-...)",
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "slack_signing_secret",
|
||||
"prompt": "Enter your Slack Signing Secret (from App Credentials)",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
@@ -33,6 +47,9 @@
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"signing_secret_name": "slack_signing_secret"
|
||||
"signing_secret_name": "slack_signing_secret",
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,15 +104,31 @@ struct SlackPostMessageResponse {
|
||||
ts: Option<String>,
|
||||
}
|
||||
|
||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||
/// Channel name for pairing store (used by pairing host APIs).
|
||||
const CHANNEL_NAME: &str = "slack";
|
||||
|
||||
/// Channel configuration from capabilities file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SlackConfig {
|
||||
/// Name of secret containing signing secret (for verification by host).
|
||||
/// Parsed from config for forward compatibility; not yet used in WASM
|
||||
/// (host handles signature verification).
|
||||
#[serde(default = "default_signing_secret_name")]
|
||||
#[allow(dead_code)]
|
||||
signing_secret_name: String,
|
||||
|
||||
#[serde(default)]
|
||||
owner_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn default_signing_secret_name() -> String {
|
||||
@@ -123,12 +139,30 @@ struct SlackChannel;
|
||||
|
||||
impl Guest for SlackChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
// Parse configuration
|
||||
let _config: SlackConfig = serde_json::from_str(&config_json)
|
||||
let config: SlackConfig = serde_json::from_str(&config_json)
|
||||
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
||||
|
||||
channel_host::log(channel_host::LogLevel::Info, "Slack channel starting");
|
||||
|
||||
// Persist owner_id so subsequent callbacks can read it
|
||||
if let Some(ref owner_id) = config.owner_id {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Owner restriction enabled: user {}", owner_id),
|
||||
);
|
||||
} else {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
||||
}
|
||||
|
||||
// Persist dm_policy and allow_from for DM pairing
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||
|
||||
Ok(ChannelConfig {
|
||||
display_name: "Slack".to_string(),
|
||||
http_endpoints: vec![HttpEndpointConfig {
|
||||
@@ -136,7 +170,7 @@ impl Guest for SlackChannel {
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: true,
|
||||
}],
|
||||
poll: None, // Slack uses push via webhooks, no polling needed
|
||||
poll: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -280,7 +314,7 @@ impl Guest for SlackChannel {
|
||||
/// Handle a Slack event and emit message if applicable.
|
||||
fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) {
|
||||
match event.event_type.as_str() {
|
||||
// Direct mention of the bot
|
||||
// Direct mention of the bot (always in a channel, not a DM)
|
||||
"app_mention" => {
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||
event.user,
|
||||
@@ -288,6 +322,10 @@ fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Opt
|
||||
event.text,
|
||||
event.ts.clone(),
|
||||
) {
|
||||
// app_mention is always in a channel (not DM)
|
||||
if !check_sender_permission(&user, &channel, false) {
|
||||
return;
|
||||
}
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
@@ -307,6 +345,9 @@ fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Opt
|
||||
) {
|
||||
// Only process DMs (channel IDs starting with D)
|
||||
if channel.starts_with('D') {
|
||||
if !check_sender_permission(&user, &channel, true) {
|
||||
return;
|
||||
}
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
@@ -358,6 +399,126 @@ fn emit_message(
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission & Pairing
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a sender is permitted. Returns true if allowed.
|
||||
/// For pairing mode, sends a pairing code DM if denied.
|
||||
fn check_sender_permission(user_id: &str, channel_id: &str, is_dm: bool) -> bool {
|
||||
// 1. Owner check (highest priority, applies to all contexts)
|
||||
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
if let Some(ref owner) = owner_id {
|
||||
if user_id != owner {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping message from non-owner user {} (owner: {})",
|
||||
user_id, owner
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. DM policy (only for DMs when no owner_id)
|
||||
if !is_dm {
|
||||
return true; // Channel messages bypass DM policy
|
||||
}
|
||||
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy == "open" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Build merged allow list: config allow_from + pairing store
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||
allowed.extend(store_allowed);
|
||||
}
|
||||
|
||||
// 4. Check sender (Slack events only have user ID, not username)
|
||||
let is_allowed =
|
||||
allowed.contains(&"*".to_string()) || allowed.contains(&user_id.to_string());
|
||||
|
||||
if is_allowed {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Not allowed — handle by policy
|
||||
if dm_policy == "pairing" {
|
||||
let meta = serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"channel_id": channel_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for user {}: code {}",
|
||||
user_id, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
let _ = send_pairing_reply(channel_id, &result.code);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send a pairing code message via Slack chat.postMessage.
|
||||
fn send_pairing_reply(channel_id: &str, code: &str) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": format!(
|
||||
"To pair with this bot, run: `ironclaw pairing approve slack {}`",
|
||||
code
|
||||
),
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
"https://slack.com/api/chat.postMessage",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) if response.status == 200 => Ok(()),
|
||||
Ok(response) => {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
Err(format!(
|
||||
"Slack API error: {} - {}",
|
||||
response.status, body_str
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip leading bot mention from text.
|
||||
fn strip_bot_mention(text: &str) -> String {
|
||||
// Slack mentions look like <@U12345678>
|
||||
|
||||
@@ -1 +1,54 @@
|
||||
{"type":"channel","name":"telegram","description":"Telegram Bot API channel for receiving and responding to Telegram messages","capabilities":{"http":{"allowlist":[{"host":"api.telegram.org","path_prefix":"/bot"}],"credentials":{"telegram_bot":{"secret_name":"telegram_bot_token","location":{"type":"url_path","placeholder":"{TELEGRAM_BOT_TOKEN}"},"host_patterns":["api.telegram.org"]}},"rate_limit":{"requests_per_minute":30,"requests_per_hour":1000}},"secrets":{"allowed_names":["telegram_*"]},"channel":{"allowed_paths":["/webhook/telegram"],"allow_polling":true,"min_poll_interval_ms":30000,"workspace_prefix":"channels/telegram/","emit_rate_limit":{"messages_per_minute":100,"messages_per_hour":5000}}},"config":{"bot_username":null,"owner_id":null,"respond_to_all_group_messages":false,"polling_enabled":false,"poll_interval_ms":30000,"dm_policy":"pairing","allow_from":[]}}
|
||||
{
|
||||
"type": "channel",
|
||||
"name": "telegram",
|
||||
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "telegram_bot_token",
|
||||
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "api.telegram.org", "path_prefix": "/bot" }
|
||||
],
|
||||
"credentials": {
|
||||
"telegram_bot": {
|
||||
"secret_name": "telegram_bot_token",
|
||||
"location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" },
|
||||
"host_patterns": ["api.telegram.org"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 30,
|
||||
"requests_per_hour": 1000
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["telegram_*"]
|
||||
},
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/telegram"],
|
||||
"allow_polling": true,
|
||||
"min_poll_interval_ms": 30000,
|
||||
"workspace_prefix": "channels/telegram/",
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"bot_username": null,
|
||||
"owner_id": null,
|
||||
"respond_to_all_group_messages": false,
|
||||
"polling_enabled": false,
|
||||
"poll_interval_ms": 30000,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,15 @@ struct WhatsAppMessageMetadata {
|
||||
timestamp: String,
|
||||
}
|
||||
|
||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||
/// Channel name for pairing store (used by pairing host APIs).
|
||||
const CHANNEL_NAME: &str = "whatsapp";
|
||||
|
||||
/// Channel configuration from capabilities file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WhatsAppConfig {
|
||||
@@ -236,6 +245,15 @@ struct WhatsAppConfig {
|
||||
/// Whether to reply to the original message (thread context)
|
||||
#[serde(default = "default_reply_to_message")]
|
||||
reply_to_message: bool,
|
||||
|
||||
#[serde(default)]
|
||||
owner_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn default_api_version() -> String {
|
||||
@@ -264,6 +282,9 @@ impl Guest for WhatsAppChannel {
|
||||
WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
owner_id: None,
|
||||
dm_policy: None,
|
||||
allow_from: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -279,6 +300,24 @@ impl Guest for WhatsAppChannel {
|
||||
// Persist api_version in workspace so on_respond() can read it
|
||||
let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version);
|
||||
|
||||
// Persist permission config for handle_message
|
||||
if let Some(ref owner_id) = config.owner_id {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Owner restriction enabled: user {}", owner_id),
|
||||
);
|
||||
} else {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
||||
}
|
||||
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||
|
||||
// WhatsApp Cloud API is webhook-only, no polling available
|
||||
Ok(ChannelConfig {
|
||||
display_name: "WhatsApp".to_string(),
|
||||
@@ -604,6 +643,15 @@ fn handle_message(
|
||||
// Look up sender's name from contacts
|
||||
let user_name = contact_names.get(&message.from).cloned();
|
||||
|
||||
// Permission check (WhatsApp is always DM)
|
||||
if !check_sender_permission(
|
||||
&message.from,
|
||||
user_name.as_deref(),
|
||||
phone_number_id,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build metadata for response routing
|
||||
// This is critical - the response handler uses this to know where to send
|
||||
let metadata = WhatsAppMessageMetadata {
|
||||
@@ -637,6 +685,149 @@ fn handle_message(
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Permission & Pairing
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a sender is permitted. Returns true if allowed.
|
||||
/// WhatsApp is always 1-to-1 (DM), so dm_policy always applies.
|
||||
fn check_sender_permission(
|
||||
sender_phone: &str,
|
||||
user_name: Option<&str>,
|
||||
phone_number_id: &str,
|
||||
) -> bool {
|
||||
// 1. Owner check (highest priority)
|
||||
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
if let Some(ref owner) = owner_id {
|
||||
if sender_phone != owner {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping message from non-owner {} (owner: {})",
|
||||
sender_phone, owner
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. DM policy (WhatsApp is always DM)
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy == "open" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Build merged allow list
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||
allowed.extend(store_allowed);
|
||||
}
|
||||
|
||||
// 4. Check sender (phone number or name)
|
||||
let is_allowed = allowed.contains(&"*".to_string())
|
||||
|| allowed.contains(&sender_phone.to_string())
|
||||
|| user_name.is_some_and(|u| allowed.contains(&u.to_string()));
|
||||
|
||||
if is_allowed {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Not allowed — handle by policy
|
||||
if dm_policy == "pairing" {
|
||||
let meta = serde_json::json!({
|
||||
"phone": sender_phone,
|
||||
"name": user_name,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, sender_phone, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for {}: code {}",
|
||||
sender_phone, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
let _ = send_pairing_reply(sender_phone, phone_number_id, &result.code);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send a pairing code message via WhatsApp Cloud API.
|
||||
fn send_pairing_reply(
|
||||
recipient_phone: &str,
|
||||
phone_number_id: &str,
|
||||
code: &str,
|
||||
) -> Result<(), String> {
|
||||
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "v18.0".to_string());
|
||||
|
||||
let url = format!(
|
||||
"https://graph.facebook.com/{}/{}/messages",
|
||||
api_version, phone_number_id
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": recipient_phone,
|
||||
"type": "text",
|
||||
"text": {
|
||||
"preview_url": false,
|
||||
"body": format!(
|
||||
"To pair with this bot, run: ironclaw pairing approve whatsapp {}",
|
||||
code
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
|
||||
});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
|
||||
Ok(response) => {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
Err(format!(
|
||||
"WhatsApp API error: {} - {}",
|
||||
response.status, body_str
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a JSON HTTP response.
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
},
|
||||
"config": {
|
||||
"api_version": "v18.0",
|
||||
"reply_to_message": true
|
||||
"reply_to_message": true,
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
|
||||
const KNOWN_CHANNELS: &[(&str, &str)] = &[
|
||||
("telegram", "telegram_channel"),
|
||||
("slack", "slack_channel"),
|
||||
("discord", "discord_channel"),
|
||||
("whatsapp", "whatsapp_channel"),
|
||||
];
|
||||
|
||||
@@ -42,6 +43,10 @@ fn channels_src_dir() -> PathBuf {
|
||||
|
||||
/// Locate the build artifacts for a channel.
|
||||
///
|
||||
/// Checks two layouts:
|
||||
/// 1. **Flat** (Docker/packaged): `<channels_src>/<name>/<name>.wasm`
|
||||
/// 2. **Build tree** (dev): `<channels_src>/<name>/target/wasm32-wasip2/release/<crate_name>.wasm`
|
||||
///
|
||||
/// Returns (wasm_path, capabilities_path) or an error if files are missing.
|
||||
fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> {
|
||||
let (_, crate_name) = KNOWN_CHANNELS
|
||||
@@ -52,31 +57,34 @@ fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> {
|
||||
let src_dir = channels_src_dir();
|
||||
let channel_dir = src_dir.join(name);
|
||||
|
||||
let wasm_path = channel_dir
|
||||
let caps_path = channel_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
// Check flat layout first (Docker/packaged deployments)
|
||||
let flat_wasm = channel_dir.join(format!("{}.wasm", name));
|
||||
if flat_wasm.exists() && caps_path.exists() {
|
||||
return Ok((flat_wasm, caps_path));
|
||||
}
|
||||
|
||||
// Fall back to build tree layout (dev builds)
|
||||
let build_wasm = channel_dir
|
||||
.join("target/wasm32-wasip2/release")
|
||||
.join(format!("{}.wasm", crate_name));
|
||||
|
||||
let caps_path = channel_dir.join(format!("{}.capabilities.json", name));
|
||||
if build_wasm.exists() && caps_path.exists() {
|
||||
return Ok((build_wasm, caps_path));
|
||||
}
|
||||
|
||||
if !wasm_path.exists() {
|
||||
return Err(format!(
|
||||
"Channel '{}' WASM not found at {}. Build it first:\n \
|
||||
Err(format!(
|
||||
"Channel '{}' WASM not found. Checked:\n \
|
||||
- {} (flat/packaged)\n \
|
||||
- {} (build tree)\n \
|
||||
Build it first:\n \
|
||||
cd {} && cargo build --target wasm32-wasip2 --release",
|
||||
name,
|
||||
wasm_path.display(),
|
||||
flat_wasm.display(),
|
||||
build_wasm.display(),
|
||||
channel_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
if !caps_path.exists() {
|
||||
return Err(format!(
|
||||
"Channel '{}' capabilities not found at {}",
|
||||
name,
|
||||
caps_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok((wasm_path, caps_path))
|
||||
))
|
||||
}
|
||||
|
||||
/// Install a channel from build artifacts into the channels directory.
|
||||
@@ -130,10 +138,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_known_channels_includes_all_three() {
|
||||
fn test_known_channels_includes_all_four() {
|
||||
let names = bundled_channel_names();
|
||||
assert!(names.contains(&"telegram"));
|
||||
assert!(names.contains(&"slack"));
|
||||
assert!(names.contains(&"discord"));
|
||||
assert!(names.contains(&"whatsapp"));
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,16 @@ pub async fn start_server(
|
||||
"/api/extensions/{name}/remove",
|
||||
post(extensions_remove_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/extensions/{name}/setup",
|
||||
get(extensions_setup_handler).post(extensions_setup_submit_handler),
|
||||
)
|
||||
// Pairing
|
||||
.route("/api/pairing/{channel}", get(pairing_list_handler))
|
||||
.route(
|
||||
"/api/pairing/{channel}/approve",
|
||||
post(pairing_approve_handler),
|
||||
)
|
||||
// Routines
|
||||
.route("/api/routines", get(routines_list_handler))
|
||||
.route("/api/routines/summary", get(routines_summary_handler))
|
||||
@@ -1708,6 +1718,7 @@ async fn extensions_list_handler(
|
||||
authenticated: ext.authenticated,
|
||||
active: ext.active,
|
||||
tools: ext.tools,
|
||||
needs_setup: ext.needs_setup,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1972,6 +1983,98 @@ async fn extensions_registry_handler(
|
||||
Json(RegistrySearchResponse { entries })
|
||||
}
|
||||
|
||||
async fn extensions_setup_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<ExtensionSetupResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
))?;
|
||||
|
||||
let secrets = ext_mgr
|
||||
.get_setup_schema(&name)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let kind = ext_mgr
|
||||
.list(None)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|list| list.into_iter().find(|e| e.name == name))
|
||||
.map(|e| e.kind.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(ExtensionSetupResponse {
|
||||
name,
|
||||
kind,
|
||||
secrets,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn extensions_setup_submit_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<ExtensionSetupRequest>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
))?;
|
||||
|
||||
match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
|
||||
Ok(message) => Ok(Json(ActionResponse::ok(message))),
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pairing handlers ---
|
||||
|
||||
async fn pairing_list_handler(
|
||||
Path(channel): Path<String>,
|
||||
) -> Result<Json<PairingListResponse>, (StatusCode, String)> {
|
||||
let store = crate::pairing::PairingStore::new();
|
||||
let requests = store
|
||||
.list_pending(&channel)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let infos = requests
|
||||
.into_iter()
|
||||
.map(|r| PairingRequestInfo {
|
||||
code: r.code,
|
||||
sender_id: r.id,
|
||||
meta: r.meta,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(PairingListResponse {
|
||||
channel,
|
||||
requests: infos,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn pairing_approve_handler(
|
||||
Path(channel): Path<String>,
|
||||
Json(req): Json<PairingApproveRequest>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
let store = crate::pairing::PairingStore::new();
|
||||
match store.approve(&channel, &req.code) {
|
||||
Ok(Some(approved)) => Ok(Json(ActionResponse::ok(format!(
|
||||
"Pairing approved for sender '{}'",
|
||||
approved.id
|
||||
)))),
|
||||
Ok(None) => Ok(Json(ActionResponse::fail(
|
||||
"Invalid or expired pairing code".to_string(),
|
||||
))),
|
||||
Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Too many failed approve attempts; try again later".to_string(),
|
||||
)),
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Skills handlers ---
|
||||
|
||||
async fn skills_list_handler(
|
||||
|
||||
@@ -1455,11 +1455,18 @@ function renderExtensionCard(ext) {
|
||||
actions.className = 'ext-actions';
|
||||
|
||||
if (!ext.active) {
|
||||
if (ext.kind === 'wasm_channel') {
|
||||
const restartLabel = document.createElement('span');
|
||||
restartLabel.className = 'ext-restart-label';
|
||||
restartLabel.textContent = 'Restart to activate';
|
||||
actions.appendChild(restartLabel);
|
||||
} else {
|
||||
const activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'btn-ext activate';
|
||||
activateBtn.textContent = 'Activate';
|
||||
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
||||
actions.appendChild(activateBtn);
|
||||
}
|
||||
} else {
|
||||
const activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
@@ -1467,6 +1474,14 @@ function renderExtensionCard(ext) {
|
||||
actions.appendChild(activeLabel);
|
||||
}
|
||||
|
||||
if (ext.needs_setup) {
|
||||
const configBtn = document.createElement('button');
|
||||
configBtn.className = 'btn-ext configure';
|
||||
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
|
||||
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
|
||||
actions.appendChild(configBtn);
|
||||
}
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-ext remove';
|
||||
removeBtn.textContent = 'Remove';
|
||||
@@ -1474,6 +1489,15 @@ function renderExtensionCard(ext) {
|
||||
actions.appendChild(removeBtn);
|
||||
|
||||
card.appendChild(actions);
|
||||
|
||||
// For active WASM channels, check for pending pairing requests
|
||||
if (ext.active && ext.kind === 'wasm_channel') {
|
||||
const pairingSection = document.createElement('div');
|
||||
pairingSection.className = 'ext-pairing';
|
||||
card.appendChild(pairingSection);
|
||||
loadPairingRequests(ext.name, pairingSection);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -1489,7 +1513,7 @@ function activateExtension(name) {
|
||||
showToast('Opening authentication for ' + name, 'info');
|
||||
window.open(res.auth_url, '_blank');
|
||||
} else if (res.awaiting_token) {
|
||||
showToast(res.instructions || 'Please provide an API token for ' + name, 'info');
|
||||
showConfigureModal(name);
|
||||
} else {
|
||||
showToast('Activate failed: ' + res.message, 'error');
|
||||
}
|
||||
@@ -1512,6 +1536,189 @@ function removeExtension(name) {
|
||||
.catch((err) => showToast('Remove failed: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
function showConfigureModal(name) {
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup')
|
||||
.then((setup) => {
|
||||
if (!setup.secrets || setup.secrets.length === 0) {
|
||||
showToast('No configuration needed for ' + name, 'info');
|
||||
return;
|
||||
}
|
||||
renderConfigureModal(name, setup.secrets);
|
||||
})
|
||||
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
function renderConfigureModal(name, secrets) {
|
||||
closeConfigureModal();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'configure-overlay';
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeConfigureModal();
|
||||
});
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'configure-modal';
|
||||
|
||||
const header = document.createElement('h3');
|
||||
header.textContent = 'Configure ' + name;
|
||||
modal.appendChild(header);
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'configure-form';
|
||||
|
||||
const fields = [];
|
||||
for (const secret of secrets) {
|
||||
const field = document.createElement('div');
|
||||
field.className = 'configure-field';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.textContent = secret.prompt;
|
||||
if (secret.optional) {
|
||||
const opt = document.createElement('span');
|
||||
opt.className = 'field-optional';
|
||||
opt.textContent = ' (optional)';
|
||||
label.appendChild(opt);
|
||||
}
|
||||
field.appendChild(label);
|
||||
|
||||
const inputRow = document.createElement('div');
|
||||
inputRow.className = 'configure-input-row';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'password';
|
||||
input.name = secret.name;
|
||||
input.placeholder = secret.provided ? '(already set — leave empty to keep)' : '';
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') submitConfigureModal(name, fields);
|
||||
});
|
||||
inputRow.appendChild(input);
|
||||
|
||||
if (secret.provided) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'field-provided';
|
||||
badge.textContent = 'Set';
|
||||
inputRow.appendChild(badge);
|
||||
}
|
||||
if (secret.auto_generate && !secret.provided) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'field-autogen';
|
||||
hint.textContent = 'Auto-generated if empty';
|
||||
inputRow.appendChild(hint);
|
||||
}
|
||||
|
||||
field.appendChild(inputRow);
|
||||
form.appendChild(field);
|
||||
fields.push({ name: secret.name, input: input });
|
||||
}
|
||||
|
||||
modal.appendChild(form);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'configure-actions';
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'btn-ext activate';
|
||||
submitBtn.textContent = 'Save';
|
||||
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
||||
actions.appendChild(submitBtn);
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn-ext remove';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.addEventListener('click', closeConfigureModal);
|
||||
actions.appendChild(cancelBtn);
|
||||
|
||||
modal.appendChild(actions);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
if (fields.length > 0) fields[0].input.focus();
|
||||
}
|
||||
|
||||
function submitConfigureModal(name, fields) {
|
||||
const secrets = {};
|
||||
for (const f of fields) {
|
||||
if (f.input.value.trim()) {
|
||||
secrets[f.name] = f.input.value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', {
|
||||
method: 'POST',
|
||||
body: { secrets },
|
||||
})
|
||||
.then((res) => {
|
||||
closeConfigureModal();
|
||||
if (res.success) {
|
||||
showToast(res.message, 'success');
|
||||
} else {
|
||||
showToast(res.message || 'Configuration failed', 'error');
|
||||
}
|
||||
loadExtensions();
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast('Configuration failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function closeConfigureModal() {
|
||||
const existing = document.querySelector('.configure-overlay');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
// --- Pairing ---
|
||||
|
||||
function loadPairingRequests(channel, container) {
|
||||
apiFetch('/api/pairing/' + encodeURIComponent(channel))
|
||||
.then(data => {
|
||||
container.innerHTML = '';
|
||||
if (!data.requests || data.requests.length === 0) return;
|
||||
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'pairing-heading';
|
||||
heading.textContent = 'Pending pairing requests';
|
||||
container.appendChild(heading);
|
||||
|
||||
data.requests.forEach(req => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pairing-row';
|
||||
|
||||
const code = document.createElement('span');
|
||||
code.className = 'pairing-code';
|
||||
code.textContent = req.code;
|
||||
row.appendChild(code);
|
||||
|
||||
const sender = document.createElement('span');
|
||||
sender.className = 'pairing-sender';
|
||||
sender.textContent = 'from ' + req.sender_id;
|
||||
row.appendChild(sender);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-ext activate';
|
||||
btn.textContent = 'Approve';
|
||||
btn.addEventListener('click', () => approvePairing(channel, req.code, container));
|
||||
row.appendChild(btn);
|
||||
|
||||
container.appendChild(row);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function approvePairing(channel, code, container) {
|
||||
apiFetch('/api/pairing/' + encodeURIComponent(channel) + '/approve', {
|
||||
method: 'POST',
|
||||
body: { code },
|
||||
}).then(res => {
|
||||
if (res.success) {
|
||||
showToast('Pairing approved', 'success');
|
||||
loadPairingRequests(channel, container);
|
||||
} else {
|
||||
showToast(res.message || 'Approve failed', 'error');
|
||||
}
|
||||
}).catch(err => showToast('Error: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
// --- Jobs ---
|
||||
|
||||
let currentJobId = null;
|
||||
|
||||
@@ -1936,6 +1936,12 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ext-restart-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.btn-ext {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius);
|
||||
@@ -1992,6 +1998,146 @@ body {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn-ext.configure {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-ext.configure:hover {
|
||||
background: rgba(136, 132, 216, 0.15);
|
||||
}
|
||||
|
||||
/* Pairing requests */
|
||||
.ext-pairing {
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.pairing-heading {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pairing-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pairing-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.pairing-sender {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Configure modal */
|
||||
.configure-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.configure-modal {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 460px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.configure-modal h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.configure-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.configure-field label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.configure-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.configure-input-row input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.configure-input-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.field-optional {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.field-provided {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: var(--success);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-autogen {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.configure-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tools-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -346,6 +346,9 @@ pub struct ExtensionInfo {
|
||||
pub authenticated: bool,
|
||||
pub active: bool,
|
||||
pub tools: Vec<String>,
|
||||
/// Whether this extension has configurable secrets (setup schema).
|
||||
#[serde(default)]
|
||||
pub needs_setup: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -371,6 +374,31 @@ pub struct InstallExtensionRequest {
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
// --- Extension Setup ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExtensionSetupResponse {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub secrets: Vec<SecretFieldInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SecretFieldInfo {
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub optional: bool,
|
||||
/// Whether this secret is already stored.
|
||||
pub provided: bool,
|
||||
/// Whether the secret will be auto-generated if left empty.
|
||||
pub auto_generate: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtensionSetupRequest {
|
||||
pub secrets: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActionResponse {
|
||||
pub success: bool,
|
||||
@@ -430,6 +458,28 @@ pub struct RegistrySearchQuery {
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
// --- Pairing ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PairingListResponse {
|
||||
pub channel: String,
|
||||
pub requests: Vec<PairingRequestInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PairingRequestInfo {
|
||||
pub code: String,
|
||||
pub sender_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<serde_json::Value>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PairingApproveRequest {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
// --- Skills ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
@@ -246,6 +246,7 @@ fn extract_url(source: &ExtensionSource) -> String {
|
||||
ExtensionSource::Discovered { url } => url.clone(),
|
||||
ExtensionSource::WasmDownload { wasm_url, .. } => wasm_url.clone(),
|
||||
ExtensionSource::WasmBuildable { repo_url, .. } => repo_url.clone(),
|
||||
ExtensionSource::Bundled { name } => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+375
-8
@@ -4,7 +4,7 @@
|
||||
//! and tool registry. All extension operations (search, install, auth, activate,
|
||||
//! list, remove) flow through here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct ExtensionManager {
|
||||
user_id: String,
|
||||
/// Optional database store for DB-backed MCP config.
|
||||
store: Option<Arc<dyn crate::db::Database>>,
|
||||
/// Names of WASM channels that were successfully loaded at startup.
|
||||
active_channel_names: RwLock<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl ExtensionManager {
|
||||
@@ -97,9 +99,17 @@ impl ExtensionManager {
|
||||
_tunnel_url: tunnel_url,
|
||||
user_id,
|
||||
store,
|
||||
active_channel_names: RwLock::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register channel names that were loaded at startup.
|
||||
/// Called after WASM channels are loaded so `list()` reports accurate active status.
|
||||
pub async fn set_active_channels(&self, names: Vec<String>) {
|
||||
let mut active = self.active_channel_names.write().await;
|
||||
active.extend(names);
|
||||
}
|
||||
|
||||
/// Search for extensions. If `discover` is true, also searches online.
|
||||
pub async fn search(
|
||||
&self,
|
||||
@@ -186,7 +196,7 @@ impl ExtensionManager {
|
||||
match kind {
|
||||
ExtensionKind::McpServer => self.auth_mcp(name, token).await,
|
||||
ExtensionKind::WasmTool => self.auth_wasm_tool(name, token).await,
|
||||
ExtensionKind::WasmChannel => self.auth_wasm_tool(name, token).await,
|
||||
ExtensionKind::WasmChannel => self.auth_wasm_channel(name, token).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +248,7 @@ impl ExtensionManager {
|
||||
authenticated,
|
||||
active,
|
||||
tools,
|
||||
needs_setup: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -264,6 +275,7 @@ impl ExtensionManager {
|
||||
authenticated: true, // WASM tools don't always need auth
|
||||
active,
|
||||
tools: if active { vec![name] } else { Vec::new() },
|
||||
needs_setup: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -279,15 +291,20 @@ impl ExtensionManager {
|
||||
{
|
||||
match crate::channels::wasm::discover_channels(&self.wasm_channels_dir).await {
|
||||
Ok(channels) => {
|
||||
let active_names = self.active_channel_names.read().await;
|
||||
for (name, _discovered) in channels {
|
||||
let active = active_names.contains(&name);
|
||||
let (authenticated, needs_setup) =
|
||||
self.check_channel_auth_status(&name).await;
|
||||
extensions.push(InstalledExtension {
|
||||
name,
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
description: None,
|
||||
url: None,
|
||||
authenticated: true,
|
||||
active: true, // If loaded at startup, they're active
|
||||
authenticated,
|
||||
active,
|
||||
tools: Vec::new(),
|
||||
needs_setup,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -369,10 +386,27 @@ impl ExtensionManager {
|
||||
|
||||
Ok(format!("Removed WASM tool '{}'", name))
|
||||
}
|
||||
ExtensionKind::WasmChannel => Err(ExtensionError::Other(
|
||||
"Channel removal requires restart. Delete the .wasm file from ~/.ironclaw/channels/ and restart."
|
||||
.to_string(),
|
||||
)),
|
||||
ExtensionKind::WasmChannel => {
|
||||
// Delete channel files
|
||||
let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if wasm_path.exists() {
|
||||
tokio::fs::remove_file(&wasm_path)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
}
|
||||
if cap_path.exists() {
|
||||
let _ = tokio::fs::remove_file(&cap_path).await;
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Removed channel '{}'. Restart IronClaw for the change to take effect.",
|
||||
name
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +521,9 @@ impl ExtensionManager {
|
||||
entry.name, entry.name
|
||||
)))
|
||||
}
|
||||
ExtensionSource::Bundled { name } => {
|
||||
self.install_bundled_channel_from_artifacts(name).await
|
||||
}
|
||||
_ => Err(ExtensionError::InstallFailed(
|
||||
"WASM channel entry has no download URL".to_string(),
|
||||
)),
|
||||
@@ -792,6 +829,39 @@ impl ExtensionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_bundled_channel_from_artifacts(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<InstallResult, ExtensionError> {
|
||||
// Check if already installed
|
||||
let channel_wasm = self.wasm_channels_dir.join(format!("{}.wasm", name));
|
||||
if channel_wasm.exists() {
|
||||
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
|
||||
}
|
||||
|
||||
crate::channels::wasm::install_bundled_channel(name, &self.wasm_channels_dir, false)
|
||||
.await
|
||||
.map_err(ExtensionError::InstallFailed)?;
|
||||
|
||||
tracing::info!(
|
||||
"Installed bundled channel '{}' to {}",
|
||||
name,
|
||||
self.wasm_channels_dir.display()
|
||||
);
|
||||
|
||||
Ok(InstallResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
message: format!(
|
||||
"Channel '{}' installed to {}. Restart IronClaw for the channel to activate. \
|
||||
Run tool_auth('{}') to configure authentication before restarting.",
|
||||
name,
|
||||
self.wasm_channels_dir.display(),
|
||||
name,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
async fn auth_mcp(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -1094,6 +1164,169 @@ impl ExtensionManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether a WASM channel has all required secrets stored.
|
||||
/// Returns `(authenticated, needs_setup)`.
|
||||
async fn check_channel_auth_status(&self, name: &str) -> (bool, bool) {
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
if !cap_path.exists() {
|
||||
return (true, false);
|
||||
}
|
||||
let Ok(cap_bytes) = tokio::fs::read(&cap_path).await else {
|
||||
return (true, false);
|
||||
};
|
||||
let Ok(cap_file) = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
else {
|
||||
return (true, false);
|
||||
};
|
||||
let required = &cap_file.setup.required_secrets;
|
||||
if required.is_empty() {
|
||||
return (true, false);
|
||||
}
|
||||
let mut all_provided = true;
|
||||
for secret in required {
|
||||
if secret.optional {
|
||||
continue;
|
||||
}
|
||||
if !self
|
||||
.secrets
|
||||
.exists(&self.user_id, &secret.name)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
all_provided = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
(all_provided, true)
|
||||
}
|
||||
|
||||
async fn auth_wasm_channel(
|
||||
&self,
|
||||
name: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<AuthResult, ExtensionError> {
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if !cap_path.exists() {
|
||||
return Ok(AuthResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
auth_url: None,
|
||||
callback_type: None,
|
||||
instructions: None,
|
||||
setup_url: None,
|
||||
awaiting_token: false,
|
||||
status: "no_auth_required".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let cap_bytes = tokio::fs::read(&cap_path)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
|
||||
let cap_file = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
|
||||
// Get required secrets from the setup section
|
||||
let required_secrets = &cap_file.setup.required_secrets;
|
||||
if required_secrets.is_empty() {
|
||||
return Ok(AuthResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
auth_url: None,
|
||||
callback_type: None,
|
||||
instructions: None,
|
||||
setup_url: None,
|
||||
awaiting_token: false,
|
||||
status: "no_auth_required".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Find the first non-optional secret that isn't yet stored
|
||||
let mut missing = Vec::new();
|
||||
for secret in required_secrets {
|
||||
if secret.optional {
|
||||
continue;
|
||||
}
|
||||
if !self
|
||||
.secrets
|
||||
.exists(&self.user_id, &secret.name)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
missing.push(secret);
|
||||
}
|
||||
}
|
||||
|
||||
if missing.is_empty() {
|
||||
return Ok(AuthResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
auth_url: None,
|
||||
callback_type: None,
|
||||
instructions: None,
|
||||
setup_url: None,
|
||||
awaiting_token: false,
|
||||
status: "authenticated".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// If a token was provided, store it for the first missing secret
|
||||
if let Some(token_value) = token {
|
||||
let secret = &missing[0];
|
||||
let params =
|
||||
CreateSecretParams::new(&secret.name, token_value).with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
|
||||
// Check if there are more missing secrets
|
||||
if missing.len() <= 1 {
|
||||
return Ok(AuthResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
auth_url: None,
|
||||
callback_type: None,
|
||||
instructions: None,
|
||||
setup_url: None,
|
||||
awaiting_token: false,
|
||||
status: "authenticated".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// More secrets needed; prompt for the next one
|
||||
let next = &missing[1];
|
||||
return Ok(AuthResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
auth_url: None,
|
||||
callback_type: None,
|
||||
instructions: Some(next.prompt.clone()),
|
||||
setup_url: cap_file.setup.validation_endpoint.clone(),
|
||||
awaiting_token: true,
|
||||
status: "awaiting_token".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Prompt for the first missing secret
|
||||
let secret = &missing[0];
|
||||
Ok(AuthResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
auth_url: None,
|
||||
callback_type: None,
|
||||
instructions: Some(secret.prompt.clone()),
|
||||
setup_url: cap_file.setup.validation_endpoint.clone(),
|
||||
awaiting_token: true,
|
||||
status: "awaiting_token".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn activate_mcp(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
|
||||
// Check if already activated
|
||||
{
|
||||
@@ -1282,6 +1515,140 @@ impl ExtensionManager {
|
||||
pending.retain(|_, auth| auth.created_at.elapsed() < std::time::Duration::from_secs(300));
|
||||
}
|
||||
|
||||
/// Get the setup schema for an extension (secret fields and their status).
|
||||
pub async fn get_setup_schema(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<Vec<crate::channels::web::types::SecretFieldInfo>, ExtensionError> {
|
||||
let kind = self.determine_installed_kind(name).await?;
|
||||
match kind {
|
||||
ExtensionKind::WasmChannel => {
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
if !cap_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let cap_bytes = tokio::fs::read(&cap_path)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
let cap_file =
|
||||
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
|
||||
let mut fields = Vec::new();
|
||||
for secret in &cap_file.setup.required_secrets {
|
||||
let provided = self
|
||||
.secrets
|
||||
.exists(&self.user_id, &secret.name)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
fields.push(crate::channels::web::types::SecretFieldInfo {
|
||||
name: secret.name.clone(),
|
||||
prompt: secret.prompt.clone(),
|
||||
optional: secret.optional,
|
||||
provided,
|
||||
auto_generate: secret.auto_generate.is_some(),
|
||||
});
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save setup secrets for an extension, validating names against the capabilities schema.
|
||||
pub async fn save_setup_secrets(
|
||||
&self,
|
||||
name: &str,
|
||||
secrets: &std::collections::HashMap<String, String>,
|
||||
) -> Result<String, ExtensionError> {
|
||||
let kind = self.determine_installed_kind(name).await?;
|
||||
if kind != ExtensionKind::WasmChannel {
|
||||
return Err(ExtensionError::Other(
|
||||
"Setup is only supported for WASM channels".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
if !cap_path.exists() {
|
||||
return Err(ExtensionError::Other(format!(
|
||||
"Capabilities file not found for '{}'",
|
||||
name
|
||||
)));
|
||||
}
|
||||
let cap_bytes = tokio::fs::read(&cap_path)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
let cap_file = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
|
||||
// Build allowed secret names from capabilities
|
||||
let allowed: std::collections::HashSet<String> = cap_file
|
||||
.setup
|
||||
.required_secrets
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
|
||||
// Validate and store each submitted secret
|
||||
for (secret_name, secret_value) in secrets {
|
||||
if !allowed.contains(secret_name.as_str()) {
|
||||
return Err(ExtensionError::Other(format!(
|
||||
"Unknown secret '{}' for extension '{}'",
|
||||
secret_name, name
|
||||
)));
|
||||
}
|
||||
if secret_value.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let params =
|
||||
CreateSecretParams::new(secret_name, secret_value).with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Auto-generate any missing secrets that have auto_generate set
|
||||
for secret_def in &cap_file.setup.required_secrets {
|
||||
if let Some(ref auto_gen) = secret_def.auto_generate {
|
||||
let already_provided = secrets
|
||||
.get(&secret_def.name)
|
||||
.is_some_and(|v| !v.trim().is_empty());
|
||||
let already_stored = self
|
||||
.secrets
|
||||
.exists(&self.user_id, &secret_def.name)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !already_provided && !already_stored {
|
||||
use rand::RngCore;
|
||||
let mut bytes = vec![0u8; auto_gen.length];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
let hex_value: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let params = CreateSecretParams::new(&secret_def.name, &hex_value)
|
||||
.with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
tracing::info!(
|
||||
"Auto-generated secret '{}' for channel '{}'",
|
||||
secret_def.name,
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Configuration saved for '{}'. Restart IronClaw for changes to take effect.",
|
||||
name
|
||||
))
|
||||
}
|
||||
|
||||
async fn unregister_hook_prefix(&self, prefix: &str) -> usize {
|
||||
let Some(ref hooks) = self.hooks else {
|
||||
return 0;
|
||||
|
||||
@@ -85,6 +85,11 @@ pub enum ExtensionSource {
|
||||
},
|
||||
/// Discovered online (not yet validated for a specific source type).
|
||||
Discovered { url: String },
|
||||
/// Bundled with the application (pre-built WASM, copied from build artifacts).
|
||||
Bundled {
|
||||
/// Channel or tool name used to locate build artifacts.
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Hint about what authentication method is needed.
|
||||
@@ -184,6 +189,9 @@ pub struct InstalledExtension {
|
||||
/// Tool names if active.
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
/// Whether this extension has a setup schema (required_secrets) that can be configured.
|
||||
#[serde(default)]
|
||||
pub needs_setup: bool,
|
||||
}
|
||||
|
||||
/// Error type for extension operations.
|
||||
|
||||
+125
-16
@@ -270,11 +270,11 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
auth_hint: AuthHint::Dcr,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "slack".to_string(),
|
||||
display_name: "Slack".to_string(),
|
||||
name: "slack-mcp".to_string(),
|
||||
display_name: "Slack MCP".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Slack for messaging, channel management, and team communication"
|
||||
"Connect to Slack via MCP for messaging, channel management, and team communication"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"messaging".into(),
|
||||
@@ -380,6 +380,72 @@ fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
},
|
||||
auth_hint: AuthHint::Dcr,
|
||||
},
|
||||
// -- WASM Channels (bundled) --
|
||||
RegistryEntry {
|
||||
name: "telegram".to_string(),
|
||||
display_name: "Telegram".to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
description: "Telegram Bot API channel for receiving and sending messages via Telegram"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"chat".into(),
|
||||
"messaging".into(),
|
||||
"bot".into(),
|
||||
"channel".into(),
|
||||
],
|
||||
source: ExtensionSource::Bundled {
|
||||
name: "telegram".to_string(),
|
||||
},
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "slack".to_string(),
|
||||
display_name: "Slack".to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
description: "Slack Events API channel for receiving and sending messages via Slack"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"chat".into(),
|
||||
"messaging".into(),
|
||||
"team".into(),
|
||||
"channel".into(),
|
||||
],
|
||||
source: ExtensionSource::Bundled {
|
||||
name: "slack".to_string(),
|
||||
},
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "discord".to_string(),
|
||||
display_name: "Discord".to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
description:
|
||||
"Discord Gateway channel for handling slash commands, buttons, and messages"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"chat".into(),
|
||||
"messaging".into(),
|
||||
"gaming".into(),
|
||||
"channel".into(),
|
||||
],
|
||||
source: ExtensionSource::Bundled {
|
||||
name: "discord".to_string(),
|
||||
},
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "whatsapp".to_string(),
|
||||
display_name: "WhatsApp".to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
description:
|
||||
"WhatsApp Business API channel for receiving and sending WhatsApp messages"
|
||||
.to_string(),
|
||||
keywords: vec!["chat".into(), "messaging".into(), "channel".into()],
|
||||
source: ExtensionSource::Bundled {
|
||||
name: "whatsapp".to_string(),
|
||||
},
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -578,10 +644,10 @@ mod tests {
|
||||
},
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
},
|
||||
// This shares a name with a builtin but has a different kind, so both should appear
|
||||
// This shares a name with the builtin slack-mcp but has a different kind, so both should appear
|
||||
RegistryEntry {
|
||||
name: "slack".to_string(),
|
||||
display_name: "Slack WASM".to_string(),
|
||||
name: "slack-mcp".to_string(),
|
||||
display_name: "Slack MCP WASM".to_string(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
description: "Slack WASM tool".to_string(),
|
||||
keywords: vec!["messaging".into()],
|
||||
@@ -600,25 +666,25 @@ mod tests {
|
||||
assert!(!results.is_empty(), "Should find telegram from catalog");
|
||||
assert_eq!(results[0].entry.name, "telegram");
|
||||
|
||||
// Should have both builtin MCP slack and catalog WASM slack
|
||||
// Should have both builtin MCP slack-mcp and catalog WASM slack-mcp
|
||||
let results = registry.search("slack").await;
|
||||
let slack_mcp = results
|
||||
.iter()
|
||||
.any(|r| r.entry.name == "slack" && r.entry.kind == ExtensionKind::McpServer);
|
||||
.any(|r| r.entry.name == "slack-mcp" && r.entry.kind == ExtensionKind::McpServer);
|
||||
let slack_wasm = results
|
||||
.iter()
|
||||
.any(|r| r.entry.name == "slack" && r.entry.kind == ExtensionKind::WasmTool);
|
||||
assert!(slack_mcp, "Should have builtin MCP slack");
|
||||
assert!(slack_wasm, "Should have catalog WASM slack");
|
||||
.any(|r| r.entry.name == "slack-mcp" && r.entry.kind == ExtensionKind::WasmTool);
|
||||
assert!(slack_mcp, "Should have builtin MCP slack-mcp");
|
||||
assert!(slack_wasm, "Should have catalog WASM slack-mcp");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_new_with_catalog_dedup_same_kind() {
|
||||
// A catalog entry with same name AND kind as a builtin should be skipped
|
||||
let catalog_entries = vec![RegistryEntry {
|
||||
name: "slack".to_string(),
|
||||
display_name: "Slack Override".to_string(),
|
||||
kind: ExtensionKind::McpServer, // same kind as builtin
|
||||
name: "slack-mcp".to_string(),
|
||||
display_name: "Slack MCP Override".to_string(),
|
||||
kind: ExtensionKind::McpServer, // same kind as builtin slack-mcp
|
||||
description: "Should be skipped".to_string(),
|
||||
keywords: vec![],
|
||||
source: ExtensionSource::McpUrl {
|
||||
@@ -629,9 +695,52 @@ mod tests {
|
||||
|
||||
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
||||
|
||||
let entry = registry.get("slack").await;
|
||||
let entry = registry.get("slack-mcp").await;
|
||||
assert!(entry.is_some());
|
||||
// Should still be the builtin, not the override
|
||||
assert_eq!(entry.unwrap().display_name, "Slack");
|
||||
assert_eq!(entry.unwrap().display_name, "Slack MCP");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_finds_telegram_channel() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
let results = registry.search("telegram").await;
|
||||
|
||||
assert!(!results.is_empty(), "Should find telegram in registry");
|
||||
assert_eq!(results[0].entry.name, "telegram");
|
||||
assert_eq!(results[0].entry.kind, ExtensionKind::WasmChannel);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_channel_by_keyword() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
let results = registry.search("bot messaging").await;
|
||||
|
||||
let has_telegram = results.iter().any(|r| r.entry.name == "telegram");
|
||||
assert!(
|
||||
has_telegram,
|
||||
"Telegram should appear in bot messaging search"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_bundled_channels() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
|
||||
let telegram = registry.get("telegram").await;
|
||||
assert!(telegram.is_some());
|
||||
assert_eq!(telegram.unwrap().kind, ExtensionKind::WasmChannel);
|
||||
|
||||
let slack = registry.get("slack").await;
|
||||
assert!(slack.is_some());
|
||||
assert_eq!(slack.unwrap().kind, ExtensionKind::WasmChannel);
|
||||
|
||||
let discord = registry.get("discord").await;
|
||||
assert!(discord.is_some());
|
||||
assert_eq!(discord.unwrap().kind, ExtensionKind::WasmChannel);
|
||||
|
||||
let whatsapp = registry.get("whatsapp").await;
|
||||
assert!(whatsapp.is_some());
|
||||
assert_eq!(whatsapp.unwrap().kind, ExtensionKind::WasmChannel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1195,6 +1195,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
));
|
||||
}
|
||||
|
||||
// Tell extension manager which channels are actually loaded
|
||||
if let Some(ref em) = extension_manager {
|
||||
em.set_active_channels(loaded_wasm_channel_names.clone())
|
||||
.await;
|
||||
}
|
||||
|
||||
for (path, err) in &results.errors {
|
||||
tracing::warn!(
|
||||
"Failed to load WASM channel {}: {}",
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Tool for ToolSearchTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search for available extensions (MCP servers, WASM tools) to add. \
|
||||
"Search for available extensions (MCP servers, WASM tools, WASM channels) to add. \
|
||||
Use discover:true to search online if the built-in registry has no results."
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ impl Tool for ToolInstallTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Install an extension (MCP server or WASM tool). \
|
||||
"Install an extension (MCP server, WASM tool, or WASM channel). \
|
||||
Use the name from tool_search results, or provide an explicit URL."
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ impl Tool for ToolInstallTool {
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["mcp_server", "wasm_tool"],
|
||||
"enum": ["mcp_server", "wasm_tool", "wasm_channel"],
|
||||
"description": "Extension type (auto-detected if omitted)"
|
||||
}
|
||||
},
|
||||
@@ -143,6 +143,7 @@ impl Tool for ToolInstallTool {
|
||||
.and_then(|k| match k {
|
||||
"mcp_server" => Some(ExtensionKind::McpServer),
|
||||
"wasm_tool" => Some(ExtensionKind::WasmTool),
|
||||
"wasm_channel" => Some(ExtensionKind::WasmChannel),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user