Skip to content

Commit b3bf50f

Browse files
henrypark133claude
andauthored
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 <noreply@anthropic.com>
1 parent 48b5323 commit b3bf50f

20 files changed

Lines changed: 2136 additions & 81 deletions

File tree

channels-src/discord/Cargo.lock

Lines changed: 401 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

channels-src/discord/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ publish = false
99
[dependencies]
1010
serde = { version = "1.0", features = ["derive"] }
1111
serde_json = "1.0"
12-
wit-bindgen = "0.41.0"
12+
wit-bindgen = "0.36"
1313

1414
[lib]
1515
crate-type = ["cdylib"]

channels-src/discord/discord.capabilities.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
"type": "channel",
33
"name": "discord",
44
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
5+
"setup": {
6+
"required_secrets": [
7+
{
8+
"name": "discord_bot_token",
9+
"prompt": "Enter your Discord Bot Token (from Developer Portal)",
10+
"optional": false
11+
}
12+
]
13+
},
514
"capabilities": {
615
"http": {
716
"allowlist": [
@@ -10,7 +19,7 @@
1019
"credentials": {
1120
"discord_bot_token": {
1221
"secret_name": "discord_bot_token",
13-
"location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " },
22+
"location": { "type": "header", "name": "Authorization", "prefix": "Bot " },
1423
"host_patterns": ["discord.com"]
1524
}
1625
},
@@ -34,6 +43,9 @@
3443
}
3544
},
3645
"config": {
37-
"require_signature_verification": true
46+
"require_signature_verification": true,
47+
"owner_id": null,
48+
"dm_policy": "pairing",
49+
"allow_from": []
3850
}
3951
}

channels-src/discord/src/lib.rs

Lines changed: 226 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,57 @@ struct DiscordMessageMetadata {
124124
thread_id: Option<String>,
125125
}
126126

127+
/// Workspace path for persisting owner_id across WASM callbacks.
128+
const OWNER_ID_PATH: &str = "state/owner_id";
129+
/// Workspace path for persisting dm_policy across WASM callbacks.
130+
const DM_POLICY_PATH: &str = "state/dm_policy";
131+
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
132+
const ALLOW_FROM_PATH: &str = "state/allow_from";
133+
/// Channel name for pairing store (used by pairing host APIs).
134+
const CHANNEL_NAME: &str = "discord";
135+
136+
/// Channel configuration from capabilities file.
137+
#[derive(Debug, Deserialize)]
138+
struct DiscordConfig {
139+
#[serde(default)]
140+
#[allow(dead_code)]
141+
require_signature_verification: bool,
142+
#[serde(default)]
143+
owner_id: Option<String>,
144+
#[serde(default)]
145+
dm_policy: Option<String>,
146+
#[serde(default)]
147+
allow_from: Option<Vec<String>>,
148+
}
149+
127150
struct DiscordChannel;
128151

