Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring"] }
teloxide = { version = "0.17", default-features = false, features = ["rustls"] }

# Twitch
twitch-irc = { version = "5.0", default-features = false, features = ["transport-tcp-rustls-webpki-roots"] }
twitch-irc = { version = "5.0", default-features = false, features = ["transport-tcp-rustls-webpki-roots", "refreshing-token-rustls-webpki-roots"] }

# Stream utilities
tokio-stream = "0.1"
Expand Down
3 changes: 3 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,9 @@ export interface CreateBindingRequest {
slack_app_token?: string;
twitch_username?: string;
twitch_oauth_token?: string;
twitch_client_id?: string;
twitch_client_secret?: string;
twitch_refresh_token?: string;
};
}

Expand Down
54 changes: 45 additions & 9 deletions interface/src/components/ChannelEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ export function ChannelEditModal({platform, name, status, open, onOpenChange}: C
request.platform_credentials = {
twitch_username: credentialInputs.twitch_username.trim(),
twitch_oauth_token: credentialInputs.twitch_oauth_token.trim(),
twitch_client_id: credentialInputs.twitch_client_id?.trim(),
twitch_client_secret: credentialInputs.twitch_client_secret?.trim(),
twitch_refresh_token: credentialInputs.twitch_refresh_token?.trim(),
};
}
saveCreds.mutate(request);
Expand Down Expand Up @@ -303,16 +306,49 @@ export function ChannelEditModal({platform, name, status, open, onOpenChange}: C
placeholder={configured ? "Enter new username to update" : "my_bot"}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">OAuth Token</label>
<Input
type="password"
value={credentialInputs.twitch_oauth_token ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, twitch_oauth_token: e.target.value})}
placeholder={configured ? "Enter new token to update" : "oauth:abc123..."}
onKeyDown={(e) => { if (e.key === "Enter") handleSaveCredentials(); }}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">Client ID</label>
<Input
value={credentialInputs.twitch_client_id ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, twitch_client_id: e.target.value})}
placeholder={configured ? "Enter new client id to update" : "your-app-client-id"}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">Client Secret</label>
<Input
type="password"
value={credentialInputs.twitch_client_secret ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, twitch_client_secret: e.target.value})}
placeholder={configured ? "Enter new client secret to update" : "your-app-client-secret"}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">OAuth Access Token</label>
<Input
type="password"
value={credentialInputs.twitch_oauth_token ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, twitch_oauth_token: e.target.value})}
placeholder={configured ? "Enter new token to update" : "abcd1234..."}
onKeyDown={(e) => { if (e.key === "Enter") handleSaveCredentials(); }}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">OAuth Refresh Token</label>
<Input
type="password"
value={credentialInputs.twitch_refresh_token ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, twitch_refresh_token: e.target.value})}
placeholder={configured ? "Enter new refresh token to update" : "refresh-token-from-twitch"}
/>
</div>
</div>
<p className="mt-1.5 text-xs text-ink-faint">
Use tokens from your Twitch application with chat:read and chat:write scopes enabled. Tokens are stored in your Spacebot instance and refreshed automatically while running.
</p>
<p className="mt-1.5 text-xs text-ink-faint">
Need help?{" "}
<a href="https://docs.spacebot.sh/twitch-setup" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
Expand Down
108 changes: 87 additions & 21 deletions interface/src/components/ChannelSettingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ export function ChannelSettingCard({
request.platform_credentials = {
twitch_username: credentialInputs.twitch_username.trim(),
twitch_oauth_token: credentialInputs.twitch_oauth_token.trim(),
twitch_client_id: credentialInputs.twitch_client_id?.trim(),
twitch_client_secret: credentialInputs.twitch_client_secret?.trim(),
twitch_refresh_token: credentialInputs.twitch_refresh_token?.trim(),
};
}
saveCreds.mutate(request);
Expand Down Expand Up @@ -611,28 +614,91 @@ function CredentialsSection({
}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">
OAuth Token
</label>
<Input
type="password"
size="lg"
value={credentialInputs.twitch_oauth_token ?? ""}
onChange={(e) =>
setCredentialInputs({
...credentialInputs,
twitch_oauth_token: e.target.value,
})
}
placeholder={
configured ? "Enter new token to update" : "oauth:abc123..."
}
onKeyDown={(e) => {
if (e.key === "Enter") onSave();
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">
Client ID
</label>
<Input
size="lg"
value={credentialInputs.twitch_client_id ?? ""}
onChange={(e) =>
setCredentialInputs({
...credentialInputs,
twitch_client_id: e.target.value,
})
}
placeholder={
configured ? "Enter new client id to update" : "your-app-client-id"
}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">
Client Secret
</label>
<Input
type="password"
size="lg"
value={credentialInputs.twitch_client_secret ?? ""}
onChange={(e) =>
setCredentialInputs({
...credentialInputs,
twitch_client_secret: e.target.value,
})
}
placeholder={
configured ? "Enter new client secret to update" : "your-app-client-secret"
}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">
OAuth Access Token
</label>
<Input
type="password"
size="lg"
value={credentialInputs.twitch_oauth_token ?? ""}
onChange={(e) =>
setCredentialInputs({
...credentialInputs,
twitch_oauth_token: e.target.value,
})
}
placeholder={
configured ? "Enter new token to update" : "abcd1234..."
}
onKeyDown={(e) => {
if (e.key === "Enter") onSave();
}}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">
OAuth Refresh Token
</label>
<Input
type="password"
size="lg"
value={credentialInputs.twitch_refresh_token ?? ""}
onChange={(e) =>
setCredentialInputs({
...credentialInputs,
twitch_refresh_token: e.target.value,
})
}
placeholder={
configured ? "Enter new refresh token to update" : "refresh-token-from-twitch"
}
/>
</div>
</div>
<p className="mt-1 text-xs text-ink-faint">
Use tokens from your Twitch application with chat:read and chat:write scopes enabled. Tokens are stored in your Spacebot instance and refreshed automatically while running.
</p>
<p className="mt-1.5 text-xs text-ink-faint">
Need help?{" "}
<a href="https://docs.spacebot.sh/twitch-setup" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
Expand Down
24 changes: 24 additions & 0 deletions src/api/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ pub(super) struct PlatformCredentials {
twitch_username: Option<String>,
#[serde(default)]
twitch_oauth_token: Option<String>,
#[serde(default)]
twitch_client_id: Option<String>,
#[serde(default)]
twitch_client_secret: Option<String>,
#[serde(default)]
twitch_refresh_token: Option<String>,
}

#[derive(Serialize)]
Expand Down Expand Up @@ -251,6 +257,9 @@ pub(super) async fn create_binding(
}
if let Some(username) = &credentials.twitch_username {
let oauth_token = credentials.twitch_oauth_token.as_deref().unwrap_or("");
let client_id = credentials.twitch_client_id.as_deref().unwrap_or("");
let client_secret = credentials.twitch_client_secret.as_deref().unwrap_or("");
let refresh_token = credentials.twitch_refresh_token.as_deref().unwrap_or("");
if !username.is_empty() && !oauth_token.is_empty() {
if doc.get("messaging").is_none() {
doc["messaging"] = toml_edit::Item::Table(toml_edit::Table::new());
Expand All @@ -267,6 +276,15 @@ pub(super) async fn create_binding(
twitch["enabled"] = toml_edit::value(true);
twitch["username"] = toml_edit::value(username.as_str());
twitch["oauth_token"] = toml_edit::value(oauth_token);
if !client_id.is_empty() {
twitch["client_id"] = toml_edit::value(client_id);
}
if !client_secret.is_empty() {
twitch["client_secret"] = toml_edit::value(client_secret);
}
if !refresh_token.is_empty() {
twitch["refresh_token"] = toml_edit::value(refresh_token);
}
new_twitch_creds = Some((username.clone(), oauth_token.to_string()));
}
}
Expand Down Expand Up @@ -457,9 +475,15 @@ pub(super) async fn create_binding(
);
std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(perms))
};
let instance_dir = state.instance_dir.load();
let token_path = instance_dir.join("twitch_token.json");
let adapter = crate::messaging::twitch::TwitchAdapter::new(
&username,
&oauth_token,
twitch_config.client_id.clone(),
twitch_config.client_secret.clone(),
twitch_config.refresh_token.clone(),
Some(token_path),
twitch_config.channels.clone(),
twitch_config.trigger_prefix.clone(),
twitch_perms,
Expand Down
6 changes: 6 additions & 0 deletions src/api/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,15 @@ pub(super) async fn toggle_platform(
&new_config.bindings,
);
let arc_swap = std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(perms));
let instance_dir = state.instance_dir.load();
let token_path = instance_dir.join("twitch_token.json");
let adapter = crate::messaging::twitch::TwitchAdapter::new(
&twitch_config.username,
&twitch_config.oauth_token,
twitch_config.client_id.clone(),
twitch_config.client_secret.clone(),
twitch_config.refresh_token.clone(),
Some(token_path),
twitch_config.channels.clone(),
twitch_config.trigger_prefix.clone(),
arc_swap,
Expand Down
30 changes: 30 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,9 @@ pub struct TwitchConfig {
pub enabled: bool,
pub username: String,
pub oauth_token: String,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub refresh_token: Option<String>,
/// Channels to join (without the # prefix).
pub channels: Vec<String>,
/// Optional prefix that triggers the bot (e.g. "!ask"). If empty, all messages are processed.
Expand Down Expand Up @@ -1525,6 +1528,9 @@ struct TomlTwitchConfig {
enabled: bool,
username: Option<String>,
oauth_token: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
refresh_token: Option<String>,
#[serde(default)]
channels: Vec<String>,
trigger_prefix: Option<String>,
Expand Down Expand Up @@ -2691,10 +2697,28 @@ impl Config {
.as_deref()
.and_then(resolve_env_value)
.or_else(|| std::env::var("TWITCH_OAUTH_TOKEN").ok())?;
let client_id = t
.client_id
.as_deref()
.and_then(resolve_env_value)
.or_else(|| std::env::var("TWITCH_CLIENT_ID").ok());
let client_secret = t
.client_secret
.as_deref()
.and_then(resolve_env_value)
.or_else(|| std::env::var("TWITCH_CLIENT_SECRET").ok());
let refresh_token = t
.refresh_token
.as_deref()
.and_then(resolve_env_value)
.or_else(|| std::env::var("TWITCH_REFRESH_TOKEN").ok());
Some(TwitchConfig {
enabled: t.enabled,
username,
oauth_token,
client_id,
client_secret,
refresh_token,
channels: t.channels,
trigger_prefix: t.trigger_prefix,
})
Expand Down Expand Up @@ -3181,6 +3205,7 @@ pub fn spawn_file_watcher(
let slack_permissions = slack_permissions.clone();
let telegram_permissions = telegram_permissions.clone();
let twitch_permissions = twitch_permissions.clone();
let instance_dir = instance_dir.clone();

rt.spawn(async move {
// Discord: start if enabled and not already running
Expand Down Expand Up @@ -3258,9 +3283,14 @@ pub fn spawn_file_watcher(
Arc::new(arc_swap::ArcSwap::from_pointee(perms))
}
};
let token_path = instance_dir.join("twitch_token.json");
let adapter = crate::messaging::twitch::TwitchAdapter::new(
&twitch_config.username,
&twitch_config.oauth_token,
twitch_config.client_id.clone(),
twitch_config.client_secret.clone(),
twitch_config.refresh_token.clone(),
Some(token_path),
twitch_config.channels.clone(),
twitch_config.trigger_prefix.clone(),
perms,
Expand Down
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1449,9 +1449,14 @@ async fn initialize_agents(
if let Some(twitch_config) = &config.messaging.twitch
&& twitch_config.enabled
{
let twitch_token_path = config.instance_dir.join("twitch_token.json");
let adapter = spacebot::messaging::twitch::TwitchAdapter::new(
&twitch_config.username,
&twitch_config.oauth_token,
twitch_config.client_id.clone(),
twitch_config.client_secret.clone(),
twitch_config.refresh_token.clone(),
Some(twitch_token_path),
twitch_config.channels.clone(),
twitch_config.trigger_prefix.clone(),
twitch_permissions
Expand Down
Loading
Loading