use std::collections::{HashMap, HashSet};
use std::io::ErrorKind;
use std::net::TcpStream;
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use std::thread;
use std::time::Duration;
use heatsync_core::i18n::{tr, trf};
use tungstenite::stream::MaybeTlsStream;
use tungstenite::Message as WsMsg;
const IRC_URL: &str = "wss://irc-ws.chat.twitch.tv:443";
const READ_TIMEOUT: Duration = Duration::from_millis(400);
pub type Send = (String, String, Option<String>);
#[derive(Debug, PartialEq, Eq)]
pub struct Note {
pub channel: Option<String>,
pub text: String,
}
pub fn notice_text(msg_id: &str) -> Option<&'static str> {
Some(match msg_id {
"msg_followersonly" => tr("input.notice_msg_followersonly"),
"msg_followersonly_followed" => tr("input.notice_msg_followersonly_followed"),
"msg_followersonly_zero" => tr("input.notice_msg_followersonly_zero"),
"msg_subsonly" => tr("input.notice_msg_subsonly"),
"msg_emoteonly" => tr("input.notice_msg_emoteonly"),
"msg_slowmode" => tr("input.notice_msg_slowmode"),
"msg_r9k" => tr("input.notice_msg_r9k"),
"msg_duplicate" => tr("input.notice_msg_duplicate"),
"msg_banned" => tr("input.notice_msg_banned"),
"msg_timedout" => tr("input.notice_msg_timedout"),
"msg_rejected" => tr("input.notice_msg_rejected"),
"msg_rejected_mandatory" => tr("input.notice_msg_rejected_mandatory"),
"msg_channel_suspended" => tr("input.notice_msg_channel_suspended"),
"msg_channel_blocked" => tr("input.notice_msg_channel_blocked"),
"msg_verified_email" => tr("input.notice_msg_verified_email"),
"msg_requires_verified_phone_number" => {
tr("input.notice_msg_requires_verified_phone_number")
}
"no_permission" => tr("input.notice_no_permission"),
"unrecognized_cmd" => tr("input.notice_unrecognized_cmd"),
"tos_ban" => tr("input.notice_tos_ban"),
_ => return None,
})
}
fn msg_id(line: &str) -> Option<&str> {
let tags = line.strip_prefix('@')?.split(' ').next()?;
tags.split(';').find_map(|kv| kv.strip_prefix("msg-id="))
}
pub fn classify_notice(line: &str) -> Option<Note> {
let (_, after) = line.split_once(" NOTICE ")?;
let (target, text) = after.split_once(" :").unwrap_or((after, ""));
let channel = target.trim().strip_prefix('#').map(|c| c.to_lowercase());
let text = text.trim_end();
let id = msg_id(line);
let text = match id.and_then(notice_text) {
Some(t) => t.to_string(),
None if id.is_some_and(|i| i.starts_with("msg_")) && !text.is_empty() => {
trf("input.twitch_says", &[("text", text)])
}
None => return None,
};
Some(Note { channel, text })
}
fn from_server(line: &str) -> Option<&str> {
let line = match line.strip_prefix('@') {
Some(tagged) => tagged.split_once(' ')?.1,
None => line,
};
line.strip_prefix(":tmi.twitch.tv ")
}
fn is_auth_failure(line: &str) -> bool {
from_server(line)
.and_then(|r| r.strip_prefix("NOTICE "))
.and_then(|r| r.split_once(" :"))
.is_some_and(|(_, msg)| {
msg.starts_with("Login authentication failed")
|| msg.starts_with("Improperly formatted auth")
|| msg.starts_with("Login unsuccessful")
})
}
fn is_welcome(line: &str) -> bool {
from_server(line).is_some_and(|r| r.starts_with("001 "))
}
#[derive(Default)]
pub struct Gate {
authed: bool,
pending: Vec<Send>,
last_wire: HashMap<String, String>,
}
impl Gate {
pub fn submit(&mut self, s: Send) -> Option<Send> {
if self.authed {
Some(s)
} else {
self.pending.push(s);
None
}
}
pub fn open(&mut self) -> Vec<Send> {
self.authed = true;
std::mem::take(&mut self.pending)
}
pub fn close(&mut self) {
self.authed = false;
}
pub fn fail(&mut self) -> Vec<Send> {
self.authed = false;
std::mem::take(&mut self.pending)
}
}
pub fn spawn(user: String, oauth: String) -> (Sender<Send>, Receiver<Note>) {
let (tx, rx) = mpsc::channel();
let (note_tx, note_rx) = mpsc::channel();
thread::spawn(move || run(user, oauth, rx, note_tx));
(tx, note_rx)
}
fn run(user: String, oauth: String, rx: Receiver<Send>, notes: Sender<Note>) {
let mut backoff = Duration::from_secs(1);
let mut gate = Gate::default();
loop {
gate.close();
match session(&user, &oauth, &rx, ¬es, &mut gate) {
End::Closed => return, End::AuthFailed => {
let _ = notes.send(Note {
channel: None,
text: tr("input.twitch_auth_failed").into(),
});
for (chan, _, _) in gate.fail() {
let _ = notes.send(Note {
channel: Some(chan.to_lowercase()),
text: tr("input.twitch_not_sent").into(),
});
}
while let Ok((chan, _, _)) = rx.recv() {
let _ = notes.send(Note {
channel: Some(chan.to_lowercase()),
text: tr("input.twitch_not_sent").into(),
});
}
return;
}
End::Dropped => {
thread::sleep(backoff);
backoff = (backoff * 2).min(Duration::from_secs(15));
}
}
}
}
enum End {
Closed,
Dropped,
AuthFailed,
}
fn session(
user: &str,
oauth: &str,
rx: &Receiver<Send>,
notes: &Sender<Note>,
gate: &mut Gate,
) -> End {
let mut ws = match tungstenite::connect(IRC_URL) {
Ok((ws, _)) => ws,
Err(_) => return End::Dropped,
};
if let Some(sock) = tcp_of(&mut ws) {
let _ = sock.set_read_timeout(Some(READ_TIMEOUT));
}
for cmd in [
"CAP REQ :twitch.tv/tags".to_string(),
format!("PASS oauth:{oauth}"),
format!("NICK {}", user.to_lowercase()),
] {
if ws.send(WsMsg::Text(format!("{cmd}\r\n"))).is_err() {
return End::Dropped;
}
}
let mut joined: HashSet<String> = HashSet::new();
loop {
match ws.read() {
Ok(WsMsg::Text(t)) => {
for line in t.as_str().lines() {
if line.starts_with("PING") {
let _ = ws.send(WsMsg::Text("PONG :tmi.twitch.tv\r\n".into()));
} else if is_welcome(line) {
for (chan, text, reply) in gate.open() {
if post(
&mut ws,
&mut joined,
&mut gate.last_wire,
&chan,
&text,
reply.as_deref(),
)
.is_err()
{
return End::Dropped;
}
}
} else if is_auth_failure(line) {
return End::AuthFailed;
} else if let Some(note) = classify_notice(line) {
let _ = notes.send(note);
}
}
}
Ok(WsMsg::Ping(p)) => {
let _ = ws.send(WsMsg::Pong(p));
}
Ok(WsMsg::Close(_)) => return End::Dropped,
Ok(_) => {}
Err(tungstenite::Error::Io(e))
if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {}
Err(_) => return End::Dropped,
}
loop {
match rx.try_recv() {
Ok(send) => {
if let Some((chan, text, reply)) = gate.submit(send) {
if post(
&mut ws,
&mut joined,
&mut gate.last_wire,
&chan,
&text,
reply.as_deref(),
)
.is_err()
{
return End::Dropped;
}
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return End::Closed,
}
}
}
}
fn privmsg(chan: &str, text: &str, reply: Option<&str>) -> String {
match reply.filter(|id| !id.is_empty() && !id.contains([' ', ';', '\r', '\n'])) {
Some(id) => format!("@reply-parent-msg-id={id} PRIVMSG #{chan} :{text}\r\n"),
None => format!("PRIVMSG #{chan} :{text}\r\n"),
}
}
fn post(
ws: &mut tungstenite::WebSocket<MaybeTlsStream<TcpStream>>,
joined: &mut HashSet<String>,
last_wire: &mut HashMap<String, String>,
channel: &str,
text: &str,
reply: Option<&str>,
) -> Result<(), ()> {
let chan = channel.to_lowercase();
if joined.insert(chan.clone()) && ws.send(WsMsg::Text(format!("JOIN #{chan}\r\n"))).is_err() {
return Err(());
}
let wire = heatsync_core::dupe::bypass(text, last_wire.get(&chan).map(String::as_str));
ws.send(WsMsg::Text(privmsg(&chan, &wire, reply)))
.map_err(|_| ())?;
last_wire.insert(chan, wire);
Ok(())
}
fn tcp_of(ws: &mut tungstenite::WebSocket<MaybeTlsStream<TcpStream>>) -> Option<&TcpStream> {
match ws.get_mut() {
MaybeTlsStream::Plain(s) => Some(s),
MaybeTlsStream::Rustls(s) => Some(s.get_ref()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_ext_msg_id_has_words() {
for id in [
"msg_followersonly",
"msg_followersonly_followed",
"msg_followersonly_zero",
"msg_subsonly",
"msg_emoteonly",
"msg_slowmode",
"msg_r9k",
"msg_duplicate",
"msg_banned",
"msg_timedout",
"msg_rejected",
"msg_rejected_mandatory",
"msg_channel_suspended",
"msg_channel_blocked",
"msg_verified_email",
"msg_requires_verified_phone_number",
"no_permission",
"unrecognized_cmd",
"tos_ban",
] {
assert!(notice_text(id).is_some(), "{id}");
}
assert_eq!(
notice_text("msg_slowmode"),
Some("slow mode — please wait a moment")
);
assert_eq!(notice_text("host_on"), None);
}
#[test]
fn notice_names_channel_and_reason() {
let l = "@msg-id=msg_banned :tmi.twitch.tv NOTICE #Streamer :You are permanently banned from talking in streamer.";
assert_eq!(
classify_notice(l),
Some(Note {
channel: Some("streamer".into()),
text: "you are banned from this channel".into()
})
);
}
#[test]
fn unknown_refusals_pass_twitchs_own_words_but_chatter_stays_quiet() {
let l = "@msg-id=msg_something_new :tmi.twitch.tv NOTICE #c :Nope : really.";
assert_eq!(classify_notice(l).unwrap().text, "twitch: Nope : really.");
assert_eq!(
classify_notice("@msg-id=host_on :tmi.twitch.tv NOTICE #c :Now hosting x."),
None
);
assert_eq!(classify_notice(":tmi.twitch.tv 001 me :Welcome"), None);
assert_eq!(
classify_notice("@msg-id=msg_banned :tmi.twitch.tv NOTICE * :x")
.unwrap()
.channel,
None
);
}
#[test]
fn auth_failure_and_welcome_detection() {
assert!(is_auth_failure(
":tmi.twitch.tv NOTICE * :Login authentication failed"
));
assert!(is_auth_failure(
":tmi.twitch.tv NOTICE * :Improperly formatted auth"
));
assert!(is_welcome(":tmi.twitch.tv 001 me :Welcome, GLHF!"));
assert!(!is_welcome("PING :tmi.twitch.tv"));
}
#[test]
fn a_chatter_saying_the_words_is_neither_a_refusal_nor_a_welcome() {
let said =
|t: &str| format!("@badges=;color= :amy!amy@amy.tmi.twitch.tv PRIVMSG #forsen :{t}");
assert!(!is_auth_failure(&said("Login authentication failed")));
assert!(!is_auth_failure(&said("Improperly formatted auth lol")));
assert!(!is_welcome(&said("room 001 is open")));
assert!(is_auth_failure(
"@msg-id=x :tmi.twitch.tv NOTICE * :Login unsuccessful"
));
}
fn s(c: &str, t: &str) -> Send {
(c.into(), t.into(), None)
}
#[test]
fn a_reply_carries_the_parent_id_tag_and_a_bad_id_is_dropped() {
assert_eq!(
privmsg("c", "hi", Some("abc-123")),
"@reply-parent-msg-id=abc-123 PRIVMSG #c :hi\r\n"
);
assert_eq!(privmsg("c", "hi", None), "PRIVMSG #c :hi\r\n");
for bad in ["", "a b", "a;b", "a\r\nJOIN #x"] {
assert_eq!(
privmsg("c", "hi", Some(bad)),
"PRIVMSG #c :hi\r\n",
"{bad:?}"
);
}
}
#[test]
fn gate_queues_until_welcome_then_releases_in_order() {
let mut g = Gate::default();
assert_eq!(g.submit(s("a", "1")), None);
assert_eq!(g.submit(s("a", "2")), None);
assert_eq!(g.open(), vec![s("a", "1"), s("a", "2")]);
assert_eq!(g.submit(s("a", "3")), Some(s("a", "3")));
}
#[test]
fn a_queued_send_is_released_once_however_often_the_welcome_is_seen() {
let mut g = Gate::default();
assert_eq!(g.submit(s("a", "1")), None);
assert_eq!(g.open(), vec![s("a", "1")]);
assert_eq!(
g.open(),
vec![],
"a repeated welcome has nothing left to flush"
);
g.close(); assert_eq!(g.open(), vec![], "and neither does the next connection");
}
#[test]
fn gate_keeps_queue_across_a_reconnect_and_fails_loud() {
let mut g = Gate::default();
g.open();
g.close(); assert_eq!(g.submit(s("a", "1")), None, "unauthenticated again");
assert_eq!(g.fail(), vec![s("a", "1")]);
assert_eq!(g.fail(), vec![], "nothing left to report twice");
}
}