129152
impl Guest for DiscordChannel {
130-
fn on_start(_config_json: String) -> Result<ChannelConfig, String> {
153+
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
154+
let config: DiscordConfig = serde_json::from_str(&config_json)
155+
.map_err(|e| format!("Failed to parse config: {}", e))?;
156+
131157
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
132158

159+
// Persist owner_id so subsequent callbacks can read it
160+
if let Some(ref owner_id) = config.owner_id {
161+
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
162+
channel_host::log(
163+
channel_host::LogLevel::Info,
164+
&format!("Owner restriction enabled: user {}", owner_id),
165+
);
166+
} else {
167+
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
168+
}
169+
170+
// Persist dm_policy and allow_from for DM pairing
171+
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
172+
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
173+
174+
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
175+
.unwrap_or_else(|_| "[]".to_string());
176+
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
177+
133178
Ok(ChannelConfig {
134179
display_name: "Discord".to_string(),
135180
http_endpoints: vec![HttpEndpointConfig {
@@ -169,16 +214,21 @@ impl Guest for DiscordChannel {
169214

170215
// Application Command (slash command)
171216
2 => {
172-
handle_slash_command(&interaction);
173-
json_response(
174-
200,
175-
serde_json::json!({
176-
"type": 5,
177-
"data": {
178-
"content": "🤔 Thinking..."
179-
}
180-
}),
181-
)
217+
if handle_slash_command(&interaction) {
218+
json_response(200, serde_json::json!({"type": 5}))
219+
} else {
220+
// Permission denied — ephemeral response
221+
json_response(
222+
200,
223+
serde_json::json!({
224+
"type": 4,
225+
"data": {
226+
"content": "You are not authorized to use this bot.",
227+
"flags": 64
228+
}
229+
}),
230+
)
231+
}
182232
}
183233

184234
// Message Component (buttons, selects)
@@ -270,7 +320,8 @@ impl Guest for DiscordChannel {
270320
}
271321
}
272322

273-
fn handle_slash_command(interaction: &DiscordInteraction) {
323+
/// Returns true if the message was emitted, false if permission denied.
324+
fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
274325
let user = interaction
275326
.member
276327
.as_ref()
@@ -287,6 +338,22 @@ fn handle_slash_command(interaction: &DiscordInteraction) {
287338
})
288339
.unwrap_or_default();
289340

341+
// DM if no guild member context (only direct user field set)
342+
let is_dm = interaction.member.is_none();
343+
344+
// Permission check
345+
if !check_sender_permission(
346+
&user_id,
347+
Some(&user_name),
348+
is_dm,
349+
Some(&PairingReplyCtx {
350+
application_id: interaction.application_id.clone(),
351+
token: interaction.token.clone(),
352+
}),
353+
) {
354+
return false;
355+
}
356+
290357
let channel_id = interaction.channel_id.clone().unwrap_or_default();
291358

292359
let command_name = interaction
@@ -322,14 +389,13 @@ fn handle_slash_command(interaction: &DiscordInteraction) {
322389
channel_host::LogLevel::Error,
323390
&format!("Failed to serialize metadata: {}", e),
324391
);
325-
// Attempt to notify user of internal error
326392
let url = format!(
327393
"https://discord.com/api/v10/webhooks/{}/{}",
328394
interaction.application_id, interaction.token
329395
);
330396
let payload = serde_json::json!({
331397
"content": "❌ Internal Error: Failed to process command metadata.",
332-
"flags": 64 // Ephemeral
398+
"flags": 64
333399
});
334400
let _ = channel_host::http_request(
335401
"POST",
@@ -338,7 +404,7 @@ fn handle_slash_command(interaction: &DiscordInteraction) {
338404
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
339405
None,
340406
);
341-
return;
407+
return true; // Error, but not a permission denial
342408
}
343409
};
344410

@@ -349,10 +415,10 @@ fn handle_slash_command(interaction: &DiscordInteraction) {
349415
thread_id: None,
350416
metadata_json,
351417
});
418+
true
352419
}
353420

354421
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
355-
// Check member first (for server contexts), then user (for DMs)
356422
let user = interaction
357423
.member
358424
.as_ref()
@@ -369,6 +435,11 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM
369435
})
370436
.unwrap_or_default();
371437

438+
let is_dm = interaction.member.is_none();
439+
if !check_sender_permission(&user_id, Some(&user_name), is_dm, None) {
440+
return;
441+
}
442+
372443
let channel_id = message.channel_id.clone();
373444

374445
let metadata = DiscordMessageMetadata {
@@ -399,6 +470,145 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM
399470
});
400471
}
401472

