//! Twitch IRC channel adapter. //! //! Connects to Twitch's IRC gateway (`irc.chat.twitch.tv`) over plain TCP and //! implements the IRC protocol for sending and receiving chat messages. Handles //! PING/PONG keepalive, channel joins, and PRIVMSG parsing. use crate::types::{ split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser, }; use async_trait::async_trait; use chrono::Utc; use futures::Stream; use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpStream; use tokio::sync::{mpsc, watch}; use tracing::{info, warn}; use zeroize::Zeroizing; const TWITCH_IRC_HOST: &str = "irc.chat.twitch.tv"; const TWITCH_IRC_PORT: u16 = 6667; const MAX_MESSAGE_LEN: usize = 500; /// Twitch IRC channel adapter. /// /// Connects to Twitch chat via the IRC protocol and bridges messages to the /// OpenFang channel system. Supports multiple channels simultaneously. pub struct TwitchAdapter { /// SECURITY: OAuth token is zeroized on drop. oauth_token: Zeroizing, /// Twitch channels to join (without the '#' prefix). channels: Vec, /// Bot's IRC nickname. nick: String, /// Shutdown signal. shutdown_tx: Arc>, shutdown_rx: watch::Receiver, } impl TwitchAdapter { /// Create a new Twitch adapter. /// /// # Arguments /// * `oauth_token` - Twitch OAuth token (without the "oauth:" prefix; it will be added). /// * `channels` - Channel names to join (without '#' prefix). /// * `nick` - Bot's IRC nickname (must match the token owner's Twitch username). pub fn new(oauth_token: String, channels: Vec, nick: String) -> Self { let (shutdown_tx, shutdown_rx) = watch::channel(false); Self { oauth_token: Zeroizing::new(oauth_token), channels, nick, shutdown_tx: Arc::new(shutdown_tx), shutdown_rx, } } /// Format the OAuth token for the IRC PASS command. fn pass_string(&self) -> String { let token = self.oauth_token.as_str(); if token.starts_with("oauth:") { format!("PASS {token}\r\n") } else { format!("PASS oauth:{token}\r\n") } } } /// Parse an IRC PRIVMSG line into its components. /// /// Expected format: `:nick!user@host PRIVMSG #channel :message text` /// Returns `(nick, channel, message)` on success. fn parse_privmsg(line: &str) -> Option<(String, String, String)> { // Must start with ':' if !line.starts_with(':') { return None; } let without_prefix = &line[1..]; let parts: Vec<&str> = without_prefix.splitn(2, ' ').collect(); if parts.len() < 2 { return None; } let nick = parts[0].split('!').next()?.to_string(); let rest = parts[1]; // Expect "PRIVMSG #channel :message" if !rest.starts_with("PRIVMSG ") { return None; } let after_cmd = &rest[8..]; // skip "PRIVMSG " let channel_end = after_cmd.find(' ')?; let channel = after_cmd[..channel_end].to_string(); let msg_start = after_cmd[channel_end..].find(':')?; let message = after_cmd[channel_end + msg_start + 1..].to_string(); Some((nick, channel, message)) } #[async_trait] impl ChannelAdapter for TwitchAdapter { fn name(&self) -> &str { "twitch" } fn channel_type(&self) -> ChannelType { ChannelType::Custom("twitch".to_string()) } async fn start( &self, ) -> Result + Send>>, Box> { info!("Twitch adapter connecting to {TWITCH_IRC_HOST}:{TWITCH_IRC_PORT}"); let (tx, rx) = mpsc::channel::(256); let pass = self.pass_string(); let nick_cmd = format!("NICK {}\r\n", self.nick); let join_cmds: Vec = self .channels .iter() .map(|ch| { let ch = ch.trim_start_matches('#'); format!("JOIN #{ch}\r\n") }) .collect(); let bot_nick = self.nick.to_lowercase(); let mut shutdown_rx = self.shutdown_rx.clone(); tokio::spawn(async move { let mut backoff = Duration::from_secs(1); loop { if *shutdown_rx.borrow() { break; } // Connect to Twitch IRC let stream = match TcpStream::connect((TWITCH_IRC_HOST, TWITCH_IRC_PORT)).await { Ok(s) => s, Err(e) => { warn!("Twitch: connection failed: {e}, retrying in {backoff:?}"); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(60)); continue; } }; let (read_half, mut write_half) = stream.into_split(); let mut reader = BufReader::new(read_half); // Authenticate if write_half.write_all(pass.as_bytes()).await.is_err() { warn!("Twitch: failed to send PASS"); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(60)); continue; } if write_half.write_all(nick_cmd.as_bytes()).await.is_err() { warn!("Twitch: failed to send NICK"); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(60)); continue; } // Join channels for join in &join_cmds { if write_half.write_all(join.as_bytes()).await.is_err() { warn!("Twitch: failed to send JOIN"); break; } } info!("Twitch IRC connected and joined channels"); backoff = Duration::from_secs(1); // Read loop let should_reconnect = loop { let mut line = String::new(); let read_result = tokio::select! { _ = shutdown_rx.changed() => { info!("Twitch adapter shutting down"); let _ = write_half.write_all(b"QUIT :Shutting down\r\n").await; return; } result = reader.read_line(&mut line) => result, }; match read_result { Ok(0) => { info!("Twitch IRC connection closed"); break true; } Ok(_) => {} Err(e) => { warn!("Twitch IRC read error: {e}"); break true; } } let line = line.trim_end_matches('\n').trim_end_matches('\r'); // Handle PING if line.starts_with("PING") { let pong = line.replacen("PING", "PONG", 1); let _ = write_half.write_all(format!("{pong}\r\n").as_bytes()).await; continue; } // Parse PRIVMSG if let Some((sender_nick, channel, message)) = parse_privmsg(line) { // Skip own messages if sender_nick.to_lowercase() == bot_nick { continue; } if message.is_empty() { continue; } let msg_content = if message.starts_with('/') || message.starts_with('!') { let trimmed = message.trim_start_matches('/').trim_start_matches('!'); let parts: Vec<&str> = trimmed.splitn(2, ' ').collect(); let cmd = parts[0]; let args: Vec = parts .get(1) .map(|a| a.split_whitespace().map(String::from).collect()) .unwrap_or_default(); ChannelContent::Command { name: cmd.to_string(), args, } } else { ChannelContent::Text(message.clone()) }; let channel_msg = ChannelMessage { channel: ChannelType::Custom("twitch".to_string()), platform_message_id: uuid::Uuid::new_v4().to_string(), sender: ChannelUser { platform_id: channel.clone(), display_name: sender_nick, openfang_user: None, }, content: msg_content, target_agent: None, timestamp: Utc::now(), is_group: true, // Twitch channels are always group thread_id: None, metadata: HashMap::new(), }; if tx.send(channel_msg).await.is_err() { return; } } }; if !should_reconnect || *shutdown_rx.borrow() { break; } warn!("Twitch: reconnecting in {backoff:?}"); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(60)); } info!("Twitch IRC loop stopped"); }); Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx))) } async fn send( &self, user: &ChannelUser, content: ChannelContent, ) -> Result<(), Box> { let channel = &user.platform_id; let text = match content { ChannelContent::Text(text) => text, _ => "(Unsupported content type)".to_string(), }; // Connect briefly to send the message // In production, a persistent write connection would be maintained. let stream = TcpStream::connect((TWITCH_IRC_HOST, TWITCH_IRC_PORT)).await?; let (_reader, mut writer) = stream.into_split(); writer.write_all(self.pass_string().as_bytes()).await?; writer .write_all(format!("NICK {}\r\n", self.nick).as_bytes()) .await?; // Wait briefly for auth to complete tokio::time::sleep(Duration::from_millis(500)).await; let chunks = split_message(&text, MAX_MESSAGE_LEN); for chunk in chunks { let msg = format!("PRIVMSG {channel} :{chunk}\r\n"); writer.write_all(msg.as_bytes()).await?; } writer.write_all(b"QUIT\r\n").await?; Ok(()) } async fn stop(&self) -> Result<(), Box> { let _ = self.shutdown_tx.send(true); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_twitch_adapter_creation() { let adapter = TwitchAdapter::new( "test-oauth-token".to_string(), vec!["testchannel".to_string()], "openfang_bot".to_string(), ); assert_eq!(adapter.name(), "twitch"); assert_eq!( adapter.channel_type(), ChannelType::Custom("twitch".to_string()) ); } #[test] fn test_twitch_pass_string_with_prefix() { let adapter = TwitchAdapter::new("oauth:abc123".to_string(), vec![], "bot".to_string()); assert_eq!(adapter.pass_string(), "PASS oauth:abc123\r\n"); } #[test] fn test_twitch_pass_string_without_prefix() { let adapter = TwitchAdapter::new("abc123".to_string(), vec![], "bot".to_string()); assert_eq!(adapter.pass_string(), "PASS oauth:abc123\r\n"); } #[test] fn test_parse_privmsg_valid() { let line = ":nick123!user@host PRIVMSG #channel :Hello world!"; let (nick, channel, message) = parse_privmsg(line).unwrap(); assert_eq!(nick, "nick123"); assert_eq!(channel, "#channel"); assert_eq!(message, "Hello world!"); } #[test] fn test_parse_privmsg_no_message() { // Missing colon before message let line = ":nick!user@host PRIVMSG #channel"; assert!(parse_privmsg(line).is_none()); } #[test] fn test_parse_privmsg_not_privmsg() { let line = ":server 001 bot :Welcome"; assert!(parse_privmsg(line).is_none()); } #[test] fn test_parse_privmsg_command() { let line = ":user!u@h PRIVMSG #ch :!help me"; let (nick, channel, message) = parse_privmsg(line).unwrap(); assert_eq!(nick, "user"); assert_eq!(channel, "#ch"); assert_eq!(message, "!help me"); } #[test] fn test_parse_privmsg_empty_prefix() { let line = "PING :tmi.twitch.tv"; assert!(parse_privmsg(line).is_none()); } }