Skip to main content

ircbot_macros/
lib.rs

1//! Procedural macros for the [`ircbot`](https://docs.rs/ircbot) framework.
2//!
3//! These macros are re-exported by the `ircbot` crate — refer to its
4//! documentation for usage.
5
6use proc_macro::TokenStream;
7use proc_macro2::{Span, TokenStream as TokenStream2};
8use quote::quote;
9use syn::{
10    parse_macro_input, Expr, ExprLit, FnArg, Ident, ImplItem, ItemImpl, Lit, Meta, Pat, Type,
11};
12
13// ─── Custom parsers ──────────────────────────────────────────────────────────
14
15/// Parses `#[command("name")]` or `#[command("name", target = "...")]`
16struct CommandArgs {
17    name: String,
18    target: Option<String>,
19}
20
21impl syn::parse::Parse for CommandArgs {
22    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
23        let name: syn::LitStr = input.parse()?;
24        let mut target = None;
25        while input.peek(syn::Token![,]) {
26            let _: syn::Token![,] = input.parse()?;
27            if input.is_empty() {
28                break;
29            }
30            let key: Ident = input.parse()?;
31            let _: syn::Token![=] = input.parse()?;
32            let val: syn::LitStr = input.parse()?;
33            if key == "target" {
34                target = Some(val.value());
35            }
36        }
37        Ok(CommandArgs {
38            name: name.value(),
39            target,
40        })
41    }
42}
43
44// ─── #[bot] ──────────────────────────────────────────────────────────────────
45
46/// Derive-like attribute that turns an `impl` block into a runnable IRC bot.
47///
48/// # Panics
49///
50/// Panics at compile time if the annotated `impl` block does not use a simple
51/// (non-generic, non-path) type name, e.g. `impl MyBot { … }`.
52#[allow(clippy::too_many_lines)]
53#[proc_macro_attribute]
54pub fn bot(_attr: TokenStream, item: TokenStream) -> TokenStream {
55    let input = parse_macro_input!(item as ItemImpl);
56
57    let self_ty = &input.self_ty;
58    let struct_name = match self_ty.as_ref() {
59        Type::Path(tp) => tp
60            .path
61            .get_ident()
62            .cloned()
63            .expect("#[bot] expects a simple struct name"),
64        _ => panic!("#[bot] expects a simple struct name"),
65    };
66
67    let mut handler_entries: Vec<TokenStream2> = Vec::new();
68    let mut cleaned_methods: Vec<TokenStream2> = Vec::new();
69
70    for item in &input.items {
71        if let ImplItem::Fn(method) = item {
72            let method_name = &method.sig.ident;
73
74            // Extra args beyond &self and ctx
75            let extra_args: Vec<(String, String)> = method
76                .sig
77                .inputs
78                .iter()
79                .skip(2)
80                .filter_map(|arg| {
81                    if let FnArg::Typed(pt) = arg {
82                        let name = match pt.pat.as_ref() {
83                            Pat::Ident(pi) => pi.ident.to_string(),
84                            _ => "arg".to_string(),
85                        };
86                        let ty = match pt.ty.as_ref() {
87                            Type::Path(tp) => tp
88                                .path
89                                .segments
90                                .last()
91                                .map(|s| s.ident.to_string())
92                                .unwrap_or_default(),
93                            _ => "Unknown".to_string(),
94                        };
95                        Some((name, ty))
96                    } else {
97                        None
98                    }
99                })
100                .collect();
101
102            let mut trigger_tokens: Option<TokenStream2> = None;
103            let mut cleaned_attrs: Vec<syn::Attribute> = Vec::new();
104
105            for attr in &method.attrs {
106                let Some(ident) = attr.path().get_ident() else {
107                    cleaned_attrs.push(attr.clone());
108                    continue;
109                };
110
111                match ident.to_string().as_str() {
112                    "command" => {
113                        if let Meta::List(ml) = &attr.meta {
114                            let args: CommandArgs =
115                                syn::parse2(ml.tokens.clone()).unwrap_or(CommandArgs {
116                                    name: String::new(),
117                                    target: None,
118                                });
119                            let name = &args.name;
120                            let target_ts = opt_str_ts(args.target.as_deref());
121                            trigger_tokens = Some(quote! {
122                                ircbot::Trigger::Command {
123                                    name: #name.to_string(),
124                                    target: #target_ts,
125                                }
126                            });
127                        }
128                    }
129                    "on" => {
130                        if let Meta::List(ml) = &attr.meta {
131                            let metas_result = ml.parse_args_with(
132                                syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
133                            );
134
135                            let mut event: Option<String> = None;
136                            let mut message: Option<String> = None;
137                            let mut command_on: Option<String> = None;
138                            let mut target: Option<String> = None;
139                            let mut regex: Option<String> = None;
140                            let mut mention = false;
141                            let mut cron_interval: Option<String> = None;
142                            let mut cron_tz: Option<String> = None;
143
144                            if let Ok(metas) = metas_result {
145                                for meta in metas {
146                                    match &meta {
147                                        Meta::Path(p) if p.is_ident("mention") => {
148                                            mention = true;
149                                        }
150                                        Meta::NameValue(nv) => {
151                                            let k = nv
152                                                .path
153                                                .get_ident()
154                                                .map(ToString::to_string)
155                                                .unwrap_or_default();
156                                            if let Expr::Lit(ExprLit {
157                                                lit: Lit::Str(s), ..
158                                            }) = &nv.value
159                                            {
160                                                let v = s.value();
161                                                match k.as_str() {
162                                                    "event" => event = Some(v),
163                                                    "message" => message = Some(v),
164                                                    "command" => command_on = Some(v),
165                                                    "target" => target = Some(v),
166                                                    "regex" => regex = Some(v),
167                                                    "cron" => cron_interval = Some(v),
168                                                    "tz" => cron_tz = Some(v),
169                                                    _ => {}
170                                                }
171                                            }
172                                        }
173                                        _ => {}
174                                    }
175                                }
176                            }
177
178                            let target_ts = opt_str_ts(target.as_deref());
179                            // Precedence: message > command > event > mention > cron.
180                            // Only the first matching key wins; combining multiple
181                            // trigger types in one `#[on(...)]` is not supported.
182                            if let Some(msg_pat) = message {
183                                trigger_tokens = Some(quote! {
184                                    ircbot::Trigger::Message {
185                                        pattern: #msg_pat.to_string(),
186                                        target: #target_ts,
187                                    }
188                                });
189                            } else if let Some(cmd) = command_on {
190                                trigger_tokens = Some(quote! {
191                                    ircbot::Trigger::Command {
192                                        name: #cmd.to_string(),
193                                        target: #target_ts,
194                                    }
195                                });
196                            } else if let Some(ev) = event {
197                                let regex_ts = opt_str_ts(regex.as_deref());
198                                trigger_tokens = Some(quote! {
199                                    ircbot::Trigger::Event {
200                                        event: #ev.to_string(),
201                                        target: #target_ts,
202                                        regex: #regex_ts,
203                                    }
204                                });
205                            } else if mention {
206                                trigger_tokens = Some(quote! {
207                                    ircbot::Trigger::Mention {
208                                        target: #target_ts,
209                                    }
210                                });
211                            } else if let Some(cron_str) = cron_interval {
212                                // Validate the cron expression at compile time.
213                                if let Err(e) = cron_str.parse::<cron::Schedule>() {
214                                    panic!(
215                                        "invalid cron expression {cron_str:?}: {e}\n\
216                                         \n\
217                                         The expression must use the 6-field Quartz format \
218                                         with an optional 7th year field:\n\
219                                         \n\
220                                         sec  min  hour  day-of-month  month  day-of-week  [year]\n\
221                                         \n\
222                                         Examples:\n\
223                                         \"0 0 * * * *\"          every hour (on the minute)\n\
224                                         \"0 0 8-16 * * MON-FRI\" top of each hour, 8 a.m.–4 p.m., weekdays\n\
225                                         \"0 */15 * * * *\"        every 15 minutes\n\
226                                         \"0 0 9 * * MON\"         every Monday at 9 a.m."
227                                    );
228                                }
229                                // Validate the timezone at compile time (defaults to UTC).
230                                let tz_str = cron_tz.as_deref().unwrap_or("UTC");
231                                if let Err(e) = tz_str.parse::<chrono_tz::Tz>() {
232                                    panic!(
233                                        "invalid timezone {tz_str:?}: {e}\n\
234                                         \n\
235                                         Use an IANA timezone name such as:\n\
236                                         \"UTC\", \"America/New_York\", \"Europe/London\", \
237                                         \"Asia/Tokyo\""
238                                    );
239                                }
240                                let tz_str = tz_str.to_string();
241                                trigger_tokens = Some(quote! {
242                                    ircbot::Trigger::Cron {
243                                        schedule: #cron_str.to_string(),
244                                        tz: #tz_str.to_string(),
245                                        target: #target_ts,
246                                    }
247                                });
248                            }
249                        }
250                    }
251                    _ => {
252                        cleaned_attrs.push(attr.clone());
253                    }
254                }
255            }
256
257            if let Some(trigger) = trigger_tokens {
258                let wrapper = build_wrapper(method_name, &extra_args);
259                handler_entries.push(quote! {
260                    ircbot::HandlerEntry {
261                        trigger: #trigger,
262                        handler: std::boxed::Box::new(#wrapper),
263                    }
264                });
265
266                let mut cleaned = method.clone();
267                cleaned.attrs = cleaned_attrs;
268                cleaned_methods.push(quote! { #cleaned });
269            } else {
270                cleaned_methods.push(quote! { #method });
271            }
272        } else {
273            let it = item;
274            cleaned_methods.push(quote! { #it });
275        }
276    }
277
278    quote! {
279        pub struct #struct_name {
280            __state: std::option::Option<ircbot::State>,
281        }
282
283        impl Default for #struct_name {
284            fn default() -> Self {
285                #struct_name { __state: std::option::Option::None }
286            }
287        }
288
289        impl #struct_name {
290            /// Connect to an IRC server and return a bot ready to run.
291            ///
292            /// On Unix, if this process was started by `exec_reload` the live
293            /// TCP connection is inherited from the parent binary and no new
294            /// connection is made.  The `nick`, `server`, and `channels`
295            /// arguments are used only when no inherited connection is present.
296            pub async fn new(
297                nick: impl Into<String>,
298                server: impl AsRef<str>,
299                channels: impl IntoIterator<Item = impl Into<String>>,
300            ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
301                // On Unix, check for an inherited fd from a hot-reload exec.
302                #[cfg(unix)]
303                if let Some(state) = ircbot::State::try_inherit_from_env()? {
304                    eprintln!("[ircbot] hot-reload: resumed on inherited connection");
305                    return Ok(#struct_name { __state: Some(state) });
306                }
307
308                let state = ircbot::State::connect(
309                    nick.into(),
310                    server.as_ref(),
311                    channels.into_iter().map(|c| c.into()).collect(),
312                ).await?;
313                Ok(#struct_name { __state: Some(state) })
314            }
315
316            /// Run the bot's main event loop.
317            ///
318            /// On Unix, listens for `SIGHUP`.  When received, the current
319            /// process execs the bot binary at the same path, passing the live
320            /// TCP socket fd to the new process so the IRC connection is never
321            /// interrupted.  If the exec fails the bot continues running.
322            pub async fn main_loop(mut self) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
323                let state = self.__state.take().expect("bot already started");
324
325                #[cfg(unix)]
326                let (raw_fd, reload_nick, reload_server, reload_channels,
327                     reload_ka_interval_ms, reload_ka_timeout_ms) = (
328                    state.raw_fd,
329                    state.nick.clone(),
330                    state.server.clone(),
331                    state.channels.clone(),
332                    state.keepalive_interval().as_millis() as u64,
333                    state.keepalive_timeout().as_millis() as u64,
334                );
335
336                let bot_arc = std::sync::Arc::new(self);
337
338                // Install a SIGHUP listener that execs the new binary with the
339                // live fd inherited — zero-disconnect binary hot-reload.
340                #[cfg(unix)]
341                {
342                    tokio::spawn(async move {
343                        use tokio::signal::unix::{signal, SignalKind};
344                        match signal(SignalKind::hangup()) {
345                            Ok(mut stream) => {
346                                while stream.recv().await.is_some() {
347                                    eprintln!("[ircbot] SIGHUP — hot-reload: exec new binary");
348                                    let err = ircbot::hot_reload::exec_reload(
349                                        raw_fd,
350                                        &reload_nick,
351                                        &reload_server,
352                                        &reload_channels,
353                                        reload_ka_interval_ms,
354                                        reload_ka_timeout_ms,
355                                    );
356                                    // exec_reload only returns on failure.
357                                    eprintln!("[ircbot] hot-reload exec failed: {err}");
358                                }
359                            }
360                            Err(e) => {
361                                eprintln!("[ircbot] failed to install SIGHUP handler: {e}");
362                            }
363                        }
364                    });
365                }
366
367                ircbot::internal::run_bot(bot_arc, state, #struct_name::__handlers()).await
368            }
369
370            fn __handlers() -> Vec<ircbot::HandlerEntry<#struct_name>> {
371                vec![ #(#handler_entries),* ]
372            }
373
374            #(#cleaned_methods)*
375        }
376    }
377    .into()
378}
379
380// ─── helpers ─────────────────────────────────────────────────────────────────
381
382fn opt_str_ts(s: Option<&str>) -> TokenStream2 {
383    if let Some(v) = s {
384        quote! { Some(#v.to_string()) }
385    } else {
386        quote! { None }
387    }
388}
389
390fn build_wrapper(method_name: &Ident, extra_args: &[(String, String)]) -> TokenStream2 {
391    if extra_args.is_empty() {
392        return quote! {
393            |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
394                std::boxed::Box::pin(async move { bot.#method_name(ctx).await })
395            }
396        };
397    }
398
399    let mut extractions: Vec<TokenStream2> = Vec::new();
400    let mut call_args: Vec<TokenStream2> = Vec::new();
401    let mut str_idx = 0usize;
402
403    for (name, ty) in extra_args {
404        let ident = Ident::new(name, Span::call_site());
405        call_args.push(quote! { #ident });
406        match ty.as_str() {
407            "User" => {
408                extractions.push(quote! {
409                    let #ident = ctx.sender.clone().unwrap_or_default();
410                });
411            }
412            "String" => {
413                let idx = str_idx;
414                str_idx += 1;
415                extractions.push(quote! {
416                    let #ident: String = if !ctx.captures.is_empty() {
417                        ctx.captures.get(#idx).cloned().unwrap_or_default()
418                    } else {
419                        ctx.message_text().to_string()
420                    };
421                });
422            }
423            _ => {
424                let ty_ident = Ident::new(ty, Span::call_site());
425                extractions.push(quote! {
426                    let #ident: #ty_ident = Default::default();
427                });
428            }
429        }
430    }
431
432    quote! {
433        |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
434            std::boxed::Box::pin(async move {
435                #(#extractions)*
436                bot.#method_name(ctx, #(#call_args),*).await
437            })
438        }
439    }
440}
441
442// ─── #[command] / #[on] as standalone no-ops ─────────────────────────────────
443
444#[doc = include_str!("../docs/command.md")]
445#[proc_macro_attribute]
446pub fn command(_attr: TokenStream, item: TokenStream) -> TokenStream {
447    item
448}
449
450#[doc = include_str!("../docs/on.md")]
451#[proc_macro_attribute]
452pub fn on(_attr: TokenStream, item: TokenStream) -> TokenStream {
453    item
454}