Skip to content

Commit 0e897e7

Browse files
feat(bot): Twitch Bot v2
1 parent f4ca3ca commit 0e897e7

18 files changed

Lines changed: 633 additions & 0 deletions

File tree

bot/css/authorize/alternative.css

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
body {
2+
> div.authorize > dialog.token {
3+
padding: 1.5em;
4+
5+
border: none;
6+
border-radius: 2em;
7+
8+
outline: none;
9+
10+
text-align: center;
11+
12+
background-color: #1c1c1c;
13+
color: white;
14+
15+
&::backdrop {
16+
background-color: transparent;
17+
}
18+
19+
> form {
20+
margin: 0;
21+
22+
display: flex;
23+
flex-direction: column;
24+
25+
gap: 1.25em;
26+
27+
> div.title {
28+
font-size: 1.5em;
29+
font-weight: 600;
30+
}
31+
32+
> div.error:not(:empty) {
33+
padding: .5em;
34+
35+
font-weight: 600;
36+
37+
background: #e45d4d;
38+
}
39+
input {
40+
padding: .5em 1em;
41+
42+
border: none;
43+
44+
&[type=text], &[type=password] {
45+
color: inherit;
46+
background-color: transparent;
47+
}
48+
49+
&[type=submit] {
50+
border-radius: 1em;
51+
52+
color: black;
53+
background-color: white;
54+
55+
&:disabled {
56+
background-color: #d6d6d6;
57+
}
58+
}
59+
}
60+
}
61+
}
62+
}

bot/css/authorize/index.css

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
@import 'alternative.css';
2+
3+
4+
body > div.authorize {
5+
height: 100%;
6+
7+
display: flex;
8+
flex-direction: column;
9+
align-items: center;
10+
justify-content: center;
11+
12+
&:has(> div.error:not(:empty)) {
13+
gap: 1em;
14+
}
15+
16+
> div.error:not(:empty) {
17+
padding: .75em 1.25em;
18+
19+
border-radius: 1em;
20+
21+
color: white;
22+
background-color: #e45d4d;
23+
}
24+
25+
> a.twitch {
26+
padding: 1em 1.25em;
27+
28+
border-radius: 2em;
29+
30+
text-decoration: none;
31+
font-weight: 600;
32+
33+
color: white;
34+
background-color: #6441a5;
35+
}
36+
}

bot/css/index.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
@import 'authorize/index.css';
2+
3+
body {
4+
margin: 0;
5+
6+
font-family: sans-serif;
7+
8+
color: #fff;
9+
10+
dialog {
11+
border: none;
12+
border-radius: 1em;
13+
14+
outline-color: transparent;
15+
16+
color: inherit;
17+
background-color: #272727;
18+
}
19+
}

bot/index.html

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<link rel=stylesheet href=css/index.css>
2+
<script type=module src=js/index.js defer></script>
3+
<template id=authorize>
4+
<div class=authorize>
5+
<div class=error></div>
6+
<a class=twitch>Connect with Twitch</a>
7+
<dialog class=token>
8+
<form>
9+
<div class=title>Check your browser!</div>
10+
<div class=description>Connect your Twitch account in your browser and paste the token here.</div>
11+
<div class=error></div>
12+
<div>
13+
<input type=password placeholder='Paste your token here'>
14+
<input type=submit value=Connect>
15+
</div>
16+
</form>
17+
</dialog>
18+
</div>
19+
</template>

bot/js/authorize.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {client} from './twitch'
2+
3+
import * as OAuth2 from '../../modules/twitch/oauth2'
4+
5+
function alternativeFlow(element) {
6+
const tokenDialog = element.querySelector('dialog.token')
7+
element.querySelector('a.twitch').addEventListener('click', event => {
8+
tokenDialog.showModal()
9+
window.invokeNative('openUrl',
10+
OAuth2.authorize(
11+
`${location.origin}${location.pathname}`, /** @todo external website */
12+
client.client_id,
13+
client.scopes
14+
)
15+
)
16+
})
17+
const errorElement = tokenDialog.querySelector('form div.error')
18+
tokenDialog.querySelector('form').addEventListener('submit', event => {
19+
event.preventDefault()
20+
const {target: [input, submit]} = event
21+
const {value: token} = input
22+
if (
23+
!token
24+
|| token === 'Bearer '
25+
|| !token.startsWith('Bearer ')
26+
) return errorElement.innerText = 'Invalid authorization token'
27+
submit.disabled = true
28+
submit.value = 'Validating...'
29+
OAuth2.validate(token).then(response => {
30+
submit.value = 'Connect'
31+
input.addEventListener('input', () => submit.disabled = false, {once: true})
32+
if (response.status)
33+
return errorElement.innerText = `${response.status} - ${response.message}`
34+
localStorage.setItem('tts_twitch_token', token)
35+
location.reload()
36+
})
37+
})
38+
}
39+
40+
function authorizationFlow(error) {
41+
const element = document.importNode(
42+
document.head.querySelector('template#authorize').content,
43+
true
44+
)
45+
46+
if (error)
47+
element.querySelector('div.error').innerText = error
48+
49+
if (window.invokeNative)
50+
alternativeFlow(client, element)
51+
else
52+
element.querySelector('a.twitch').href =
53+
OAuth2.authorize(
54+
`${location.origin}${location.pathname}`,
55+
client.client_id,
56+
client.scopes
57+
)
58+
59+
document.body.replaceChildren(element)
60+
}
61+
62+
export default function() {
63+
const response = OAuth2.response()
64+
65+
if (response && !client.token)
66+
localStorage.setItem('tts_twitch_token', client.token = response.token)
67+
68+
if (client.token)
69+
OAuth2.validate(client.token).then(response => {
70+
const error = response.status
71+
? `${response.status} - ${response.message}`
72+
: !OAuth2.compareScopes(client.scopes, response.scopes)
73+
? 'Scopes changed, please reauthorize.'
74+
: client.connect(client.user_id = response.user_id)
75+
if (!error) return
76+
localStorage.removeItem('tts_twitch_token')
77+
authorizationFlow(error)
78+
})
79+
else
80+
authorizationFlow(OAuth2.error())
81+
}

