@single-studio/plugin-twitch 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -12
- package/package.json +4 -3
- package/src/auth.js +129 -0
- package/src/index.js +378 -49
- package/src/protocol.js +1 -1
package/README.md
CHANGED
|
@@ -17,32 +17,81 @@ class MyShow extends TwitchHandler {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
createVelcroHost({
|
|
20
|
+
createVelcroHost({
|
|
21
|
+
name: STUDIO_ID,
|
|
22
|
+
mutations,
|
|
23
|
+
plugins: [twitch(MyShow, { clientId: 'your-client-id' })],
|
|
24
|
+
})
|
|
21
25
|
```
|
|
22
26
|
|
|
27
|
+
## Once, as the studio's author: a Client ID
|
|
28
|
+
|
|
29
|
+
Register one application at [dev.twitch.tv/console/apps](https://dev.twitch.tv/console/apps)
|
|
30
|
+
and pass its Client ID to `twitch()`. Nobody who runs your studio ever sees Twitch's
|
|
31
|
+
developer console.
|
|
32
|
+
|
|
33
|
+
- **Client Type: Public.** A studio is static files with nowhere to keep a secret,
|
|
34
|
+
and a public client is what lets it sign in without one.
|
|
35
|
+
- **OAuth Redirect URL:** anything, such as `http://localhost:5173`. The form needs
|
|
36
|
+
one; the sign-in never uses it.
|
|
37
|
+
- The Client ID is **not a secret**. It belongs in the build.
|
|
38
|
+
- Its name is what operators see on Twitch's approval screen.
|
|
39
|
+
|
|
40
|
+
## Every operator: Sign in with Twitch
|
|
41
|
+
|
|
42
|
+
On the board's plugin panel, press **Sign in with Twitch**. A code appears. Go to
|
|
43
|
+
twitch.tv/activate on any device, sign in as the channel, type the code, and approve.
|
|
44
|
+
The panel changes to "Signed in as …" and the plugin connects.
|
|
45
|
+
|
|
46
|
+
The machine stays signed in: tokens are refreshed in the background, and only Sign
|
|
47
|
+
out, a reset, or thirty days unused ask for the code again. Nobody types a user id or a
|
|
48
|
+
token.
|
|
49
|
+
|
|
50
|
+
A moderator running a board for somebody else's channel signs in as themselves and
|
|
51
|
+
types that channel's name under **Channel**.
|
|
52
|
+
|
|
23
53
|
## What it tells you
|
|
24
54
|
|
|
25
|
-
`onChat`, `onFollow`, `onSubscribe`, `
|
|
55
|
+
`onChat`, `onFollow`, `onSubscribe`, `onResub`, `onGift`, `onCheer`, `onRaid`, and
|
|
56
|
+
`onRevoked` for a subscription Twitch withdrew.
|
|
26
57
|
|
|
27
|
-
|
|
58
|
+
Every one of them, unless you narrow it. Narrowing is yours, as the author, rather
|
|
59
|
+
than the operator's, because it decides what Twitch asks a streamer to approve at
|
|
60
|
+
sign-in:
|
|
28
61
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
```js
|
|
63
|
+
twitch(MyShow, { clientId: 'your-client-id', events: ['chat', 'raid'] })
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Names are the handler's (`chat`, `follow`, `subscribe`, `resub`, `gift`, `cheer`,
|
|
67
|
+
`raid`) or Twitch's (`channel.raid`). A chat-only studio then asks only to read
|
|
68
|
+
chat.
|
|
69
|
+
|
|
70
|
+
Some events depend on who signed in. A moderator watching somebody else's channel
|
|
71
|
+
gets chat, follows and raids; subs, gifts and cheers only go to the channel's own
|
|
72
|
+
sign-in. The plugin carries on with whatever Twitch will send and names the rest on
|
|
73
|
+
the panel, since a graphic that never shows a sub otherwise looks like a quiet night.
|
|
74
|
+
|
|
75
|
+
## Trying it without Twitch
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
twitch(MyShow, { mock: true })
|
|
79
|
+
```
|
|
32
80
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
81
|
+
connects to the [Twitch CLI](https://github.com/twitchdev/twitch-cli)'s mock EventSub
|
|
82
|
+
server on this machine, with no sign-in. From this repository,
|
|
83
|
+
`pnpm --filter @single-studio/plugin-twitch mock` starts it and turns single keys into
|
|
84
|
+
test events. See [dev/README.md](dev/README.md).
|
|
36
85
|
|
|
37
86
|
## Two things worth knowing
|
|
38
87
|
|
|
39
88
|
**A reconnect is a handover, not a drop.** Twitch hands over a URL rather than closing,
|
|
40
|
-
and the old socket keeps delivering until the new one has welcomed
|
|
89
|
+
and the old socket keeps delivering until the new one has welcomed, so nothing is
|
|
41
90
|
missed and no graphic flickers. The plugin does that for you.
|
|
42
91
|
|
|
43
92
|
**Some subscriptions can be refused while others succeed**, usually because a scope is
|
|
44
|
-
missing. The plugin keeps going with what it got
|
|
45
|
-
|
|
93
|
+
missing. The plugin keeps going with what it got rather than treating a partial
|
|
94
|
+
success as a failed connection.
|
|
46
95
|
|
|
47
96
|
## Licence
|
|
48
97
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@single-studio/plugin-twitch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Twitch EventSub for Single Studio: chat, follows, subs, gifts, cheers and raids, straight into a studio with no backend.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -30,10 +30,11 @@
|
|
|
30
30
|
"LICENSE"
|
|
31
31
|
],
|
|
32
32
|
"scripts": {
|
|
33
|
-
"test": "vitest run"
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"mock": "node dev/mock.mjs"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
|
-
"@single-studio/core": "^0.
|
|
37
|
+
"@single-studio/core": "^0.9.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@single-studio/core": "workspace:*",
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Signing in to Twitch from a page with nowhere to keep a secret.
|
|
2
|
+
//
|
|
3
|
+
// A studio is static files, so it is what Twitch calls a *public client*, and a
|
|
4
|
+
// public client gets exactly one way to sign in: the Device Code Flow. The plugin
|
|
5
|
+
// asks for a code, the operator types it at twitch.tv/activate on any device, and the
|
|
6
|
+
// plugin asks until Twitch says yes. That suits an OBS dock better than a redirect
|
|
7
|
+
// would anyway: nothing has to find its way back into the dock.
|
|
8
|
+
//
|
|
9
|
+
// Kept apart from the socket so every step is testable with a fake `fetch`, and so
|
|
10
|
+
// the part of this plugin that depends on Twitch's wording is in one place.
|
|
11
|
+
//
|
|
12
|
+
// Confirmed from a browser page against real Twitch, before any of this was built:
|
|
13
|
+
// the device endpoint, the token endpoint and Helix all answer a cross-origin call,
|
|
14
|
+
// so this needs no backend. Two properties of the refresh token are built around
|
|
15
|
+
// below: it is single use, and it expires after thirty days unused.
|
|
16
|
+
|
|
17
|
+
const ID = 'https://id.twitch.tv/oauth2'
|
|
18
|
+
|
|
19
|
+
const GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
|
20
|
+
|
|
21
|
+
const form = (fields) => ({ method: 'POST', body: new URLSearchParams(fields) })
|
|
22
|
+
|
|
23
|
+
/** What Twitch says went wrong, whichever field it put it in. */
|
|
24
|
+
const reasonOf = (body, status) => String(body?.message ?? body?.error_description ?? body?.error ?? `Twitch answered ${status}`)
|
|
25
|
+
|
|
26
|
+
async function read(response) {
|
|
27
|
+
return response.json().catch(() => ({}))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A token response, in the shape the plugin stores. */
|
|
31
|
+
const tokensOf = (body, now) => ({
|
|
32
|
+
token: body.access_token,
|
|
33
|
+
refresh: body.refresh_token ?? '',
|
|
34
|
+
expiresAt: now + Number(body.expires_in ?? 0) * 1000,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ask for a code to show the operator.
|
|
39
|
+
*
|
|
40
|
+
* @returns {Promise<{ deviceCode: string, userCode: string, uri: string, interval: number, expiresAt: number }>}
|
|
41
|
+
*/
|
|
42
|
+
export async function requestCode({ clientId, scopes, fetch = globalThis.fetch, now = Date.now() }) {
|
|
43
|
+
const response = await fetch(`${ID}/device`, form({ client_id: clientId, scopes: scopes.join(' ') }))
|
|
44
|
+
const body = await read(response)
|
|
45
|
+
|
|
46
|
+
if (!response.ok) throw new Error(`Twitch would not start a sign-in: ${reasonOf(body, response.status)}`)
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
deviceCode: body.device_code,
|
|
50
|
+
userCode: body.user_code,
|
|
51
|
+
uri: body.verification_uri ?? 'https://www.twitch.tv/activate',
|
|
52
|
+
interval: Number(body.interval ?? 5),
|
|
53
|
+
expiresAt: now + Number(body.expires_in ?? 1800) * 1000,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ask once whether the operator has approved the code yet.
|
|
59
|
+
*
|
|
60
|
+
* Three answers, because two of Twitch's refusals are not failures: "not yet" and
|
|
61
|
+
* "slower", which the caller turns into waiting. Anything else -- denied, expired,
|
|
62
|
+
* a code Twitch no longer recognises -- ends the sign-in, with Twitch's reason.
|
|
63
|
+
*
|
|
64
|
+
* @returns {Promise<{ pending?: boolean, slowDown?: boolean, token?: string, refresh?: string, expiresAt?: number }>}
|
|
65
|
+
* `pending` or `slowDown`, or the three token fields. One shape rather than a union,
|
|
66
|
+
* so a caller can test for each without narrowing first.
|
|
67
|
+
*/
|
|
68
|
+
export async function pollToken({ clientId, scopes, deviceCode, fetch = globalThis.fetch, now = Date.now() }) {
|
|
69
|
+
const response = await fetch(`${ID}/token`, form({ client_id: clientId, scopes: scopes.join(' '), device_code: deviceCode, grant_type: GRANT }))
|
|
70
|
+
const body = await read(response)
|
|
71
|
+
|
|
72
|
+
if (response.ok && body.access_token) return tokensOf(body, now)
|
|
73
|
+
|
|
74
|
+
const reason = reasonOf(body, response.status)
|
|
75
|
+
|
|
76
|
+
if (/authorization_pending/i.test(reason)) return { pending: true }
|
|
77
|
+
if (/slow_down/i.test(reason)) return { slowDown: true }
|
|
78
|
+
|
|
79
|
+
throw new Error(`Sign-in did not complete: ${reason}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Swap a refresh token for a fresh pair.
|
|
84
|
+
*
|
|
85
|
+
* The old refresh token stops working the moment this succeeds, so the caller has
|
|
86
|
+
* to store the new one before anything else can go wrong. A refusal means the
|
|
87
|
+
* operator is signed out -- thirty days unused, a password change, access revoked on
|
|
88
|
+
* Twitch -- and is marked so, because the answer to it is a button, not a retry.
|
|
89
|
+
*/
|
|
90
|
+
export async function refreshTokens({ clientId, refresh, fetch = globalThis.fetch, now = Date.now() }) {
|
|
91
|
+
const response = await fetch(`${ID}/token`, form({ client_id: clientId, grant_type: 'refresh_token', refresh_token: refresh }))
|
|
92
|
+
const body = await read(response)
|
|
93
|
+
|
|
94
|
+
if (response.ok && body.access_token) return tokensOf(body, now)
|
|
95
|
+
|
|
96
|
+
/** @type {Error & { signedOut?: boolean }} */
|
|
97
|
+
const problem = new Error(`Twitch has signed this machine out (${reasonOf(body, response.status)}). Sign in again.`)
|
|
98
|
+
|
|
99
|
+
problem.signedOut = response.status === 400 || response.status === 401
|
|
100
|
+
|
|
101
|
+
throw problem
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Who a token belongs to, which is how the plugin learns the operator's user id
|
|
106
|
+
* without asking them for a number nobody knows.
|
|
107
|
+
*
|
|
108
|
+
* @returns {Promise<{ userId: string, login: string } | null>} null for a token Twitch no longer accepts
|
|
109
|
+
*/
|
|
110
|
+
export async function whoIs({ token, fetch = globalThis.fetch }) {
|
|
111
|
+
const response = await fetch(`${ID}/validate`, { headers: { Authorization: `OAuth ${token}` } })
|
|
112
|
+
|
|
113
|
+
if (response.status === 401) return null
|
|
114
|
+
|
|
115
|
+
const body = await read(response)
|
|
116
|
+
|
|
117
|
+
if (!response.ok) throw new Error(`Twitch would not say who this is: ${reasonOf(body, response.status)}`)
|
|
118
|
+
|
|
119
|
+
return { userId: String(body.user_id), login: String(body.login ?? '') }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Tell Twitch the token is finished with. Best effort: signing out locally does not wait on it. */
|
|
123
|
+
export async function revoke({ clientId, token, fetch = globalThis.fetch }) {
|
|
124
|
+
try {
|
|
125
|
+
await fetch(`${ID}/revoke`, form({ client_id: clientId, token }))
|
|
126
|
+
} catch {
|
|
127
|
+
// Offline, or Twitch unreachable. The token expires on its own.
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,58 @@
|
|
|
1
1
|
import { definePlugin, PluginHandler, SocketService } from '@single-studio/core/worker'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { pollToken, refreshTokens, requestCode, revoke, whoIs } from './auth.js'
|
|
4
|
+
import { EVENTS, normalise, scopesFor } from './events.js'
|
|
4
5
|
import { Protocol } from './protocol.js'
|
|
5
6
|
|
|
6
7
|
export { EVENTS, normalise, scopesFor } from './events.js'
|
|
7
8
|
export { Protocol } from './protocol.js'
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
const
|
|
10
|
+
/** Where Twitch is. */
|
|
11
|
+
const TWITCH = { eventsub: 'wss://eventsub.wss.twitch.tv/ws', helix: 'https://api.twitch.tv/helix' }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the Twitch CLI's mock EventSub server is, by default.
|
|
15
|
+
*
|
|
16
|
+
* `twitch event websocket start-server` -- see dev/README.md. Written from the CLI's
|
|
17
|
+
* documentation rather than run from here, so both halves can be overridden.
|
|
18
|
+
*/
|
|
19
|
+
const MOCK = { eventsub: 'ws://127.0.0.1:8080/ws', helix: 'http://127.0.0.1:8080' }
|
|
20
|
+
|
|
21
|
+
/** Refresh a token this long before it runs out, rather than finding out from a 401. */
|
|
22
|
+
const EARLY = 5 * 60 * 1000
|
|
23
|
+
|
|
24
|
+
/** What signing out, or being signed out, clears. */
|
|
25
|
+
const SIGNED_OUT = { token: '', refresh: '', expiresAt: 0, userId: '', login: '' }
|
|
26
|
+
|
|
27
|
+
/** What the panel calls each event when it has to say one is missing. */
|
|
28
|
+
const WORDS = {
|
|
29
|
+
'channel.chat.message': 'chat',
|
|
30
|
+
'channel.follow': 'follows',
|
|
31
|
+
'channel.subscribe': 'subs',
|
|
32
|
+
'channel.subscription.message': 'resubs',
|
|
33
|
+
'channel.subscription.gift': 'gifts',
|
|
34
|
+
'channel.cheer': 'cheers',
|
|
35
|
+
'channel.raid': 'raids',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "a", "a and b", "a, b and c". */
|
|
39
|
+
const list = (words) => (words.length < 2 ? words.join('') : `${words.slice(0, -1).join(', ')} and ${words.at(-1)}`)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The wire types for what an author listed, by the name their handler uses
|
|
43
|
+
* (`'chat'`, `'cheer'`) or by Twitch's (`'channel.cheer'`). Nothing listed is all.
|
|
44
|
+
*
|
|
45
|
+
* @param {string[]} [events]
|
|
46
|
+
*/
|
|
47
|
+
const typesFor = (events) => {
|
|
48
|
+
if (!events?.length) return Object.keys(EVENTS)
|
|
49
|
+
|
|
50
|
+
const byName = Object.fromEntries(Object.entries(EVENTS).map(([type, known]) => [known.emit, type]))
|
|
51
|
+
|
|
52
|
+
return [...new Set(events.map((event) => byName[event] ?? event))]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
11
56
|
|
|
12
57
|
/**
|
|
13
58
|
* Twitch chat, follows, subs, gifts, cheers and raids, in the SharedWorker.
|
|
@@ -30,6 +75,234 @@ class Twitch extends SocketService {
|
|
|
30
75
|
|
|
31
76
|
#protocol = new Protocol()
|
|
32
77
|
|
|
78
|
+
/** What the studio author passed to `twitch()`: the Client ID, and where Twitch is. */
|
|
79
|
+
#options
|
|
80
|
+
|
|
81
|
+
/** The host's way to store into this plugin's own config. */
|
|
82
|
+
#save
|
|
83
|
+
|
|
84
|
+
/** The sign-in in progress, if there is one. Compared by identity, so a cancel ends it. */
|
|
85
|
+
#signing = null
|
|
86
|
+
|
|
87
|
+
/** The code the operator is typing somewhere, while a sign-in waits on them. */
|
|
88
|
+
#waiting = null
|
|
89
|
+
|
|
90
|
+
/** The last thing a sign-in had to say that was not "done": a denial, an expiry. */
|
|
91
|
+
#said = null
|
|
92
|
+
|
|
93
|
+
/** The channel's numeric id, resolved at connect time from what the operator typed. */
|
|
94
|
+
#broadcaster = ''
|
|
95
|
+
|
|
96
|
+
/** The events Twitch would not send on this connection, by wire name. */
|
|
97
|
+
#refused = []
|
|
98
|
+
|
|
99
|
+
constructor(context, options = {}) {
|
|
100
|
+
super(context)
|
|
101
|
+
|
|
102
|
+
this.#options = options
|
|
103
|
+
this.#save = context.save ?? (async () => ({ ok: false }))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The author's, when they gave one; otherwise whatever the operator typed. */
|
|
107
|
+
get clientId() {
|
|
108
|
+
return this.#options.clientId || this.config.clientId || ''
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get #mock() {
|
|
112
|
+
return Boolean(this.#options.mock)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Which buttons make sense right now. Never both "Sign in" and "Sign out". */
|
|
116
|
+
get offers() {
|
|
117
|
+
if (this.#mock) return []
|
|
118
|
+
if (this.#signing) return ['cancel']
|
|
119
|
+
|
|
120
|
+
return this.config.token ? ['signOut'] : ['signIn']
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What the panel shows: the code while waiting, then who is signed in. */
|
|
124
|
+
get notice() {
|
|
125
|
+
if (this.#waiting) return this.#waiting
|
|
126
|
+
if (this.#said) return this.#said
|
|
127
|
+
if (this.#mock) return { text: 'Mock mode: talking to the Twitch CLI on this machine, not to Twitch.' }
|
|
128
|
+
if (this.config.token && this.config.login) {
|
|
129
|
+
const missing = this.#refused.map((type) => WORDS[type] ?? type)
|
|
130
|
+
|
|
131
|
+
if (!missing.length) return { text: `Signed in as ${this.config.login}.` }
|
|
132
|
+
|
|
133
|
+
// Said, because the plugin carries on without them and a graphic that never
|
|
134
|
+
// shows a sub looks exactly like a quiet night. On somebody else's channel it is
|
|
135
|
+
// usually not a fault: Twitch only gives those to the channel's own sign-in.
|
|
136
|
+
const why = String(this.config.channel ?? '').trim() ? ' On somebody else’s channel, only its owner’s sign-in gets those.' : ''
|
|
137
|
+
|
|
138
|
+
return { text: `Signed in as ${this.config.login}. Twitch will not send ${list(missing)} to this sign-in.${why}` }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async act(key) {
|
|
145
|
+
if (key === 'signIn') return this.#signIn()
|
|
146
|
+
|
|
147
|
+
if (key === 'cancel') {
|
|
148
|
+
this.#signing = null
|
|
149
|
+
this.#waiting = null
|
|
150
|
+
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (key === 'signOut') {
|
|
155
|
+
await revoke({ clientId: this.clientId, token: this.config.token })
|
|
156
|
+
|
|
157
|
+
return this.#save(SIGNED_OUT)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw new Error(`Twitch has no action "${key}"`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Start a sign-in, and return once there is a code to show.
|
|
165
|
+
*
|
|
166
|
+
* Returns early on purpose: the operator is about to spend a minute on another
|
|
167
|
+
* device, and the panel's button should not sit on "Working…" for all of it. The
|
|
168
|
+
* rest happens in `#await`, and the panel follows it through the notice.
|
|
169
|
+
*/
|
|
170
|
+
async #signIn() {
|
|
171
|
+
if (!this.clientId) throw new Error('This studio has no Twitch Client ID. Its author sets one when adding the plugin.')
|
|
172
|
+
|
|
173
|
+
const device = await requestCode({ clientId: this.clientId, scopes: scopesFor(this.types) })
|
|
174
|
+
const ticket = {}
|
|
175
|
+
|
|
176
|
+
this.#signing = ticket
|
|
177
|
+
this.#said = null
|
|
178
|
+
this.#waiting = {
|
|
179
|
+
text: 'Go to twitch.tv/activate on any device, sign in, and enter this code.',
|
|
180
|
+
code: device.userCode,
|
|
181
|
+
href: device.uri,
|
|
182
|
+
label: 'Open twitch.tv/activate',
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.#await(device, ticket).catch((error) => {
|
|
186
|
+
if (this.#signing !== ticket) return
|
|
187
|
+
|
|
188
|
+
this.#signing = null
|
|
189
|
+
this.#waiting = null
|
|
190
|
+
this.#said = { text: error?.message ?? String(error) }
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Ask Twitch at the pace it set until the operator approves, refuses, or the code runs out. */
|
|
195
|
+
async #await(device, ticket) {
|
|
196
|
+
let interval = device.interval
|
|
197
|
+
|
|
198
|
+
while (this.#signing === ticket) {
|
|
199
|
+
await sleep(interval * 1000)
|
|
200
|
+
|
|
201
|
+
if (this.#signing !== ticket) return
|
|
202
|
+
|
|
203
|
+
if (Date.now() > device.expiresAt) throw new Error('That code ran out before it was entered. Press Sign in with Twitch for a new one.')
|
|
204
|
+
|
|
205
|
+
const answer = await pollToken({ clientId: this.clientId, scopes: scopesFor(this.types), deviceCode: device.deviceCode })
|
|
206
|
+
|
|
207
|
+
if (answer.pending) continue
|
|
208
|
+
|
|
209
|
+
// Twitch asking for less: back off by its own step and keep going.
|
|
210
|
+
if (answer.slowDown) {
|
|
211
|
+
interval += 5
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const who = await whoIs({ token: answer.token })
|
|
216
|
+
|
|
217
|
+
if (this.#signing !== ticket) return
|
|
218
|
+
|
|
219
|
+
this.#signing = null
|
|
220
|
+
this.#waiting = null
|
|
221
|
+
|
|
222
|
+
// Stored, then restarted on it: the Save button's own path, so what comes back
|
|
223
|
+
// is a plugin that connects as the person who just signed in.
|
|
224
|
+
await this.#save({ ...answer, userId: who?.userId ?? '', login: who?.login ?? '' })
|
|
225
|
+
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A token that is about to run out, swapped before it does.
|
|
232
|
+
*
|
|
233
|
+
* The refresh token is single use, so the new pair is stored before anything else
|
|
234
|
+
* can go wrong -- losing it would sign the machine out. Stored without a restart:
|
|
235
|
+
* this runs inside a connect, and the running plugin already has the new values.
|
|
236
|
+
*/
|
|
237
|
+
async #fresh({ force = false } = {}) {
|
|
238
|
+
const { refresh, expiresAt } = this.config
|
|
239
|
+
|
|
240
|
+
if (!refresh) return
|
|
241
|
+
if (!force && (!expiresAt || Number(expiresAt) - Date.now() > EARLY)) return
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const tokens = await refreshTokens({ clientId: this.clientId, refresh })
|
|
245
|
+
|
|
246
|
+
Object.assign(this.config, tokens)
|
|
247
|
+
await this.#save(tokens, { restart: false })
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error?.signedOut) {
|
|
250
|
+
Object.assign(this.config, SIGNED_OUT)
|
|
251
|
+
await this.#save(SIGNED_OUT, { restart: false })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
throw error
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The numeric id of the channel to watch.
|
|
260
|
+
*
|
|
261
|
+
* Nobody knows their numeric id, so nobody is asked for it. Blank means the
|
|
262
|
+
* account that signed in; a name means that channel, looked up -- which is what a
|
|
263
|
+
* moderator running somebody else's board types.
|
|
264
|
+
*/
|
|
265
|
+
async #channel() {
|
|
266
|
+
const typed = String(this.config.channel ?? '').trim()
|
|
267
|
+
|
|
268
|
+
if (this.#mock) return /^\d+$/.test(typed) ? typed : '12345'
|
|
269
|
+
if (/^\d+$/.test(typed)) return typed
|
|
270
|
+
|
|
271
|
+
if (!typed) {
|
|
272
|
+
if (this.config.userId) return String(this.config.userId)
|
|
273
|
+
|
|
274
|
+
const who = await whoIs({ token: this.config.token })
|
|
275
|
+
|
|
276
|
+
if (!who) throw new Error('Twitch no longer accepts this sign-in. Sign in again.')
|
|
277
|
+
|
|
278
|
+
Object.assign(this.config, who)
|
|
279
|
+
await this.#save(who, { restart: false })
|
|
280
|
+
|
|
281
|
+
return who.userId
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const response = await fetch(`${this.#helix}/users?login=${encodeURIComponent(typed.toLowerCase())}`, { headers: this.#headers })
|
|
285
|
+
const body = await response.json().catch(() => ({}))
|
|
286
|
+
const id = body?.data?.[0]?.id
|
|
287
|
+
|
|
288
|
+
if (!response.ok) throw new Error(`Twitch would not look up the channel "${typed}" (${response.status}).`)
|
|
289
|
+
if (!id) throw new Error(`There is no Twitch channel called "${typed}".`)
|
|
290
|
+
|
|
291
|
+
return String(id)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
get #helix() {
|
|
295
|
+
return this.#options.helix ?? (this.#mock ? MOCK.helix : TWITCH.helix)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
get #headers() {
|
|
299
|
+
return {
|
|
300
|
+
Authorization: `Bearer ${this.config.token || 'mock'}`,
|
|
301
|
+
'Client-Id': this.clientId || 'mock',
|
|
302
|
+
'Content-Type': 'application/json',
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
33
306
|
/** Not usable until subscribed, so `open()` waits for that rather than the socket. */
|
|
34
307
|
get readyOnOpen() {
|
|
35
308
|
return false
|
|
@@ -52,7 +325,7 @@ class Twitch extends SocketService {
|
|
|
52
325
|
}
|
|
53
326
|
|
|
54
327
|
get url() {
|
|
55
|
-
return
|
|
328
|
+
return this.#options.eventsub ?? (this.#mock ? MOCK.eventsub : TWITCH.eventsub)
|
|
56
329
|
}
|
|
57
330
|
|
|
58
331
|
/** Sized by what Twitch said in the welcome rather than by a guess. */
|
|
@@ -60,20 +333,33 @@ class Twitch extends SocketService {
|
|
|
60
333
|
return this.#protocol.silenceBudgetMs
|
|
61
334
|
}
|
|
62
335
|
|
|
63
|
-
/**
|
|
336
|
+
/**
|
|
337
|
+
* Every event, unless the studio's author narrowed it.
|
|
338
|
+
*
|
|
339
|
+
* The author's choice rather than the operator's. It decides which permissions
|
|
340
|
+
* Twitch asks for at sign-in, which is the author's to weigh -- a chat-only studio
|
|
341
|
+
* should not ask a streamer to approve subscription access -- and a free-text box
|
|
342
|
+
* on the panel was a way for anybody at the board to switch events off by typo.
|
|
343
|
+
* What this sign-in cannot have is found out by asking, and the panel says.
|
|
344
|
+
*/
|
|
64
345
|
get types() {
|
|
65
|
-
|
|
66
|
-
.split(',')
|
|
67
|
-
.map((type) => type.trim())
|
|
68
|
-
.filter(Boolean)
|
|
69
|
-
|
|
70
|
-
return asked.length ? asked : Object.keys(EVENTS)
|
|
346
|
+
return typesFor(this.#options.events)
|
|
71
347
|
}
|
|
72
348
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!this
|
|
76
|
-
|
|
349
|
+
/** @param {AbortSignal} [signal] */
|
|
350
|
+
async open(signal) {
|
|
351
|
+
if (!this.#mock) {
|
|
352
|
+
if (!this.clientId) throw new Error('This studio has no Twitch Client ID. Its author sets one when adding the plugin.')
|
|
353
|
+
if (!this.config.token) throw new Error('Not signed in to Twitch yet. Press Sign in with Twitch.')
|
|
354
|
+
|
|
355
|
+
await this.#fresh()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
this.#broadcaster = await this.#channel()
|
|
359
|
+
|
|
360
|
+
// Two round trips to Twitch above, and a restart or a stand-down can land during
|
|
361
|
+
// either. A socket dialled after that would subscribe for a plugin nobody holds.
|
|
362
|
+
if (signal?.aborted) return undefined
|
|
77
363
|
|
|
78
364
|
return super.open()
|
|
79
365
|
}
|
|
@@ -146,29 +432,27 @@ class Twitch extends SocketService {
|
|
|
146
432
|
* After the welcome rather than before it: the session id is what ties a
|
|
147
433
|
* subscription to this socket, and it does not exist until Twitch says so.
|
|
148
434
|
*/
|
|
149
|
-
async #subscribe(session) {
|
|
435
|
+
async #subscribe(session, retried = false) {
|
|
150
436
|
const failures = []
|
|
437
|
+
const broadcaster = this.#broadcaster
|
|
438
|
+
const me = String(this.config.userId || broadcaster)
|
|
151
439
|
|
|
152
440
|
for (const type of this.types) {
|
|
153
441
|
const known = EVENTS[type]
|
|
154
|
-
const condition = { broadcaster_user_id:
|
|
442
|
+
const condition = { broadcaster_user_id: broadcaster }
|
|
155
443
|
|
|
156
444
|
// Each type words its condition differently, and a wrong one is rejected as a
|
|
157
445
|
// 400 that reads like a scope problem.
|
|
158
|
-
if (type === 'channel.chat.message') condition.user_id =
|
|
159
|
-
if (type === 'channel.follow') condition.moderator_user_id =
|
|
446
|
+
if (type === 'channel.chat.message') condition.user_id = me
|
|
447
|
+
if (type === 'channel.follow') condition.moderator_user_id = me
|
|
160
448
|
if (type === 'channel.raid') {
|
|
161
449
|
delete condition.broadcaster_user_id
|
|
162
|
-
condition.to_broadcaster_user_id =
|
|
450
|
+
condition.to_broadcaster_user_id = broadcaster
|
|
163
451
|
}
|
|
164
452
|
|
|
165
|
-
const response = await fetch(
|
|
453
|
+
const response = await fetch(`${this.#helix}/eventsub/subscriptions`, {
|
|
166
454
|
method: 'POST',
|
|
167
|
-
headers:
|
|
168
|
-
Authorization: `Bearer ${this.config.token}`,
|
|
169
|
-
'Client-Id': this.config.clientId,
|
|
170
|
-
'Content-Type': 'application/json',
|
|
171
|
-
},
|
|
455
|
+
headers: this.#headers,
|
|
172
456
|
body: JSON.stringify({
|
|
173
457
|
type,
|
|
174
458
|
version: known?.version ?? '1',
|
|
@@ -177,16 +461,33 @@ class Twitch extends SocketService {
|
|
|
177
461
|
}),
|
|
178
462
|
})
|
|
179
463
|
|
|
180
|
-
if (!response.ok) failures.push(
|
|
464
|
+
if (!response.ok) failures.push({ type, status: response.status })
|
|
181
465
|
}
|
|
182
466
|
|
|
467
|
+
// Every one refused as unauthorised is a token that died between the refresh
|
|
468
|
+
// check and now. One refresh and one more go, not a loop.
|
|
469
|
+
if (!retried && !this.#mock && failures.length === this.types.length && failures.every((failure) => failure.status === 401) && this.config.refresh) {
|
|
470
|
+
await this.#fresh({ force: true })
|
|
471
|
+
|
|
472
|
+
return this.#subscribe(session, true)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const said = failures.map((failure) => `${failure.type} (${failure.status})`).join(', ')
|
|
476
|
+
|
|
477
|
+
this.#refused = failures.length < this.types.length ? failures.map((failure) => failure.type) : []
|
|
478
|
+
|
|
183
479
|
// Some working is better than none: a studio missing `bits:read` should still
|
|
184
480
|
// get chat rather than a dead plugin.
|
|
185
|
-
if (failures.length === this.types.length) throw new Error(`Twitch refused every subscription: ${
|
|
186
|
-
if (failures.length) console.warn(`[twitch] some subscriptions were refused: ${
|
|
481
|
+
if (failures.length === this.types.length) throw new Error(`Twitch refused every subscription: ${said}`)
|
|
482
|
+
if (failures.length) console.warn(`[twitch] some subscriptions were refused: ${said}`)
|
|
483
|
+
|
|
484
|
+
return undefined
|
|
187
485
|
}
|
|
188
486
|
|
|
189
487
|
async close() {
|
|
488
|
+
// A sign-in waiting on the operator ends with the plugin that started it.
|
|
489
|
+
this.#signing = null
|
|
490
|
+
this.#waiting = null
|
|
190
491
|
this.#next?.close()
|
|
191
492
|
this.#next = null
|
|
192
493
|
await super.close()
|
|
@@ -223,48 +524,76 @@ export class TwitchHandler extends PluginHandler {
|
|
|
223
524
|
onRevoked() {}
|
|
224
525
|
}
|
|
225
526
|
|
|
527
|
+
/**
|
|
528
|
+
* @typedef {object} TwitchOptions
|
|
529
|
+
* @property {string} [clientId] Your Twitch application's Client ID. Public, not a
|
|
530
|
+
* secret, so it belongs in the build: register one app, as a *Public* client, and
|
|
531
|
+
* every operator of the studio signs in through it without ever seeing Twitch's
|
|
532
|
+
* developer console. Leave it out and the panel asks each operator for one.
|
|
533
|
+
* @property {string[]} [events] Only these events, by handler name (`'chat'`,
|
|
534
|
+
* `'follow'`, `'subscribe'`, `'resub'`, `'gift'`, `'cheer'`, `'raid'`) or by
|
|
535
|
+
* Twitch's type. All of them if left out. Twitch asks for the permissions these
|
|
536
|
+
* need at sign-in, so a chat-only studio asks only to read chat.
|
|
537
|
+
* @property {boolean} [mock] Talk to the Twitch CLI's mock server on this machine
|
|
538
|
+
* instead of Twitch. No sign-in. See dev/README.md in this package.
|
|
539
|
+
* @property {string} [eventsub] Override the EventSub WebSocket address.
|
|
540
|
+
* @property {string} [helix] Override the Helix API base, without a trailing slash.
|
|
541
|
+
*/
|
|
542
|
+
|
|
226
543
|
/**
|
|
227
544
|
* @param {typeof TwitchHandler} [Handler] The studio's subclass.
|
|
545
|
+
* @param {TwitchOptions} [options]
|
|
228
546
|
*/
|
|
229
|
-
export const twitch = (Handler = TwitchHandler) =>
|
|
547
|
+
export const twitch = (Handler = TwitchHandler, options = {}) =>
|
|
230
548
|
definePlugin({
|
|
231
549
|
name: 'twitch',
|
|
232
550
|
label: 'Twitch',
|
|
233
551
|
summary: 'Chat, follows, subs, gifts, cheers and raids, straight into the show.',
|
|
234
552
|
help: [
|
|
235
|
-
{
|
|
236
|
-
type: 'note',
|
|
237
|
-
text: 'Signing in properly is not built yet, so for now this needs an access token pasted in by hand. Tokens expire, so expect to redo this.',
|
|
238
|
-
},
|
|
239
553
|
{
|
|
240
554
|
type: 'steps',
|
|
241
555
|
items: [
|
|
242
|
-
'
|
|
243
|
-
'
|
|
244
|
-
'
|
|
245
|
-
'
|
|
556
|
+
'Press Sign in with Twitch.',
|
|
557
|
+
'Go to twitch.tv/activate on any device — your phone is fine — and sign in as the channel.',
|
|
558
|
+
'Type the code shown here, and approve.',
|
|
559
|
+
'That is all. This machine stays signed in until you sign out.',
|
|
246
560
|
],
|
|
247
561
|
},
|
|
248
|
-
{ type: 'text', text: 'Scopes needed, depending on which events you want:' },
|
|
249
562
|
{
|
|
250
|
-
type: '
|
|
251
|
-
text:
|
|
563
|
+
type: 'text',
|
|
564
|
+
text: `Once you are signed in, this studio gets ${list(typesFor(options.events).map((type) => WORDS[type] ?? type))}. If Twitch will not send some of them to your sign-in, this panel says which.`,
|
|
252
565
|
},
|
|
253
|
-
{ type: 'link', href: 'https://dev.twitch.tv/console/apps', label: 'Twitch developer console' },
|
|
254
566
|
{
|
|
255
567
|
type: 'text',
|
|
256
|
-
text: '
|
|
568
|
+
text: 'Running a board for somebody else’s channel as their moderator? Sign in as yourself and type their channel name under Channel. You get chat, follows and raids; subs, gifts and cheers only go to the channel’s own sign-in.',
|
|
257
569
|
},
|
|
258
570
|
],
|
|
571
|
+
actions: [
|
|
572
|
+
{ key: 'signIn', label: 'Sign in with Twitch' },
|
|
573
|
+
{ key: 'cancel', label: 'Cancel sign-in' },
|
|
574
|
+
{ key: 'signOut', label: 'Sign out' },
|
|
575
|
+
],
|
|
259
576
|
config: [
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
577
|
+
// Only when the studio did not bring one. An operator should never have to
|
|
578
|
+
// visit Twitch's developer console; the author does it once for everybody.
|
|
579
|
+
...(options.clientId || options.mock
|
|
580
|
+
? []
|
|
581
|
+
: [
|
|
582
|
+
{
|
|
583
|
+
key: 'clientId',
|
|
584
|
+
label: 'Client ID',
|
|
585
|
+
help: 'This studio does not come with one. From an app registered as a Public client at dev.twitch.tv/console/apps.',
|
|
586
|
+
},
|
|
587
|
+
]),
|
|
588
|
+
{
|
|
589
|
+
key: 'channel',
|
|
590
|
+
label: 'Channel',
|
|
591
|
+
placeholder: 'The one you signed in as',
|
|
592
|
+
help: 'Blank for your own. A moderator types the channel’s name.',
|
|
593
|
+
},
|
|
265
594
|
],
|
|
266
595
|
create: (context) => {
|
|
267
|
-
const plugin = new Twitch(context)
|
|
596
|
+
const plugin = new Twitch(context, options)
|
|
268
597
|
|
|
269
598
|
new Handler({ ...context, plugin }).attach(plugin.events)
|
|
270
599
|
|
package/src/protocol.js
CHANGED
|
@@ -70,7 +70,7 @@ export class Protocol {
|
|
|
70
70
|
/**
|
|
71
71
|
* Decide what a raw message means.
|
|
72
72
|
*
|
|
73
|
-
* @param {
|
|
73
|
+
* @param {any} raw Already JSON.parse'd, from a socket -- so nothing about its shape is known yet.
|
|
74
74
|
* @returns {Action}
|
|
75
75
|
*/
|
|
76
76
|
handle(raw) {
|