@@ -7,12 +7,30 @@ mod reactions;
77mod setup;
88mod stt;
99
10+ use clap:: Parser ;
1011use serenity:: prelude:: * ;
1112use std:: collections:: HashSet ;
1213use std:: path:: PathBuf ;
1314use std:: sync:: Arc ;
1415use tracing:: info;
1516
17+ #[ derive( Parser ) ]
18+ #[ command( name = "openab" ) ]
19+ #[ command( about = "Discord bot that manages ACP agent sessions" , long_about = None ) ]
20+ enum Commands {
21+ /// Run the bot (default)
22+ Run {
23+ /// Config file path (default: config.toml)
24+ config : Option < String > ,
25+ } ,
26+ /// Launch the interactive setup wizard
27+ Setup {
28+ /// Output file path for generated config (default: config.toml)
29+ #[ arg( short, long) ]
30+ output : Option < String > ,
31+ } ,
32+ }
33+
1634#[ tokio:: main]
1735async fn main ( ) -> anyhow:: Result < ( ) > {
1836 tracing_subscriber:: fmt ( )
@@ -22,94 +40,95 @@ async fn main() -> anyhow::Result<()> {
2240 )
2341 . init ( ) ;
2442
25- // Setup wizard mode
26- if let Some ( arg) = std:: env:: args ( ) . nth ( 1 ) {
27- if arg == "setup" {
28- setup:: run_setup ( None ) ?;
43+ let cmd = Commands :: parse ( ) ;
44+
45+ match cmd {
46+ Commands :: Setup { output } => {
47+ setup:: run_setup ( output. map ( PathBuf :: from) ) ?;
2948 return Ok ( ( ) ) ;
3049 }
31- }
32-
33- let config_path = std:: env:: args ( )
34- . nth ( 1 )
35- . map ( PathBuf :: from)
36- . unwrap_or_else ( || PathBuf :: from ( "config.toml" ) ) ;
37-
38- let mut cfg = config:: load_config ( & config_path) ?;
39- info ! (
40- agent_cmd = %cfg. agent. command,
41- pool_max = cfg. pool. max_sessions,
42- channels = ?cfg. discord. allowed_channels,
43- users = ?cfg. discord. allowed_users,
44- reactions = cfg. reactions. enabled,
45- "config loaded"
46- ) ;
47-
48- let pool = Arc :: new ( acp:: SessionPool :: new ( cfg. agent , cfg. pool . max_sessions ) ) ;
49- let ttl_secs = cfg. pool . session_ttl_hours * 3600 ;
50-
51- let allowed_channels = parse_id_set ( & cfg. discord . allowed_channels , "allowed_channels" ) ?;
52- let allowed_users = parse_id_set ( & cfg. discord . allowed_users , "allowed_users" ) ?;
53- info ! ( channels = allowed_channels. len( ) , users = allowed_users. len( ) , "parsed allowlists" ) ;
54-
55- // Resolve STT config before constructing handler (auto-detect mutates cfg.stt)
56- if cfg. stt . enabled {
57- if cfg. stt . api_key . is_empty ( ) && cfg. stt . base_url . contains ( "groq.com" ) {
58- if let Ok ( key) = std:: env:: var ( "GROQ_API_KEY" ) {
59- if !key. is_empty ( ) {
60- info ! ( "stt.api_key not set, using GROQ_API_KEY from environment" ) ;
61- cfg. stt . api_key = key;
50+ Commands :: Run { config } => {
51+ let config_path = config
52+ . map ( PathBuf :: from)
53+ . unwrap_or_else ( || PathBuf :: from ( "config.toml" ) ) ;
54+
55+ let mut cfg = config:: load_config ( & config_path) ?;
56+ info ! (
57+ agent_cmd = %cfg. agent. command,
58+ pool_max = cfg. pool. max_sessions,
59+ channels = ?cfg. discord. allowed_channels,
60+ users = ?cfg. discord. allowed_users,
61+ reactions = cfg. reactions. enabled,
62+ "config loaded"
63+ ) ;
64+
65+ let pool = Arc :: new ( acp:: SessionPool :: new ( cfg. agent , cfg. pool . max_sessions ) ) ;
66+ let ttl_secs = cfg. pool . session_ttl_hours * 3600 ;
67+
68+ let allowed_channels = parse_id_set ( & cfg. discord . allowed_channels , "allowed_channels" ) ?;
69+ let allowed_users = parse_id_set ( & cfg. discord . allowed_users , "allowed_users" ) ?;
70+ info ! ( channels = allowed_channels. len( ) , users = allowed_users. len( ) , "parsed allowlists" ) ;
71+
72+ // Resolve STT config before constructing handler (auto-detect mutates cfg.stt)
73+ if cfg. stt . enabled {
74+ if cfg. stt . api_key . is_empty ( ) && cfg. stt . base_url . contains ( "groq.com" ) {
75+ if let Ok ( key) = std:: env:: var ( "GROQ_API_KEY" ) {
76+ if !key. is_empty ( ) {
77+ info ! ( "stt.api_key not set, using GROQ_API_KEY from environment" ) ;
78+ cfg. stt . api_key = key;
79+ }
80+ }
81+ }
82+ if cfg. stt . api_key . is_empty ( ) {
83+ anyhow:: bail!( "stt.enabled = true but no API key found — set stt.api_key in config or export GROQ_API_KEY" ) ;
6284 }
85+ info ! ( model = %cfg. stt. model, base_url = %cfg. stt. base_url, "STT enabled" ) ;
6386 }
64- }
65- if cfg. stt . api_key . is_empty ( ) {
66- anyhow:: bail!( "stt.enabled = true but no API key found — set stt.api_key in config or export GROQ_API_KEY" ) ;
67- }
68- info ! ( model = %cfg. stt. model, base_url = %cfg. stt. base_url, "STT enabled" ) ;
69- }
7087
71- let handler = discord:: Handler {
72- pool : pool. clone ( ) ,
73- allowed_channels,
74- allowed_users,
75- reactions_config : cfg. reactions ,
76- stt_config : cfg. stt . clone ( ) ,
77- } ;
78-
79- let intents = GatewayIntents :: GUILD_MESSAGES
80- | GatewayIntents :: MESSAGE_CONTENT
81- | GatewayIntents :: GUILDS ;
82-
83- let mut client = Client :: builder ( & cfg. discord . bot_token , intents)
84- . event_handler ( handler)
85- . await ?;
86-
87- // Spawn cleanup task
88- let cleanup_pool = pool. clone ( ) ;
89- let cleanup_handle = tokio:: spawn ( async move {
90- loop {
91- tokio:: time:: sleep ( std:: time:: Duration :: from_secs ( 60 ) ) . await ;
92- cleanup_pool. cleanup_idle ( ttl_secs) . await ;
88+ let handler = discord:: Handler {
89+ pool : pool. clone ( ) ,
90+ allowed_channels,
91+ allowed_users,
92+ reactions_config : cfg. reactions ,
93+ stt_config : cfg. stt . clone ( ) ,
94+ } ;
95+
96+ let intents = GatewayIntents :: GUILD_MESSAGES
97+ | GatewayIntents :: MESSAGE_CONTENT
98+ | GatewayIntents :: GUILDS ;
99+
100+ let mut client = Client :: builder ( & cfg. discord . bot_token , intents)
101+ . event_handler ( handler)
102+ . await ?;
103+
104+ // Spawn cleanup task
105+ let cleanup_pool = pool. clone ( ) ;
106+ let cleanup_handle = tokio:: spawn ( async move {
107+ loop {
108+ tokio:: time:: sleep ( std:: time:: Duration :: from_secs ( 60 ) ) . await ;
109+ cleanup_pool. cleanup_idle ( ttl_secs) . await ;
110+ }
111+ } ) ;
112+
113+ // Run bot until SIGINT/SIGTERM
114+ let shard_manager = client. shard_manager . clone ( ) ;
115+ let shutdown_pool = pool. clone ( ) ;
116+ tokio:: spawn ( async move {
117+ tokio:: signal:: ctrl_c ( ) . await . ok ( ) ;
118+ info ! ( "shutdown signal received" ) ;
119+ shard_manager. shutdown_all ( ) . await ;
120+ } ) ;
121+
122+ info ! ( "starting discord bot" ) ;
123+ client. start ( ) . await ?;
124+
125+ // Cleanup
126+ cleanup_handle. abort ( ) ;
127+ shutdown_pool. shutdown ( ) . await ;
128+ info ! ( "openab shut down" ) ;
129+ Ok ( ( ) )
93130 }
94- } ) ;
95-
96- // Run bot until SIGINT/SIGTERM
97- let shard_manager = client. shard_manager . clone ( ) ;
98- let shutdown_pool = pool. clone ( ) ;
99- tokio:: spawn ( async move {
100- tokio:: signal:: ctrl_c ( ) . await . ok ( ) ;
101- info ! ( "shutdown signal received" ) ;
102- shard_manager. shutdown_all ( ) . await ;
103- } ) ;
104-
105- info ! ( "starting discord bot" ) ;
106- client. start ( ) . await ?;
107-
108- // Cleanup
109- cleanup_handle. abort ( ) ;
110- shutdown_pool. shutdown ( ) . await ;
111- info ! ( "openab shut down" ) ;
112- Ok ( ( ) )
131+ }
113132}
114133
115134fn parse_id_set ( raw : & [ String ] , label : & str ) -> anyhow:: Result < HashSet < u64 > > {
0 commit comments