bot/js/commands/help/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default function(client, {message_id: reply_parent_message_id}) {
2+
return client.sendChatMessage({
3+
message: `Commands: ${Object.keys(this).join(', ')}`,
4+
reply_parent_message_id
5+
})
6+
}

bot/js/commands/index.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {default as help} from './help'
2+
import {default as ping} from './ping'
3+
import {play, pause, skip} from './music'
4+
5+
export const prefix = '!'
6+
7+
export const commands = {
8+
help,
9+
ping,
10+
play,
11+
pause,
12+
skip
13+
}
14+
15+
/**
16+
* @param {string} text
17+
*/
18+
export function executeCommand(client, event) {
19+
const [command, ...args] = event.message.text.slice(1).split(' ')
20+
return commands[command]?.(client, event, ...args)
21+
}

bot/js/commands/music/index.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
export const queue = []
2+
3+
export let playing = false
4+
5+
function run() {
6+
if (!queue.length) return playing = false
7+
const [audio, resolve, reject] = queue.shift()
8+
audio.addEventListener('error', run)
9+
audio.addEventListener('ended', run)
10+
audio.play().then(resolve).catch(reject)
11+
playing = audio
12+
}
13+
14+
function push(url) {
15+
const {promise, resolve, reject} = Promise.withResolvers()
16+
queue.push([new Audio(url), resolve, reject])
17+
return promise
18+
}
19+
20+
export function play(client, {message_id: reply_parent_message_id}, url) {
21+
if (playing?.paused) {
22+
client.sendChatMessage({
23+
message: 'Resuming song...',
24+
reply_parent_message_id
25+
})
26+
playing.play()
27+
}
28+
29+
if (!url)
30+
return client.sendChatMessage({
31+
message: '!play <url>',
32+
reply_parent_message_id
33+
})
34+
push(url)
35+
.then(() => client.sendChatMessage({
36+
message: 'Now playing...',
37+
reply_parent_message_id
38+
}))
39+
.catch(error => client.sendChatMessage({
40+
message: error.toString(),
41+
reply_parent_message_id
42+
}))
43+
if (!playing)
44+
run()
45+
}
46+
47+
export function pause(client, {message_id: reply_parent_message_id}, argument) {
48+
if (!playing)
49+
return client.sendChatMessage({
50+
message: 'Nothing to pause!',
51+
reply_parent_message_id
52+
})
53+
playing.pause()
54+
client.sendChatMessage({
55+
message: 'Pausing...',
56+
reply_parent_message_id
57+
})
58+
}
59+
60+
export function skip(client, {message_id: reply_parent_message_id}, argument) {
61+
if (!playing)
62+
return client.sendChatMessage({
63+
message: 'Nothing to skip!',
64+
reply_parent_message_id
65+
})
66+
const all = argument === 'all'
67+
const count = all ? queue.length + 1 : 1
68+
client.sendChatMessage({
69+
message: `Skipping ${count} song${count === 1 ? '' : 's'}...`,
70+
reply_parent_message_id
71+
})
72+
playing.src = ''
73+
if (all) queue.length = 0
74+
run()
75+
}

bot/js/commands/ping/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default function(client, {message_id: reply_parent_message_id}) {
2+
return client.sendChatMessage({
3+
message: 'pong!',
4+
reply_parent_message_id
5+
})
6+
}

bot/js/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import './message'
2+
3+
import authorize from './authorize'
4+
5+
authorize()

0 commit comments

Comments
 (0)