473+
// ============================================================================
474+
// Permission & Pairing
475+
// ============================================================================
476+
477+
/// Context needed to send a pairing reply via Discord webhook followup.
478+
struct PairingReplyCtx {
479+
application_id: String,
480+
token: String,
481+
}
482+
483+
/// Check if a sender is permitted to interact with the bot.
484+
/// Returns true if allowed, false if denied (pairing reply sent if applicable).
485+
fn check_sender_permission(
486+
user_id: &str,
487+
username: Option<&str>,
488+
is_dm: bool,
489+
reply_ctx: Option<&PairingReplyCtx>,
490+
) -> bool {
491+
// 1. Owner check (highest priority, applies to all contexts)
492+
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
493+
if let Some(ref owner) = owner_id {
494+
if user_id != owner {
495+
channel_host::log(
496+
channel_host::LogLevel::Debug,
497+
&format!(
498+
"Dropping interaction from non-owner user {} (owner: {})",
499+
user_id, owner
500+
),
501+
);
502+
return false;
503+
}
504+
return true;
505+
}
506+
507+
// 2. DM policy (only for DMs when no owner_id)
508+
if !is_dm {
509+
return true; // Guild interactions bypass DM policy
510+
}
511+
512+
let dm_policy =
513+
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
514+
515+
if dm_policy == "open" {
516+
return true;
517+
}
518+
519+
// 3. Build merged allow list: config allow_from + pairing store
520+
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
521+
.and_then(|s| serde_json::from_str(&s).ok())
522+
.unwrap_or_default();
523+
524+
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
525+
allowed.extend(store_allowed);
526+
}
527+
528+
// 4. Check sender against allow list
529+
let is_allowed = allowed.contains(&"*".to_string())
530+
|| allowed.contains(&user_id.to_string())
531+
|| username.is_some_and(|u| allowed.contains(&u.to_string()));
532+
533+
if is_allowed {
534+
return true;
535+
}
536+
537+
// 5. Not allowed — handle by policy
538+
if dm_policy == "pairing" {
539+
let meta = serde_json::json!({
540+
"user_id": user_id,
541+
"username": username,
542+
})
543+
.to_string();
544+
545+
match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) {
546+
Ok(result) => {
547+
channel_host::log(
548+
channel_host::LogLevel::Info,
549+
&format!(
550+
"Pairing request for user {}: code {}",
551+
user_id, result.code
552+
),
553+
);
554+
if result.created {
555+
if let Some(ctx) = reply_ctx {
556+
let _ = send_pairing_reply(ctx, &result.code);
557+
}
558+
}
559+
}
560+
Err(e) => {
561+
channel_host::log(
562+
channel_host::LogLevel::Error,
563+
&format!("Pairing upsert failed: {}", e),
564+
);
565+
}
566+
}
567+
}
568+
false
569+
}
570+
571+
/// Send a pairing code as an ephemeral Discord followup message.
572+
fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
573+
let url = format!(
574+
"https://discord.com/api/v10/webhooks/{}/{}",
575+
ctx.application_id, ctx.token
576+
);
577+
578+
let payload = serde_json::json!({
579+
"content": format!(
580+
"To pair with this bot, run: `ironclaw pairing approve discord {}`",
581+
code
582+
),
583+
"flags": 64 // Ephemeral — only visible to the sender
584+
});
585+
586+
let payload_bytes =
587+
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
588+
589+
let headers = serde_json::json!({"Content-Type": "application/json"});
590+
591+
let result = channel_host::http_request(
592+
"POST",
593+
&url,
594+
&headers.to_string(),
595+
Some(&payload_bytes),
596+
None,
597+
);
598+
599+
match result {
600+
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
601+
Ok(response) => {
602+
let body_str = String::from_utf8_lossy(&response.body);
603+
Err(format!(
604+
"Discord API error: {} - {}",
605+
response.status, body_str
606+
))
607+
}
608+
Err(e) => Err(format!("HTTP request failed: {}", e)),
609+
}
610+
}
611+
402612
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
403613
let body = serde_json::to_vec(&value).unwrap_or_default();
404614
let headers = serde_json::json!({"Content-Type": "application/json"});

channels-src/slack/slack.capabilities.json

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22
"type": "channel",
33
"name": "slack",
44
"description": "Slack Events API channel for receiving and responding to Slack messages",
5+
"setup": {
6+
"required_secrets": [
7+
{
8+
"name": "slack_bot_token",
9+
"prompt": "Enter your Slack Bot OAuth Token (xoxb-...)",
10+
"optional": false
11+
},
12+
{
13+
"name": "slack_signing_secret",
14+
"prompt": "Enter your Slack Signing Secret (from App Credentials)",
15+
"optional": false
16+
}
17+
]
18+
},
519
"capabilities": {
620
"http": {
721
"allowlist": [
@@ -33,6 +47,9 @@
3347
}
3448
},
3549
"config": {
36-
"signing_secret_name": "slack_signing_secret"
50+
"signing_secret_name": "slack_signing_secret",
51+
"owner_id": null,
52+
"dm_policy": "pairing",
53+
"allow_from": []
3754
}
3855
}

0 commit comments

Comments
 (0)