Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
TWITCH_BOT_USERNAME=715209
TWITCH_BOT_OAUTH=oauth:YOUR_OAUTH_HERE
TWITCH_BOT_OAUTH=YOUR_OAUTH_HERE
TWITCH_APP_CLIENT_ID=YOUR_APP_CLIENT_ID_HERE
TWITCH_APP_CLIENT_SECRET=YOUR_APP_CLIENT_SECRET_HERE
45 changes: 45 additions & 0 deletions ENV FILE EDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## **Enable only replies in your own chat during shared chats & activate the Twitch chat badge, please follow these steps:**
<h1>Go to:</h1>
[https://dev.twitch.tv/console/extensions/create](https://dev.twitch.tv/console/extensions/create)

_In the app settings, set OAuth Redirect URLs to:_

`https://twitchtokengenerator.com`

<img width="1013" height="578" alt="Image" src="https://github.com/user-attachments/assets/64068ec1-c2d4-4311-a058-d4347015d575" />

_Copy credentials into .env:_
**From your Twitch app, copy the Client ID and Client Secret, and place them into your .env file:**
```
TWITCH_APP_CLIENT_ID=your_client_id_here
TWITCH_APP_CLIENT_SECRET=your_client_secret_here
```


**Generate an Access Token:**
Go to [https://twitchtokengenerator.com/](https://twitchtokengenerator.com/)

_Enter your Client ID and Client Secret from the Twitch app._

<img width="1276" height="309" alt="Image" src="https://github.com/user-attachments/assets/4ca7b6dd-207e-46ea-91fe-20da04cd757d" />

**_Under Token Scopes, either manually set the following to YES:_**
```
chat:read
chat:edit
moderation:read
channel:manage:raids
moderator:read:chatters
channel:bot
user:bot
user:read:chat
user:write:chat
```
### **_Or simply click “Select All” (recommended)._**

**Finally, press “Generate Token!”**

_Update .env with the token_

> Copy the generated **_Access Token_** and insert it into your .env file:
`TWITCH_BOT_OAUTH=your_generated_access_token`
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# NOALBS ![GitHub stars](https://img.shields.io/github/stars/NOALBS/nginx-obs-automatic-low-bitrate-switching) ![GitHub forks](https://img.shields.io/github/forks/NOALBS/nginx-obs-automatic-low-bitrate-switching)

A simple executable for all Operating Systems (Windows, Mac & Linux) to automatically switch scenes in OBS Studio/OBS.Live; based on the current bitrate fetched from your ingest server stats. NOALBS is used as a DIY tool to have your OBS Studio/OBS.Live auto switch scenes when you are either in a LOW bitrate situation or if your source disconnects completely from your ingest server (RTMP, SRT, SRTLA, RIST Etc..).

![GitHub commits since latest release](https://img.shields.io/github/commits-since/NOALBS/nginx-obs-automatic-low-bitrate-switching/latest)
Expand All @@ -16,6 +15,21 @@ A simple executable for all Operating Systems (Windows, Mac & Linux) to automati

![GitHub Sponsors](https://img.shields.io/github/sponsors/715209?logo=GitHub%20Sponsors&label=715209's%20sponsors%3A&color=%23DB61A2&link=%23)
![GitHub Sponsors](https://img.shields.io/github/sponsors/b3ck?logo=GitHub%20Sponsors&label=b3ck's%20sponsors%3A&color=%23DB61A2&link=https%3A%2F%2Fgithub.com%2Fsponsors%2Fb3ck)
<br>
---------------------------------------------------------------------------------------------------------------------------------<br>

### IMPORTANT - THERE IS A NEW UPDATE TO THE .ENV FILE! ⚠️
<!-- # There is a new update to the .env file! -->

___**The .env file has been updated with two new lines.**___
<br>
___**These lines are required!!**___

```plaintext
TWITCH_APP_CLIENT_ID=YOUR_APP_CLIENT_ID_HERE
TWITCH_APP_CLIENT_SECRET=YOUR_APP_CLIENT_SECRET_HERE
```
### [ <b> CHECK OUT THE NEW GUIDE FOR APP TOKENS IN .ENV FILE HERE 🔗 </b> ](https://github.com/NoOneNook/nginx-obs-automatic-low-bitrate-switching/blob/160f03b410bb3139d0b2554d7114d514ca9bf8f2/ENV%20FILE%20EDIT.md)

---
# Try out the new [NOALBS Cloud Alpha](https://noalbs.com/signin) (Twitch Only) today!
Expand Down Expand Up @@ -54,7 +68,6 @@ Don't feel like setting this all up by yourself?

Do you offer a similar solution or paid service? Want your link here? Message [@b3ck](https://discordapp.com/channels/@me/96991451006660608) on Discord.
- Also if your service is already listed above and you would like me to add any type of tracking to the URL so you know where your traffic is coming from send me a DM on Discord.

---

## Table of Contents
Expand Down
167 changes: 123 additions & 44 deletions src/chat/twitch.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use std::sync::Arc;
use std::collections::HashMap;

use async_trait::async_trait;
use tokio::sync::mpsc;
use tracing::{error, info, trace};
use tracing::{error, info, trace, warn};
use twitch_irc::{
ClientConfig, SecureTCPTransport, TwitchIRCClient,
login::StaticLoginCredentials,
Expand All @@ -20,17 +21,31 @@ use crate::{
pub struct Twitch {
client: TwitchIRCClient<TCPTransport<TLS>, StaticLoginCredentials>,
pub event_loop: Arc<tokio::task::JoinHandle<()>>,
client_id: String, // Client-Id matching helix_access_token
helix_access_token: String, // App or User token for Helix
bot_user_id: String, // sender_id in the Helix payload
channel_ids: HashMap<String, String>, // broadcaster_login -> broadcaster_id
http_client: reqwest::Client,
helix_ok: bool,
use_app_token: bool, // true => attempt badge mode (requires MOD in channel or channel:bot)
}

impl Twitch {
pub fn new(bot_username: String, mut oauth: String, chat_handler_tx: ChatSender) -> Self {
if let Some(oauth_without_prefix) = oauth.strip_prefix("oauth:") {
oauth = oauth_without_prefix.to_string();
}

#[allow(clippy::too_many_arguments)]
pub fn new(
bot_username: String,
oauth_for_irc: String, // expects token WITHOUT "oauth:" prefix
chat_handler_tx: ChatSender,
client_id: String,
helix_access_token: String,
bot_user_id: String,
channel_ids: HashMap<String, String>,
helix_ok: bool,
use_app_token: bool,
) -> Self {
let config = ClientConfig::new_simple(StaticLoginCredentials::new(
bot_username.to_lowercase(),
Some(oauth.to_lowercase()),
Some(oauth_for_irc),
));

let (incoming_messages, client) =
Expand All @@ -43,6 +58,13 @@ impl Twitch {
Self {
client,
event_loop: Arc::new(event_loop_handle),
client_id,
helix_access_token,
bot_user_id,
channel_ids,
http_client: reqwest::Client::new(),
helix_ok,
use_app_token,
}
}

Expand All @@ -56,47 +78,35 @@ impl Twitch {
pubsub: PubsubManager,
) {
while let Some(message) = incoming_messages.recv().await {
// println!("Received message: {:?}", message);

match message {
message::ServerMessage::RoomState(state) => {
trace!(
"user_id: {}, user_name: {}",
state.channel_id, state.channel_login
);
trace!("user_id: {}, user_name: {}", state.channel_id, state.channel_login);
pubsub.add_raid(state.channel_id, state.channel_login).await;
}
message::ServerMessage::Notice(msg) => {
if msg.message_text == "Login authentication failed" {
error!("Twitch authentication failed");

// TODO: Handle panic
// panic!("Twitch authentication failed");
} else {
trace!("NOTICE: {}", msg.message_text);
}
}
message::ServerMessage::Privmsg(msg) => {
let permission =
msg.badges
.iter()
.fold(chat::Permission::Public, |acc, badge| {
match badge.name.as_str() {
"vip" => chat::Permission::Vip,
"moderator" => chat::Permission::Mod,
"broadcaster" => chat::Permission::Admin,
_ => acc,
}
});

chat_handler_tx
.send(HandleMessage::ChatMessage(chat::ChatMessage {
platform: ChatPlatform::Twitch,
permission,
channel: msg.channel_login,
sender: msg.sender.login,
message: msg.message_text.to_owned(),
}))
.await
.unwrap();
let permission = msg.badges.iter().fold(chat::Permission::Public, |acc, badge| {
match badge.name.as_str() {
"vip" => chat::Permission::Vip,
"moderator" => chat::Permission::Mod,
"broadcaster" => chat::Permission::Admin,
_ => acc,
}
});

let _ = chat_handler_tx.send(HandleMessage::ChatMessage(chat::ChatMessage {
platform: ChatPlatform::Twitch,
permission,
channel: msg.channel_login,
sender: msg.sender.login,
message: msg.message_text.to_owned(),
})).await;
}
_ => {}
}
Expand All @@ -114,15 +124,84 @@ impl Twitch {
#[async_trait]
impl super::ChatLogic for Twitch {
async fn send_message(&self, channel: String, message: String) {
if let Err(err) = self.client.say(channel, message).await {
error!("Error sending message to twitch: {}", err);
let channel_login = channel.to_lowercase();

// Try Helix first
if self.helix_ok && !self.client_id.is_empty() && !self.helix_access_token.is_empty() && !self.bot_user_id.is_empty() {
if let Some(broadcaster_id) = self.channel_ids.get(&channel_login) {
let url = "https://api.twitch.tv/helix/chat/messages";

// With APP token we set for_source_only=false to ensure the message goes to shared chat too.
// NOTE: this parameter is illegal with User token (400), so we include it only in app mode. (See API ref.)
// https://dev.twitch.tv/docs/api/reference/#send-chat-message
let payload = if self.use_app_token {
serde_json::json!({
"broadcaster_id": broadcaster_id,
"sender_id": self.bot_user_id,
"message": message,
"for_source_only": true
})
} else {
serde_json::json!({
"broadcaster_id": broadcaster_id,
"sender_id": self.bot_user_id,
"message": message
})
};

let result = self.http_client
.post(url)
.header("Client-Id", &self.client_id)
.header("Authorization", format!("Bearer {}", self.helix_access_token))
.json(&payload)
.send()
.await;

match result {
Ok(resp) => {
if !resp.status().is_success() {
let status = resp.status();
let err_text = resp.text().await.unwrap_or_default();
error!("Helix POST /chat/messages failed ({}): {}", status, err_text);
if self.use_app_token {
// Common badge-mode causes: not MOD in the channel, or missing channel:bot grant from broadcaster.
warn!("Tip: For bot badge the bot must be MOD in the channel OR the app must have 'channel:bot' from the broadcaster. Otherwise Twitch returns 403.");
}
// Fallback to IRC
if let Err(err) = self.client.say(
channel_login.clone(),
payload["message"].as_str().unwrap_or_default().to_string()
).await {
error!("IRC fallback failed: {}", err);
}
}
}
Err(err) => {
error!("Helix error: {}", err);
if self.use_app_token {
warn!("(Badge mode) Check that the app token is valid and the bot is MOD or has 'channel:bot'.");
}
// Fallback to IRC
if let Err(err2) = self.client.say(channel_login.clone(), message.clone()).await {
error!("IRC fallback failed: {}", err2);
}
}
}
return;
} else {
warn!("Missing broadcaster_id for '{}', sending via IRC (no bot badge).", channel_login);
}
} else {
trace!("Helix disabled or missing fields — sending via IRC (no bot badge).");
}

// IRC (no badge)
if let Err(err) = self.client.say(channel_login, message).await {
error!("IRC send failed: {}", err);
}
}
}

impl Drop for Twitch {
// Abort the spawned tasks
fn drop(&mut self) {
self.event_loop.abort();
}
fn drop(&mut self) { self.event_loop.abort(); }
}
Loading