xdcc_request/
lib.rs

1#![doc = include_str!("../readme.md")]
2
3use std::net::{IpAddr, Ipv4Addr};
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use futures_util::Stream;
8use irc::client::Client;
9use irc::client::data::Config;
10use irc::error::{Error, Result};
11use irc::proto::Message;
12use names::Generator;
13
14/// Internal engine state, shared across requests.
15struct InnerEngine {
16    /// Name generator for IRC nicknames.
17    names: Mutex<Generator<'static>>,
18    /// Timeout duration for IRC responses.
19    timeout: Duration,
20}
21
22impl Default for InnerEngine {
23    fn default() -> Self {
24        Self {
25            names: Default::default(),
26            timeout: Duration::from_secs(30),
27        }
28    }
29}
30
31impl std::fmt::Debug for InnerEngine {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_struct(stringify!(InnerEngine))
34            .field("timeout", &self.timeout)
35            .finish_non_exhaustive()
36    }
37}
38
39impl InnerEngine {
40    /// Generate the next unique IRC nickname.
41    fn next_name(&self) -> Option<String> {
42        if let Ok(mut lock) = self.names.lock() {
43            lock.next()
44        } else {
45            None
46        }
47    }
48}
49
50/// A clonable interface to create and manage IRC XDCC requests.
51#[derive(Clone, Debug, Default)]
52pub struct Engine(Arc<InnerEngine>);
53
54impl Engine {
55    /// Create a new XDCC `Request` using the given parameters.
56    ///
57    /// # Arguments
58    ///
59    /// * `server` - IRC server address.
60    /// * `channel` - IRC channel to join.
61    /// * `botname` - Bot's nickname to send the XDCC request to.
62    /// * `packnum` - XDCC pack number.
63    pub fn create_request(
64        &self,
65        server: impl Into<String>,
66        channel: impl Into<String>,
67        botname: impl Into<String>,
68        packnum: u64,
69    ) -> Request {
70        Request {
71            inner: self.0.clone(),
72            info: RequestInfo {
73                server: server.into(),
74                channel: channel.into(),
75                botname: botname.into(),
76                packnum,
77            },
78        }
79    }
80}
81
82/// Information needed to perform a XDCC request.
83#[derive(Clone, Debug)]
84pub struct RequestInfo {
85    /// IRC server address.
86    pub server: String,
87    /// IRC channel to join.
88    pub channel: String,
89    /// Bot nickname to send request to.
90    pub botname: String,
91    /// XDCC pack number.
92    pub packnum: u64,
93}
94
95/// A single XDCC request created from an `Engine`.
96#[derive(Debug)]
97pub struct Request {
98    inner: Arc<InnerEngine>,
99    info: RequestInfo,
100}
101
102/// Waits for the first private message from the IRC server.
103///
104/// Returns `Ok(())` if a `PRIVMSG` is received, or an error if the stream ends or fails.
105async fn wait_for_first_private_message(
106    mut stream: impl Stream<Item = Result<Message>> + Unpin,
107) -> Result<()> {
108    use futures_util::StreamExt;
109
110    while let Some(message) = stream.next().await.transpose()? {
111        if matches!(message.command, irc::proto::Command::PRIVMSG(_, _)) {
112            return Ok(());
113        }
114    }
115
116    Err(Error::AsyncChannelClosed)
117}
118
119/// Waits for a DCC SEND response from the IRC bot.
120///
121/// Returns a parsed [`Response`] or an error if the stream ends or times out.
122async fn wait_for_dcc_response(
123    mut stream: impl Stream<Item = Result<Message>> + Unpin,
124) -> Result<Response> {
125    use futures_util::StreamExt;
126
127    while let Some(message) = stream.next().await.transpose()? {
128        let irc::proto::Command::PRIVMSG(_botname, cmd) = message.command else {
129            continue;
130        };
131        if let Some(res) = Response::decode(&cmd) {
132            return Ok(res);
133        }
134    }
135
136    Err(Error::AsyncChannelClosed)
137}
138
139impl Request {
140    /// Executes the XDCC request by connecting to the IRC server,
141    /// identifying, joining the channel, sending the XDCC command,
142    /// and awaiting the DCC SEND response.
143    ///
144    /// # Errors
145    ///
146    /// Returns a [`Result`] with IRC or timeout errors.
147    pub async fn execute(&self) -> Result<Response> {
148        let config = Config {
149            nickname: self.inner.next_name(),
150            server: Some(self.info.server.clone()),
151            channels: vec![self.info.channel.clone()],
152            ..Default::default()
153        };
154
155        let mut client = Client::from_config(config).await?;
156        client.identify()?;
157
158        let mut stream = client.stream()?;
159        tokio::time::timeout(
160            self.inner.timeout,
161            wait_for_first_private_message(&mut stream),
162        )
163        .await
164        .map_err(|_| Error::PingTimeout)??;
165
166        client.send_privmsg(
167            self.info.botname.as_str(),
168            format!("xdcc send #{}", self.info.packnum),
169        )?;
170
171        tokio::time::timeout(self.inner.timeout, wait_for_dcc_response(&mut stream))
172            .await
173            .map_err(|_| Error::PingTimeout)?
174    }
175}
176
177/// Represents a parsed DCC SEND response from the IRC bot.
178#[derive(Clone, Debug)]
179pub struct Response {
180    /// The name of the file being sent.
181    pub filename: String,
182    /// IP address of the sender.
183    pub address: IpAddr,
184    /// Port number used for the DCC transfer.
185    pub port: u16,
186    /// Size of the file in bytes.
187    pub filesize: u64,
188}
189
190impl Response {
191    /// Decodes a `DCC SEND` command message into a `Response`.
192    ///
193    /// Returns `Some(Response)` if decoding is successful, or `None` if parsing fails.
194    pub fn decode(msg: &str) -> Option<Self> {
195        let msg = msg.trim().strip_prefix("DCC SEND ")?;
196
197        let (msg, filesize) = msg.rsplit_once(" ")?;
198        let filesize = filesize.parse::<u64>().ok()?;
199
200        let (msg, port) = msg.rsplit_once(" ")?;
201        let port = port.parse::<u16>().ok()?;
202
203        let (msg, ip) = msg.rsplit_once(" ")?;
204        let ip = ip.parse::<u32>().ok()?;
205        let ip = Ipv4Addr::from(ip);
206
207        let filename = msg.trim_matches('"');
208        let filename = filename.replace("\\\"", "\"");
209
210        Some(Self {
211            filename,
212            address: IpAddr::V4(ip),
213            port,
214            filesize,
215        })
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use futures_util::stream;
222    use irc::proto::{Command, Message};
223
224    #[tokio::test]
225    async fn should_wait_for_dcc_message() {
226        let mut stream = stream::iter(vec![Ok(Message {
227            tags: None,
228            prefix: None,
229            command: Command::PRIVMSG(
230                "botname".into(),
231                "DCC SEND \"ubuntu.iso\" 3232235777 5000 1048576".into(),
232            ),
233        })]);
234        let res = super::wait_for_dcc_response(&mut stream).await.unwrap();
235        assert_eq!(res.filename, "ubuntu.iso");
236    }
237
238    #[tokio::test]
239    async fn should_wait_for_private_message() {
240        let mut stream = stream::iter(vec![
241            Ok(Message {
242                tags: None,
243                prefix: None,
244                command: Command::PING(Default::default(), Default::default()),
245            }),
246            Ok(Message {
247                tags: None,
248                prefix: None,
249                command: Command::PRIVMSG("botname".into(), "hello world".into()),
250            }),
251        ]);
252        super::wait_for_first_private_message(&mut stream)
253            .await
254            .unwrap();
255    }
256
257    #[tokio::test]
258    async fn should_fail_if_no_private_message() {
259        let mut stream = stream::iter(vec![Ok(Message {
260            tags: None,
261            prefix: None,
262            command: Command::PING(Default::default(), Default::default()),
263        })]);
264        super::wait_for_first_private_message(&mut stream)
265            .await
266            .unwrap_err();
267    }
268
269    #[test_case::test_case("DCC SEND \"foo.txt\" 3232235777 5000 1048576", "foo.txt", 5000, 1048576; "simple")]
270    #[test_case::test_case("DCC SEND \"hello\\\"world.txt\" 3232235777 5000 1048576", "hello\"world.txt", 5000, 1048576; "with quotes")]
271    #[test_case::test_case("DCC SEND \"foo bar baz.txt\" 3232235777 5000 1048576", "foo bar baz.txt", 5000, 1048576; "filename with spaces")]
272    fn should_decode_dcc_msg(msg: &str, fname: &str, port: u16, size: u64) {
273        let res = super::Response::decode(msg).unwrap();
274        assert_eq!(res.filename, fname);
275        assert_eq!(res.port, port);
276        assert_eq!(res.filesize, size);
277    }
278}