@@ -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+
127150struct DiscordChannel ;
128151
129152impl 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
354421fn 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+
402612fn 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" } ) ;
0 commit comments