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 // A constructor that takes a pre-built state and attaches no live
357 // connection. Only meaningful when the bot has a `state` field, so it is
358 // emitted solely in the `state = Type` case. This is the supported entry
359 // point for unit-testing handlers (see `ircbot::testing`): it bypasses the
360 // `Default` impl, which would build state via `Default::default()` — wrong
361 // for any state that opens files, sockets, or other real resources.
362 let from_state_method = match &args.state {
363 Some(ty) => quote! {
364 /// Construct the bot from a pre-built `state`, with no live IRC
365 /// connection attached.
366 ///
367 /// This is the intended way to unit-test handlers. Handlers take
368 /// `&self` and reach the connection only when they send a reply,
369 /// which in tests is captured by a
370 /// [`TestContext`](ircbot::testing::TestContext) instead — so a bot
371 /// built this way can drive handlers directly without ever touching
372 /// the network.
373 ///
374 /// Prefer this over [`Default::default`] whenever your state type's
375 /// `Default` does real work (opening a database, reading config,
376 /// connecting to a service): `from_state` lets the test build a
377 /// purpose-made state — an in-memory store, a temp-dir fixture —
378 /// and inject it directly.
379 ///
380 /// # Example
381 ///
382 /// ```rust,no_run
383 /// # use ircbot::{bot, Context, Result};
384 /// # use ircbot::testing::TestContext;
385 /// #[derive(Default)]
386 /// struct State { greeting: String }
387 ///
388 /// #[bot(state = State)]
389 /// impl Greeter {
390 /// #[on(mention)]
391 /// async fn hello(&self, ctx: Context, _text: String) -> Result {
392 /// ctx.reply(self.state.greeting.clone())
393 /// }
394 /// }
395 ///
396 /// #[tokio::test]
397 /// async fn replies_with_configured_greeting() {
398 /// let bot = Greeter::from_state(State { greeting: "hi!".into() });
399 /// let mut tc = TestContext::channel("#test", "alice", "greeter: yo");
400 /// bot.hello(tc.take_ctx(), "yo".into()).await.unwrap();
401 /// // `reply` prefixes the sender's nick in a channel.
402 /// assert_eq!(tc.next_reply().as_deref(), Some("PRIVMSG #test :alice, hi!\r\n"));
403 /// }
404 /// ```
405 pub fn from_state(state: #ty) -> Self {
406 #struct_name { __state: std::option::Option::None, state }
407 }
408 },
409 None => quote! {},
410 };
411
412 quote! {
413 pub struct #struct_name {
414 __state: std::option::Option<ircbot::State>,
415 #state_field_decl
416 }
417
418 impl Default for #struct_name {
419 fn default() -> Self {
420 #struct_name { __state: std::option::Option::None #state_field_init }
421 }
422 }
423
424 impl #struct_name {
425 /// Connect to an IRC server and return a bot ready to run.
426 ///
427 /// On Unix, if this process was started by `exec_reload` the live
428 /// TCP connection is inherited from the parent binary and no new
429 /// connection is made. The `nick`, `server`, and `channels`
430 /// arguments are used only when no inherited connection is present.
431 pub async fn new(
432 nick: impl Into<String>,
433 server: impl AsRef<str>,
434 channels: impl IntoIterator<Item = impl Into<String>>,
435 ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
436 // On Unix, check for an inherited fd from a hot-reload exec.
437 #[cfg(unix)]
438 if let Some(state) = ircbot::State::try_inherit_from_env()? {
439 eprintln!("[ircbot] hot-reload: resumed on inherited connection");
440 return Ok(#struct_name { __state: Some(state) #state_field_init });
441 }
442
443 let state = ircbot::State::connect(
444 nick.into(),
445 server.as_ref(),
446 channels.into_iter().map(|c| ircbot::Channel::from(c.into())).collect(),
447 ).await?;
448 Ok(#struct_name { __state: Some(state) #state_field_init })
449 }
450
451 #from_state_method
452
453 /// Set a custom CTCP `VERSION` reply.
454 ///
455 /// By default the bot answers CTCP `VERSION` with
456 /// `ircbot <crate-version>`. Call this (before `main_loop`) to reply
457 /// with your own identifier instead. The value is re-applied on a
458 /// `SIGHUP` hot-reload, since the builder runs again on startup.
459 #[must_use]
460 pub fn with_ctcp_version(mut self, version: impl Into<String>) -> Self {
461 if let Some(state) = self.__state.take() {
462 self.__state = Some(state.with_ctcp_version(version));
463 }
464 self
465 }
466
467 /// Run the bot's main event loop.
468 ///
469 /// On Unix, listens for `SIGHUP`. When received, the current
470 /// process execs the bot binary at the same path, passing the live
471 /// TCP socket fd to the new process so the IRC connection is never
472 /// interrupted. If the exec fails the bot continues running.
473 pub async fn main_loop(mut self) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
474 let state = self.__state.take().expect("bot already started");
475
476 #[cfg(unix)]
477 let (raw_fd, reload_nick, reload_server, reload_channels,
478 reload_ka_interval_ms, reload_ka_timeout_ms) = (
479 state.raw_fd,
480 state.nick.as_str().to_string(),
481 state.server.clone(),
482 state.channels.iter().map(|c| c.as_str().to_string()).collect::<std::vec::Vec<String>>(),
483 state.keepalive_interval().as_millis() as u64,
484 state.keepalive_timeout().as_millis() as u64,
485 );
486
487 let bot_arc = std::sync::Arc::new(self);
488
489 // Install a SIGHUP listener that execs the new binary with the
490 // live fd inherited — zero-disconnect binary hot-reload.
491 #[cfg(unix)]
492 {
493 tokio::spawn(async move {
494 use tokio::signal::unix::{signal, SignalKind};
495 match signal(SignalKind::hangup()) {
496 Ok(mut stream) => {
497 while stream.recv().await.is_some() {
498 eprintln!("[ircbot] SIGHUP — hot-reload: exec new binary");
499 let err = ircbot::hot_reload::exec_reload(
500 raw_fd,
501 &reload_nick,
502 &reload_server,
503 &reload_channels,
504 reload_ka_interval_ms,
505 reload_ka_timeout_ms,
506 );
507 // exec_reload only returns on failure.
508 eprintln!("[ircbot] hot-reload exec failed: {err}");
509 }
510 }
511 Err(e) => {
512 eprintln!("[ircbot] failed to install SIGHUP handler: {e}");
513 }
514 }
515 });
516 }
517
518 ircbot::internal::run_bot(bot_arc, state, #struct_name::__handlers()).await
519 }
520
521 fn __handlers() -> Vec<ircbot::HandlerEntry<#struct_name>> {
522 vec![ #(#handler_entries),* ]
523 }
524
525 #(#cleaned_methods)*
526 }
527 }
528 .into()
529}
530
531// ─── helpers ─────────────────────────────────────────────────────────────────
532
533fn opt_str_ts(s: Option<&str>) -> TokenStream2 {
534 if let Some(v) = s {
535 quote! { Some(#v.to_string()) }
536 } else {
537 quote! { None }
538 }
539}
540
541fn build_wrapper(method_name: &Ident, extra_args: &[(String, String)]) -> TokenStream2 {
542 if extra_args.is_empty() {
543 return quote! {
544 |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
545 std::boxed::Box::pin(async move { bot.#method_name(ctx).await })
546 }
547 };
548 }
549
550 let mut extractions: Vec<TokenStream2> = Vec::new();
551 let mut call_args: Vec<TokenStream2> = Vec::new();
552 let mut str_idx = 0usize;
553
554 for (name, ty) in extra_args {
555 let ident = Ident::new(name, Span::call_site());
556 call_args.push(quote! { #ident });
557 match ty.as_str() {
558 "User" => {
559 extractions.push(quote! {
560 let #ident = ctx.sender.clone().unwrap_or_default();
561 });
562 }
563 "String" => {
564 let idx = str_idx;
565 str_idx += 1;
566 extractions.push(quote! {
567 let #ident: String = if !ctx.captures.is_empty() {
568 ctx.captures.get(#idx).cloned().unwrap_or_default()
569 } else {
570 ctx.message_text().to_string()
571 };
572 });
573 }
574 _ => {
575 let ty_ident = Ident::new(ty, Span::call_site());
576 extractions.push(quote! {
577 let #ident: #ty_ident = Default::default();
578 });
579 }
580 }
581 }
582
583 quote! {
584 |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
585 std::boxed::Box::pin(async move {
586 #(#extractions)*
587 bot.#method_name(ctx, #(#call_args),*).await
588 })
589 }
590 }
591}
592
593// ─── #[command] / #[on] as standalone no-ops ─────────────────────────────────
594
595#[doc = include_str!("../docs/command.md")]
596#[proc_macro_attribute]
597pub fn command(_attr: TokenStream, item: TokenStream) -> TokenStream {
598 item
599}
600
601#[doc = include_str!("../docs/on.md")]
602#[proc_macro_attribute]
603pub fn on(_attr: TokenStream, item: TokenStream) -> TokenStream {
604 item
605}