1use 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
13struct 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#[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 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 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 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 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 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 #[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 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 #[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 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
380fn 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#[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}