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