Skip to main content

badterm_core/
script.rs

1//! Scripts: small programs, in [Rhai](https://rhai.rs), that log in to a
2//! BBS or find their way around it, as Telix's SALT and Qodem's scripts did.
3//!
4//! A script runs on its own thread. It reads what the BBS sends (as text,
5//! without the ANSI codes) and asks the front end to send, show messages
6//! or hang up through channels; the front end wakes up for its requests.
7//!
8//! ```text
9//! if wait("Login:", 20) {          // seconds; false if it never came
10//!     send(user() + "\r");
11//!     wait("Password:");
12//!     send(password() + "\r");
13//! }
14//! let which = wait_any(["[Y/n]", "Main Menu"], 30);
15//! if which == 0 { send("n\r"); }
16//! sleep(0.5);
17//! notify("Logged in to " + bbs());
18//! ```
19//!
20//! Besides those: `screen()` is the text on the screen, one line per row;
21//! `hangup()` ends the call; `print(x)` shows `x` on the status line.
22
23use std::cell::RefCell;
24use std::rc::Rc;
25use std::sync::Arc;
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
28use std::time::{Duration, Instant};
29
30use rhai::{Array, Dynamic, Engine, EvalAltResult};
31
32/// How long `wait` waits when no time is given.
33const WAIT: f64 = 30.0;
34/// The most text kept for `wait` to search.
35const KEEP: usize = 16 * 1024;
36
37/// From the front end to the script.
38pub enum ToScript {
39    /// What the BBS sent.
40    Data(Vec<u8>),
41    /// The screen's text, asked for with [`FromScript::WantScreen`].
42    Screen(String),
43}
44
45/// What a script asks of the front end.
46#[derive(Debug, PartialEq, Eq)]
47pub enum FromScript {
48    /// Send this text (in the terminal's encoding).
49    Send(String),
50    Notify(String),
51    /// Answer with [`Script::screen`].
52    WantScreen,
53    HangUp,
54    /// It finished, or failed with this message.
55    Done(Result<(), String>),
56}
57
58/// About the call, for `user()`, `password()` and `bbs()`.
59#[derive(Clone, Debug, Default)]
60pub struct CallInfo {
61    pub bbs: String,
62    pub user: String,
63    pub password: String,
64}
65
66/// A script running.
67pub struct Script {
68    pub name: String,
69    tx: Sender<ToScript>,
70    rx: Receiver<FromScript>,
71    stop: Arc<AtomicBool>,
72    done: bool,
73}
74
75impl Script {
76    /// Start `source`; `wake` is called (from the script's thread) when it
77    /// has something for [`Script::poll`].
78    pub fn start(name: &str, source: String, info: CallInfo, wake: impl Fn() + Send + 'static) -> Script {
79        let (tx, script_rx) = mpsc::channel();
80        let (script_tx, rx) = mpsc::channel();
81        let stop = Arc::new(AtomicBool::new(false));
82        let host = Host {
83            rx: script_rx,
84            tx: script_tx,
85            wake: Box::new(wake),
86            stop: stop.clone(),
87            text: String::new(),
88            esc: Esc::None,
89            info,
90        };
91        std::thread::Builder::new().name("script".into()).spawn(move || run(source, host)).expect("starting a thread");
92        Script { name: name.to_string(), tx, rx, stop, done: false }
93    }
94
95    /// What the BBS sent.
96    pub fn feed(&self, data: &[u8]) {
97        if !data.is_empty() {
98            let _ = self.tx.send(ToScript::Data(data.to_vec()));
99        }
100    }
101
102    pub fn screen(&self, text: String) {
103        let _ = self.tx.send(ToScript::Screen(text));
104    }
105
106    /// Stop it at its next step.
107    pub fn stop(&self) {
108        self.stop.store(true, Ordering::Relaxed);
109    }
110
111    /// What it asks for now.
112    pub fn poll(&mut self) -> Vec<FromScript> {
113        let out: Vec<FromScript> = self.rx.try_iter().collect();
114        if out.iter().any(|m| matches!(m, FromScript::Done(_))) {
115            self.done = true;
116        }
117        out
118    }
119
120    pub fn is_done(&self) -> bool {
121        self.done
122    }
123}
124
125impl Drop for Script {
126    fn drop(&mut self) {
127        self.stop();
128    }
129}
130
131/// Where an escape sequence being skipped is.
132#[derive(Clone, Copy)]
133enum Esc {
134    None,
135    /// After ESC.
136    Start,
137    /// In a CSI's parameters.
138    Csi,
139    /// In an OSC, APC or DCS string, until ST or BEL.
140    Str,
141    StrEnd,
142}
143
144struct Host {
145    rx: Receiver<ToScript>,
146    tx: Sender<FromScript>,
147    wake: Box<dyn Fn() + Send>,
148    stop: Arc<AtomicBool>,
149    /// What arrived since the last match.
150    text: String,
151    esc: Esc,
152    info: CallInfo,
153}
154
155type Res<T> = Result<T, Box<EvalAltResult>>;
156
157impl Host {
158    fn ask(&self, m: FromScript) {
159        let _ = self.tx.send(m);
160        (self.wake)();
161    }
162
163    fn stopped(&self) -> Res<()> {
164        if self.stop.load(Ordering::Relaxed) { Err("stopped".into()) } else { Ok(()) }
165    }
166
167    /// Take what the BBS sent as text: the ANSI codes and most control
168    /// characters go.
169    fn take(&mut self, data: &[u8]) {
170        for &b in data {
171            self.esc = match (self.esc, b) {
172                (Esc::None, 0x1B) => Esc::Start,
173                (Esc::None, b'\r' | b'\n' | b'\t') => {
174                    self.text.push(b as char);
175                    Esc::None
176                }
177                (Esc::None, 0..=0x1F | 0x7F) => Esc::None,
178                (Esc::None, _) => {
179                    self.text.push(crate::cp437::to_unicode(b));
180                    Esc::None
181                }
182                (Esc::Start, b'[') => Esc::Csi,
183                (Esc::Start, b']' | b'_' | b'P' | b'^') => Esc::Str,
184                (Esc::Start, _) => Esc::None,
185                (Esc::Csi, 0x40..=0x7E) => Esc::None,
186                (Esc::Csi, _) => Esc::Csi,
187                (Esc::Str, 0x07) => Esc::None,
188                (Esc::Str, 0x1B) => Esc::StrEnd,
189                (Esc::Str, _) => Esc::Str,
190                (Esc::StrEnd, b'\\') => Esc::None,
191                (Esc::StrEnd, _) => Esc::Str,
192            };
193        }
194        if self.text.len() > KEEP {
195            let mut cut = self.text.len() - KEEP / 2;
196            while !self.text.is_char_boundary(cut) {
197                cut += 1;
198            }
199            self.text.drain(..cut);
200        }
201    }
202
203    /// The next message, waiting up to `until`; data is taken in as it
204    /// comes. None at the deadline.
205    fn next(&mut self, until: Instant) -> Res<Option<String>> {
206        loop {
207            self.stopped()?;
208            let left = until.saturating_duration_since(Instant::now());
209            if left.is_zero() {
210                return Ok(None);
211            }
212            match self.rx.recv_timeout(left.min(Duration::from_millis(100))) {
213                Ok(ToScript::Data(d)) => {
214                    self.take(&d);
215                    return Ok(Some(String::new()));
216                }
217                Ok(ToScript::Screen(s)) => return Ok(Some(s)),
218                Err(RecvTimeoutError::Timeout) => {}
219                Err(RecvTimeoutError::Disconnected) => return Err("the call ended".into()),
220            }
221        }
222    }
223
224    /// Wait for the first of `texts` to arrive: its index, or -1.
225    fn wait_any(&mut self, texts: &[String], secs: f64) -> Res<i64> {
226        let until = Instant::now() + Duration::from_secs_f64(secs.clamp(0.0, 86_400.0));
227        loop {
228            let found = texts
229                .iter()
230                .enumerate()
231                .filter(|(_, t)| !t.is_empty())
232                .filter_map(|(i, t)| self.text.find(t.as_str()).map(|at| (at, i, at + t.len())))
233                .min();
234            if let Some((_, i, end)) = found {
235                self.text.drain(..end);
236                return Ok(i as i64);
237            }
238            if self.next(until)?.is_none() {
239                return Ok(-1);
240            }
241        }
242    }
243
244    fn screen(&mut self) -> Res<String> {
245        self.ask(FromScript::WantScreen);
246        let until = Instant::now() + Duration::from_secs(5);
247        loop {
248            match self.next(until)? {
249                None => return Ok(String::new()),
250                Some(s) if !s.is_empty() => return Ok(s),
251                Some(_) => {}
252            }
253        }
254    }
255
256    fn sleep(&mut self, secs: f64) -> Res<()> {
257        let until = Instant::now() + Duration::from_secs_f64(secs.clamp(0.0, 86_400.0));
258        while self.next(until)?.is_some() {}
259        Ok(())
260    }
261}
262
263fn run(source: String, host: Host) {
264    let tx = host.tx.clone();
265    let host = Rc::new(RefCell::new(host));
266    let result = engine(&host).run(&source).map_err(|e| match *e {
267        EvalAltResult::ErrorTerminated(..) => "stopped".to_string(),
268        EvalAltResult::ErrorRuntime(ref v, _) if v.to_string() == "stopped" => "stopped".to_string(),
269        _ => e.to_string(),
270    });
271    let result = match result {
272        Err(e) if e == "stopped" => Ok(()),
273        other => other,
274    };
275    let h = host.borrow();
276    let _ = tx.send(FromScript::Done(result));
277    (h.wake)();
278}
279
280fn engine(host: &Rc<RefCell<Host>>) -> Engine {
281    let mut e = Engine::new();
282    // A script that loops with no waits still stops when asked.
283    let h = host.clone();
284    e.on_progress(move |_| h.borrow().stop.load(Ordering::Relaxed).then_some(Dynamic::UNIT));
285    let h = host.clone();
286    e.on_print(move |s| h.borrow().ask(FromScript::Notify(s.to_string())));
287    let h = host.clone();
288    e.register_fn("send", move |s: &str| -> Res<()> {
289        let h = h.borrow();
290        h.stopped()?;
291        h.ask(FromScript::Send(s.to_string()));
292        Ok(())
293    });
294    let h = host.clone();
295    e.register_fn("notify", move |s: &str| h.borrow().ask(FromScript::Notify(s.to_string())));
296    let h = host.clone();
297    e.register_fn("hangup", move || h.borrow().ask(FromScript::HangUp));
298    let h = host.clone();
299    e.register_fn("wait", move |s: &str, secs: f64| -> Res<bool> {
300        Ok(h.borrow_mut().wait_any(&[s.to_string()], secs)? == 0)
301    });
302    let h = host.clone();
303    e.register_fn("wait", move |s: &str, secs: i64| -> Res<bool> {
304        Ok(h.borrow_mut().wait_any(&[s.to_string()], secs as f64)? == 0)
305    });
306    let h = host.clone();
307    e.register_fn("wait", move |s: &str| -> Res<bool> { Ok(h.borrow_mut().wait_any(&[s.to_string()], WAIT)? == 0) });
308    let texts = |a: Array| a.into_iter().map(|d| d.to_string()).collect::<Vec<_>>();
309    let h = host.clone();
310    e.register_fn("wait_any", move |a: Array, secs: f64| -> Res<i64> { h.borrow_mut().wait_any(&texts(a), secs) });
311    let h = host.clone();
312    e.register_fn("wait_any", move |a: Array, secs: i64| -> Res<i64> {
313        h.borrow_mut().wait_any(&texts(a), secs as f64)
314    });
315    let h = host.clone();
316    e.register_fn("wait_any", move |a: Array| -> Res<i64> { h.borrow_mut().wait_any(&texts(a), WAIT) });
317    let h = host.clone();
318    e.register_fn("sleep", move |secs: f64| -> Res<()> { h.borrow_mut().sleep(secs) });
319    let h = host.clone();
320    e.register_fn("sleep", move |secs: i64| -> Res<()> { h.borrow_mut().sleep(secs as f64) });
321    let h = host.clone();
322    e.register_fn("screen", move || -> Res<String> { h.borrow_mut().screen() });
323    let h = host.clone();
324    e.register_fn("user", move || h.borrow().info.user.clone());
325    let h = host.clone();
326    e.register_fn("password", move || h.borrow().info.password.clone());
327    let h = host.clone();
328    e.register_fn("bbs", move || h.borrow().info.bbs.clone());
329    e
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    /// Run a script against a pretend BBS: each time the script sends
337    /// something, the reply for it (if any) comes back.
338    fn talk(source: &str, greeting: &[u8], replies: &[(&str, &[u8])]) -> (Vec<FromScript>, Result<(), String>) {
339        let info = CallInfo { bbs: "Test BBS".into(), user: "sysop".into(), password: "secret".into() };
340        let mut s = Script::start("test", source.to_string(), info, || {});
341        s.feed(greeting);
342        let mut seen = Vec::new();
343        let until = Instant::now() + Duration::from_secs(10);
344        while Instant::now() < until {
345            for m in s.poll() {
346                match m {
347                    FromScript::Done(r) => return (seen, r),
348                    FromScript::Send(ref t) => {
349                        if let Some((_, r)) = replies.iter().find(|(k, _)| k == t) {
350                            s.feed(r);
351                        }
352                        seen.push(m);
353                    }
354                    FromScript::WantScreen => s.screen("Row one\nRow two".into()),
355                    m => seen.push(m),
356                }
357            }
358            std::thread::sleep(Duration::from_millis(5));
359        }
360        panic!("the script did not finish");
361    }
362
363    #[test]
364    fn logs_in_through_ansi() {
365        let script = r#"
366            if wait("Login:", 5) { send(user() + "\r"); }
367            wait("Password:");
368            send(password() + "\r");
369            let i = wait_any(["Bad password", "Main Menu"], 5);
370            if i == 1 { notify("in " + bbs()); }
371            if !wait("never", 0.2) { print("timed out"); }
372            if screen().contains("Row two") { send("ok"); }
373        "#;
374        let (seen, done) = talk(
375            script,
376            b"\x1b[2J\x1b[1;33mWelcome\r\nLogin: ",
377            &[("sysop\r", b"\x1b[0mPass\x1b[1mword: "), ("secret\r", b"\x1b]0;title\x07\r\n\x1b[32mMain Menu\r\n")],
378        );
379        assert_eq!(done, Ok(()));
380        assert_eq!(
381            seen,
382            [
383                FromScript::Send("sysop\r".into()),
384                FromScript::Send("secret\r".into()),
385                FromScript::Notify("in Test BBS".into()),
386                FromScript::Notify("timed out".into()),
387                FromScript::Send("ok".into()),
388            ]
389        );
390    }
391
392    #[test]
393    fn the_example_login_script() {
394        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../extras/scripts/login.rhai");
395        let Ok(source) = std::fs::read_to_string(path) else { return };
396        let (seen, done) = talk(
397            &source,
398            b"Welcome! Press any key to continue\r\n",
399            &[
400                ("\r", b"Login: "),
401                ("sysop\r", b"Password: "),
402                ("secret\r", b"Read new mail? [Y/n] "),
403                ("n", b"\x1b[1mMain Menu\x1b[0m\r\nCommand: "),
404            ],
405        );
406        assert_eq!(done, Ok(()));
407        assert_eq!(seen.last(), Some(&FromScript::Notify("Logged in to Test BBS".into())));
408        assert_eq!(seen.len(), 5, "{seen:?}");
409    }
410
411    #[test]
412    fn errors_and_stopping() {
413        let (_, done) = talk("let x = ;", b"", &[]);
414        assert!(done.is_err());
415        let (_, done) = talk("wait(\"x\", 0.1); hangup(); throw \"no login\";", b"", &[]);
416        assert!(done.unwrap_err().contains("no login"));
417        // A loop that never waits is stopped too.
418        let mut s = Script::start("loop", "loop { }".into(), CallInfo::default(), || {});
419        std::thread::sleep(Duration::from_millis(50));
420        s.stop();
421        let until = Instant::now() + Duration::from_secs(5);
422        while !s.is_done() && Instant::now() < until {
423            s.poll();
424            std::thread::sleep(Duration::from_millis(5));
425        }
426        assert!(s.is_done());
427    }
428}