@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 CHANGED
@@ -17,32 +17,81 @@ class MyShow extends TwitchHandler {
17
17
  }
18
18
  }
19
19
 
20
- createVelcroHost({ name: STUDIO_ID, mutations, plugins: [twitch(MyShow)] })
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`, `onResubscribe`, `onGift`, `onCheer`, `onRaid`.
55
+ `onChat`, `onFollow`, `onSubscribe`, `onResub`, `onGift`, `onCheer`, `onRaid`, and
56
+ `onRevoked` for a subscription Twitch withdrew.
26
57
 
27
- ## What the operator sets
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
- A client id, the channel's numeric user id, their own user id, and an access token.
30
- All four are facts about that person and their app, so they belong on the board rather
31
- than in a build.
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
- `Events` is a comma-separated list, blank for all of them. Subscribing to less than
34
- everything is worth doing: each type is a separate subscription created at connect
35
- time, and the ones you do not read still cost a round trip.
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 — so nothing is
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 and says which were refused on the
45
- board, rather than treating a partial success as a failed connection.
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.7.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.7.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 { EVENTS, normalise } from './events.js'
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
- const EVENTSUB = 'wss://eventsub.wss.twitch.tv/ws'
10
- const HELIX = 'https://api.twitch.tv/helix/eventsub/subscriptions'
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 EVENTSUB
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
- /** The events this studio asked for, or all of them. */
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
- const asked = String(this.config.events ?? '')
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
- open() {
74
- if (!this.config.token) return Promise.reject(new Error('Not signed in to Twitch yet.'))
75
- if (!this.config.clientId) return Promise.reject(new Error('A Twitch application Client ID is needed.'))
76
- if (!this.config.broadcasterId) return Promise.reject(new Error('The broadcaster user id is needed.'))
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: String(this.config.broadcasterId) }
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 = String(this.config.userId || this.config.broadcasterId)
159
- if (type === 'channel.follow') condition.moderator_user_id = String(this.config.userId || this.config.broadcasterId)
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 = String(this.config.broadcasterId)
450
+ condition.to_broadcaster_user_id = broadcaster
163
451
  }
164
452
 
165
- const response = await fetch(HELIX, {
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(`${type} (${response.status})`)
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: ${failures.join(', ')}`)
186
- if (failures.length) console.warn(`[twitch] some subscriptions were refused: ${failures.join(', ')}`)
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
- 'Register an application at dev.twitch.tv/console/apps. The Client ID it gives you is public — paste it above.',
243
- 'Find your channel’s numeric user id (any "Twitch username to user id" tool will do) and paste it into both id fields.',
244
- 'Generate a user access token with the scopes below and paste it into Access token.',
245
- 'Save and reconnect.',
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: 'code',
251
- text: 'user:read:chat chat messages\nmoderator:read:followers follows\nchannel:read:subscriptions subs, resubs and gifts\nbits:read cheers',
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: 'Leave Events blank for all of them, or list the ones you want — a studio that only shows chat need not ask for subscription scopes at all.',
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
- { key: 'clientId', label: 'Client ID', help: 'From your app at dev.twitch.tv/console/apps. Public, not a secret.' },
261
- { key: 'broadcasterId', label: 'Channel user id', help: 'The numeric id of the channel to watch.' },
262
- { key: 'userId', label: 'Your user id', help: 'Usually the same as the channel. Differs when a moderator runs the board.' },
263
- { key: 'token', label: 'Access token', type: 'secret' },
264
- { key: 'events', label: 'Events', help: 'Comma separated. Blank for all of them.' },
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 {unknown} raw Already JSON.parse'd.
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) {