Skip to main content

heatsync_core/
slash.rs

1//! slash commands typed in the composer, the way every chat client does it.
2//!
3//! the rule that matters: we only claim commands a CLIENT owns — joining,
4//! parting, quitting, and the heatsync-only /w /dm /op /re (twitch dropped
5//! chat-line whispers, so `/w` has nothing else to mean). everything else is the platform's (`/ban`, `/timeout`,
6//! `/me`, `/vip`, `/raid` …) and goes out verbatim, so moderating from here
7//! behaves exactly like typing into twitch or kick directly. claiming a name
8//! the platform also uses would silently break a mod action, which is why the
9//! list below is short and stays short.
10
11/// what a line typed into the composer turns out to be.
12#[derive(Debug, PartialEq)]
13pub enum Cmd {
14    Join(String),
15    /// `/open <channel>` — look at a stream in the one unsaved temp tab.
16    Open(String),
17    /// `/keep` — promote the temp tab to a saved one.
18    Keep,
19    /// leave a named channel, or the focused one when `None`.
20    Part(Option<String>),
21    Quit,
22    /// open (or focus) a virtual tab: `/mentions`, `/feed`, `/whispers`.
23    Tab(crate::TabKind),
24    /// `/w <user> <text>` — a twitch whisper through heatsync.
25    Whisper(String, String),
26    /// `/dm <user> <text>` — a heatsync direct message.
27    Dm(String, String),
28    /// `/op <text>` — post to the heatsync feed.
29    Op(String),
30    /// `/re <text>` — reply to the last OP you posted.
31    Re(String),
32    /// `/block <emote>` — hide that exact emote everywhere.
33    Block(String),
34    /// `/unblock <emote>` — bring it back.
35    Unblock(String),
36    /// `/blocked` — list what's hidden.
37    Blocked,
38    /// `/sharelayout` (`/sl`, `/share`) — copy the open channels as a heatsync.org/m/ link.
39    Share,
40    /// `/layout <link>` — open the channels in a share link.
41    Layout(String),
42    /// send these bytes to the platform as an ordinary message.
43    Send(String),
44    /// a client command we recognise but that was typed without its argument.
45    Usage(&'static str),
46}
47
48/// parse one composer line. anything that isn't ours comes back as `Send`, so
49/// platform commands are never swallowed.
50pub fn parse(input: &str) -> Cmd {
51    let line = input.trim();
52    // `//x` escapes: sends a literal `/x`, the only way to start a message with
53    // a slash. same convention chatterino uses.
54    if let Some(rest) = line.strip_prefix("//") {
55        return Cmd::Send(format!("/{rest}"));
56    }
57    let Some(body) = line.strip_prefix('/') else {
58        return Cmd::Send(line.to_string());
59    };
60    let mut it = body.splitn(2, char::is_whitespace);
61    let verb = it.next().unwrap_or_default().to_lowercase();
62    let arg = it.next().map(str::trim).filter(|s| !s.is_empty());
63    match verb.as_str() {
64        "join" | "j" => match arg {
65            Some(a) => Cmd::Join(a.to_string()),
66            None => Cmd::Usage("usage: /join <channel>  (or kick:<channel>)"),
67        },
68        "open" => match arg {
69            Some(a) => Cmd::Open(a.to_string()),
70            None => Cmd::Usage("usage: /open <channel>  (or kick:<channel>)"),
71        },
72        "keep" => Cmd::Keep,
73        "part" | "leave" | "close" => Cmd::Part(arg.map(str::to_string)),
74        "quit" | "q" | "exit" => Cmd::Quit,
75        "mentions" | "feed" | "whispers" => match crate::TabKind::parse(&verb) {
76            Some(k) => Cmd::Tab(k),
77            None => Cmd::Send(line.to_string()),
78        },
79        "w" | "whisper" | "dm" => {
80            let mut p = arg.unwrap_or_default().splitn(2, char::is_whitespace);
81            let to = p.next().unwrap_or_default();
82            let text = p.next().map(str::trim).unwrap_or_default();
83            match (verb == "dm", to.is_empty() || text.is_empty()) {
84                (_, true) if verb == "dm" => Cmd::Usage("usage: /dm <user> <message>"),
85                (_, true) => Cmd::Usage("usage: /w <user> <message>"),
86                (true, _) => Cmd::Dm(to.into(), text.into()),
87                _ => Cmd::Whisper(to.into(), text.into()),
88            }
89        }
90        "block" | "blockemote" => match arg {
91            Some(a) => Cmd::Block(a.to_string()),
92            None => Cmd::Usage("usage: /block <emote>  (/unblock <emote> · /blocked lists them)"),
93        },
94        "unblock" | "unblockemote" => match arg {
95            Some(a) => Cmd::Unblock(a.to_string()),
96            None => Cmd::Usage("usage: /unblock <emote>  (/blocked lists what's hidden)"),
97        },
98        "blocked" | "blocks" => Cmd::Blocked,
99        "sharelayout" | "sl" | "share" => Cmd::Share,
100        "layout" => match arg {
101            Some(a) => Cmd::Layout(a.to_string()),
102            None => Cmd::Usage("usage: /layout <heatsync.org/m/ link>"),
103        },
104        "op" => match arg {
105            Some(a) => Cmd::Op(a.to_string()),
106            None => Cmd::Usage("usage: /op <text>  (posts to the heatsync feed)"),
107        },
108        "re" => match arg {
109            Some(a) => Cmd::Re(a.to_string()),
110            None => Cmd::Usage("usage: /re <text>  (replies to your last OP)"),
111        },
112        // not ours — the platform's problem, and it must arrive untouched.
113        _ => Cmd::Send(line.to_string()),
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn join_takes_a_channel() {
123        assert_eq!(parse("/join hot"), Cmd::Join("hot".into()));
124        assert_eq!(parse("/j xqc"), Cmd::Join("xqc".into()));
125        assert_eq!(
126            parse("/join kick:trainwreckstv"),
127            Cmd::Join("kick:trainwreckstv".into())
128        );
129        assert_eq!(parse("  /join   hot  "), Cmd::Join("hot".into()));
130    }
131
132    #[test]
133    fn emote_block_commands_are_ours() {
134        assert_eq!(parse("/block Kappa"), Cmd::Block("Kappa".into()));
135        assert_eq!(parse("/unblock  Kappa "), Cmd::Unblock("Kappa".into()));
136        assert_eq!(parse("/blocked"), Cmd::Blocked);
137        assert!(matches!(parse("/block"), Cmd::Usage(_)));
138        assert!(matches!(parse("/unblock"), Cmd::Usage(_)));
139    }
140
141    #[test]
142    fn open_and_keep_are_ours() {
143        assert_eq!(parse("/open xqc"), Cmd::Open("xqc".into()));
144        assert_eq!(parse("/open kick:abc"), Cmd::Open("kick:abc".into()));
145        assert!(matches!(parse("/open"), Cmd::Usage(_)));
146        assert_eq!(parse("/keep"), Cmd::Keep);
147    }
148
149    #[test]
150    fn join_without_an_argument_explains_itself() {
151        assert!(matches!(parse("/join"), Cmd::Usage(_)));
152        assert!(matches!(parse("/join   "), Cmd::Usage(_)));
153    }
154
155    #[test]
156    fn part_and_quit_have_the_usual_aliases() {
157        assert_eq!(parse("/part"), Cmd::Part(None));
158        assert_eq!(parse("/leave"), Cmd::Part(None));
159        assert_eq!(parse("/part xqc"), Cmd::Part(Some("xqc".into())));
160        assert_eq!(parse("/quit"), Cmd::Quit);
161        assert_eq!(parse("/q"), Cmd::Quit);
162    }
163
164    #[test]
165    fn virtual_tabs_open_by_name() {
166        use crate::TabKind;
167        assert_eq!(parse("/mentions"), Cmd::Tab(TabKind::Mentions));
168        assert_eq!(parse("/feed"), Cmd::Tab(TabKind::Feed));
169        assert_eq!(parse("/Whispers"), Cmd::Tab(TabKind::Whispers));
170    }
171
172    #[test]
173    fn share_and_layout_are_ours() {
174        assert_eq!(parse("/share"), Cmd::Share);
175        assert_eq!(parse("/sharelayout"), Cmd::Share);
176        assert_eq!(parse("/sl"), Cmd::Share);
177        assert_eq!(
178            parse("/layout https://heatsync.org/m/t:a"),
179            Cmd::Layout("https://heatsync.org/m/t:a".into())
180        );
181        assert!(matches!(parse("/layout"), Cmd::Usage(_)));
182    }
183
184    #[test]
185    fn whispers_dms_and_feed_posts_are_ours() {
186        assert_eq!(
187            parse("/w bob hi there"),
188            Cmd::Whisper("bob".into(), "hi there".into())
189        );
190        assert_eq!(
191            parse("/whisper bob  hi"),
192            Cmd::Whisper("bob".into(), "hi".into())
193        );
194        assert_eq!(parse("/dm cat yo"), Cmd::Dm("cat".into(), "yo".into()));
195        assert_eq!(parse("/op gm all"), Cmd::Op("gm all".into()));
196        assert_eq!(parse("/re agreed"), Cmd::Re("agreed".into()));
197        for bare in ["/w", "/w bob", "/dm", "/dm cat", "/op", "/re"] {
198            assert!(matches!(parse(bare), Cmd::Usage(_)), "{bare}");
199        }
200        // `//w` still sends a literal /w
201        assert_eq!(parse("//w bob hi"), Cmd::Send("/w bob hi".into()));
202    }
203
204    #[test]
205    fn verbs_are_case_insensitive() {
206        assert_eq!(parse("/JOIN hot"), Cmd::Join("hot".into()));
207        assert_eq!(parse("/Quit"), Cmd::Quit);
208    }
209
210    /// the one that must never regress: claiming a platform command would break
211    /// moderating from the TUI, silently.
212    #[test]
213    fn platform_commands_pass_through_untouched() {
214        for line in [
215            "/ban someguy",
216            "/timeout someguy 600 spam",
217            "/me waves",
218            "/vip friend",
219            "/unban someguy",
220            "/mod someguy",
221            "/raid xqc",
222            "/clear",
223            "/slow 30",
224            "/emoteonly",
225            "/followers 10m",
226            "/announce hello chat",
227        ] {
228            assert_eq!(
229                parse(line),
230                Cmd::Send(line.into()),
231                "{line} must reach the platform"
232            );
233        }
234    }
235
236    #[test]
237    fn ordinary_messages_are_untouched() {
238        assert_eq!(parse("hello chat"), Cmd::Send("hello chat".into()));
239        assert_eq!(parse("PagMan"), Cmd::Send("PagMan".into()));
240        // a bare slash isn't a command
241        assert_eq!(parse("/"), Cmd::Send("/".into()));
242    }
243
244    #[test]
245    fn double_slash_sends_a_literal_slash() {
246        assert_eq!(parse("//join hot"), Cmd::Send("/join hot".into()));
247        assert_eq!(parse("//ban"), Cmd::Send("/ban".into()));
248    }
249}