初始化提交
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
This commit is contained in:
385
crates/openfang-channels/src/twitch.rs
Normal file
385
crates/openfang-channels/src/twitch.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
//! 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<String>,
|
||||
/// Twitch channels to join (without the '#' prefix).
|
||||
channels: Vec<String>,
|
||||
/// Bot's IRC nickname.
|
||||
nick: String,
|
||||
/// Shutdown signal.
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
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<String>, 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<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
info!("Twitch adapter connecting to {TWITCH_IRC_HOST}:{TWITCH_IRC_PORT}");
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let pass = self.pass_string();
|
||||
let nick_cmd = format!("NICK {}\r\n", self.nick);
|
||||
let join_cmds: Vec<String> = 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<String> = 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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user