streamops-twitch-connector 0.0.0-stage → 0.1.2
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 +88 -2
- package/dist/api/twitch-api-client.d.ts +13 -0
- package/dist/api/twitch-api-client.d.ts.map +1 -0
- package/dist/api/twitch-api-client.js +50 -0
- package/dist/api/twitch-api-client.js.map +1 -0
- package/dist/api/types.d.ts +15 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/auth/device-code-flow.d.ts +29 -0
- package/dist/auth/device-code-flow.d.ts.map +1 -0
- package/dist/auth/device-code-flow.js +185 -0
- package/dist/auth/device-code-flow.js.map +1 -0
- package/dist/auth/token-manager.d.ts +20 -0
- package/dist/auth/token-manager.d.ts.map +1 -0
- package/dist/auth/token-manager.js +100 -0
- package/dist/auth/token-manager.js.map +1 -0
- package/dist/auth/token-store.d.ts +13 -0
- package/dist/auth/token-store.d.ts.map +1 -0
- package/dist/auth/token-store.js +2 -0
- package/dist/auth/token-store.js.map +1 -0
- package/dist/chat/twitch-chat-client.d.ts +11 -0
- package/dist/chat/twitch-chat-client.d.ts.map +1 -0
- package/dist/chat/twitch-chat-client.js +54 -0
- package/dist/chat/twitch-chat-client.js.map +1 -0
- package/dist/chat/types.d.ts +22 -0
- package/dist/chat/types.d.ts.map +1 -0
- package/dist/chat/types.js +2 -0
- package/dist/chat/types.js.map +1 -0
- package/dist/errors/twitch-error.d.ts +6 -0
- package/dist/errors/twitch-error.d.ts.map +1 -0
- package/dist/errors/twitch-error.js +11 -0
- package/dist/errors/twitch-error.js.map +1 -0
- package/dist/eventsub/eventsub-client.d.ts +28 -0
- package/dist/eventsub/eventsub-client.d.ts.map +1 -0
- package/dist/eventsub/eventsub-client.js +197 -0
- package/dist/eventsub/eventsub-client.js.map +1 -0
- package/dist/eventsub/eventsub-subscription.d.ts +23 -0
- package/dist/eventsub/eventsub-subscription.d.ts.map +1 -0
- package/dist/eventsub/eventsub-subscription.js +43 -0
- package/dist/eventsub/eventsub-subscription.js.map +1 -0
- package/dist/eventsub/types.d.ts +46 -0
- package/dist/eventsub/types.d.ts.map +1 -0
- package/dist/eventsub/types.js +2 -0
- package/dist/eventsub/types.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/twitch/twitch-client.d.ts +17 -0
- package/dist/twitch/twitch-client.d.ts.map +1 -0
- package/dist/twitch/twitch-client.js +41 -0
- package/dist/twitch/twitch-client.js.map +1 -0
- package/package.json +29 -4
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# streamops-twitch-connector
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# StreamOps Twitch Connector
|
|
4
|
+
|
|
5
|
+
A framework-independent TypeScript connector for Twitch OAuth, Helix API
|
|
6
|
+
requests, chat messaging, and EventSub WebSocket notifications.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Node.js 22 or newer
|
|
11
|
+
- A Twitch application with a registered Client ID
|
|
12
|
+
- A token store implementation supplied by the consuming application
|
|
13
|
+
|
|
14
|
+
## Install dependencies
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
npm install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Build
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
npm run build
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Basic usage
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import {
|
|
30
|
+
TwitchClient,
|
|
31
|
+
type TwitchTokenStore,
|
|
32
|
+
type TwitchTokens,
|
|
33
|
+
} from 'streamops-twitch-connector';
|
|
34
|
+
|
|
35
|
+
const tokenStore: TwitchTokenStore = {
|
|
36
|
+
async load(): Promise<TwitchTokens | null> {
|
|
37
|
+
// Load tokens from your preferred secure storage.
|
|
38
|
+
return null;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async save(tokens: TwitchTokens): Promise<void> {
|
|
42
|
+
// Persist tokens securely.
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async clear(): Promise<void> {
|
|
46
|
+
// Remove persisted tokens.
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const twitch = new TwitchClient({
|
|
51
|
+
clientId: process.env.TWITCH_CLIENT_ID!,
|
|
52
|
+
scopes: ['user:read:chat', 'user:write:chat'],
|
|
53
|
+
tokenStore,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const login = await twitch.tokens.startLogin();
|
|
57
|
+
|
|
58
|
+
console.log('Open:', login.verificationUri);
|
|
59
|
+
console.log('Enter code:', login.userCode);
|
|
60
|
+
|
|
61
|
+
// Call completeLogin() after starting the login flow.
|
|
62
|
+
// The method polls until authorization succeeds or fails.
|
|
63
|
+
await twitch.tokens.completeLogin();
|
|
64
|
+
|
|
65
|
+
twitch.chat.onMessage(async (message) => {
|
|
66
|
+
console.log(`${message.chatterName}: ${message.text}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await twitch.connectEventSub();
|
|
70
|
+
|
|
71
|
+
// After receiving the EventSub session welcome, subscribe using
|
|
72
|
+
// twitch.eventSub.currentSessionId and twitch.subscriptions.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Design
|
|
76
|
+
|
|
77
|
+
- No dependency on NestJS or Express.
|
|
78
|
+
- OAuth tokens are persisted through a caller-provided token store.
|
|
79
|
+
- Helix requests automatically include the Client ID and access token.
|
|
80
|
+
- Chat messages are received through EventSub and sent through Helix.
|
|
81
|
+
- EventSub subscriptions use the active WebSocket session ID.
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
The initial implementation assumes one authenticated Twitch account.
|
|
86
|
+
A separate bot account and multi-account token management are not included.
|
|
87
|
+
|
|
88
|
+
Treat token storage as sensitive. Do not commit access tokens or refresh
|
|
89
|
+
tokens to source control.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TwitchConnectorConfig } from './types.js';
|
|
2
|
+
import { TokenManager } from '../auth/token-manager.js';
|
|
3
|
+
export declare class TwitchApiClient {
|
|
4
|
+
private readonly config;
|
|
5
|
+
private readonly tokenManager;
|
|
6
|
+
constructor(config: Pick<TwitchConnectorConfig, 'clientId' | 'fetch'>, tokenManager: TokenManager);
|
|
7
|
+
private get fetcher();
|
|
8
|
+
request<T>(path: string, init?: RequestInit): Promise<T>;
|
|
9
|
+
get<T>(path: string): Promise<T>;
|
|
10
|
+
post<T>(path: string, body: unknown): Promise<T>;
|
|
11
|
+
delete<T>(path: string): Promise<T>;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=twitch-api-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"twitch-api-client.d.ts","sourceRoot":"","sources":["../../src/api/twitch-api-client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAKxD,qBAAa,eAAe;IAExB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAIvB,OAAO,CAAC,QAAQ,CAAC,YAAY;gBAJZ,MAAM,EAAE,IAAI,CAC3B,qBAAqB,EACrB,UAAU,GAAG,OAAO,CACrB,EACgB,YAAY,EAAE,YAAY;IAG7C,OAAO,KAAK,OAAO,GAElB;IAEK,OAAO,CAAC,CAAC,EACb,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,WAAgB,GACrB,OAAO,CAAC,CAAC,CAAC;IAqCb,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;IAIhC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAOhD,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;CAKpC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { TwitchError } from '../errors/twitch-error.js';
|
|
2
|
+
const HELIX_BASE_URL = 'https://api.twitch.tv/helix';
|
|
3
|
+
export class TwitchApiClient {
|
|
4
|
+
config;
|
|
5
|
+
tokenManager;
|
|
6
|
+
constructor(config, tokenManager) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.tokenManager = tokenManager;
|
|
9
|
+
}
|
|
10
|
+
get fetcher() {
|
|
11
|
+
return this.config.fetch ?? fetch;
|
|
12
|
+
}
|
|
13
|
+
async request(path, init = {}) {
|
|
14
|
+
const accessToken = await this.tokenManager.getAccessToken();
|
|
15
|
+
const url = new URL(path.replace(/^\/+/, ''), `${HELIX_BASE_URL}/`);
|
|
16
|
+
const headers = new Headers(init.headers);
|
|
17
|
+
headers.set('Client-Id', this.config.clientId);
|
|
18
|
+
headers.set('Authorization', `Bearer ${accessToken}`);
|
|
19
|
+
if (init.body && !headers.has('Content-Type')) {
|
|
20
|
+
headers.set('Content-Type', 'application/json');
|
|
21
|
+
}
|
|
22
|
+
const response = await this.fetcher(url, {
|
|
23
|
+
...init,
|
|
24
|
+
headers,
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const responseText = await response.text().catch(() => '');
|
|
28
|
+
throw new TwitchError(`Twitch Helix request failed (${response.status})${responseText ? `: ${responseText}` : ''}`, response.status);
|
|
29
|
+
}
|
|
30
|
+
if (response.status === 204) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return (await response.json());
|
|
34
|
+
}
|
|
35
|
+
get(path) {
|
|
36
|
+
return this.request(path);
|
|
37
|
+
}
|
|
38
|
+
post(path, body) {
|
|
39
|
+
return this.request(path, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
delete(path) {
|
|
45
|
+
return this.request(path, {
|
|
46
|
+
method: 'DELETE',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=twitch-api-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"twitch-api-client.js","sourceRoot":"","sources":["../../src/api/twitch-api-client.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAExD,MAAM,cAAc,GAAG,6BAA6B,CAAC;AAErD,MAAM,OAAO,eAAe;IAEP;IAIA;IALnB,YACmB,MAGhB,EACgB,YAA0B;QAJ1B,WAAM,GAAN,MAAM,CAGtB;QACgB,iBAAY,GAAZ,YAAY,CAAc;IAC1C,CAAC;IAEJ,IAAY,OAAO;QACjB,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK,IAAI,KAAK,CAAC;IACpC,CAAC;IAED,KAAK,CAAC,OAAO,CACX,IAAY,EACZ,OAAoB,EAAE;QAEtB,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,cAAc,EAAE,CAAC;QAC7D,MAAM,GAAG,GAAG,IAAI,GAAG,CACjB,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,EACxB,GAAG,cAAc,GAAG,CACrB,CAAC;QAEF,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC1C,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC/C,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,WAAW,EAAE,CAAC,CAAC;QAEtD,IAAI,IAAI,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,EAAE,CAAC;YAC9C,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,kBAAkB,CAAC,CAAC;QAClD,CAAC;QAED,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE;YACvC,GAAG,IAAI;YACP,OAAO;SACR,CAAC,CAAC;QAEH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,YAAY,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;YAC3D,MAAM,IAAI,WAAW,CACnB,gCAAgC,QAAQ,CAAC,MAAM,IAC7C,YAAY,CAAC,CAAC,CAAC,KAAK,YAAY,EAAE,CAAC,CAAC,CAAC,EACvC,EAAE,EACF,QAAQ,CAAC,MAAM,CAChB,CAAC;QACJ,CAAC;QAED,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YAC5B,OAAO,SAAc,CAAC;QACxB,CAAC;QAED,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAM,CAAC;IACtC,CAAC;IAED,GAAG,CAAI,IAAY;QACjB,OAAO,IAAI,CAAC,OAAO,CAAI,IAAI,CAAC,CAAC;IAC/B,CAAC;IAED,IAAI,CAAI,IAAY,EAAE,IAAa;QACjC,OAAO,IAAI,CAAC,OAAO,CAAI,IAAI,EAAE;YAC3B,MAAM,EAAE,MAAM;YACd,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;SAC3B,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CAAI,IAAY;QACpB,OAAO,IAAI,CAAC,OAAO,CAAI,IAAI,EAAE;YAC3B,MAAM,EAAE,QAAQ;SACjB,CAAC,CAAC;IACL,CAAC;CACF"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TwitchTokenStore } from '../auth/token-store.js';
|
|
2
|
+
export interface TwitchConnectorConfig {
|
|
3
|
+
clientId: string;
|
|
4
|
+
scopes: string[];
|
|
5
|
+
tokenStore: TwitchTokenStore;
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
export interface TwitchValidatedToken {
|
|
9
|
+
client_id: string;
|
|
10
|
+
login: string;
|
|
11
|
+
scopes: string[];
|
|
12
|
+
user_id: string;
|
|
13
|
+
expires_in: number;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/api/types.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,gBAAgB,CAAC;IAC7B,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/api/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TwitchConnectorConfig } from '../api/types.js';
|
|
2
|
+
export interface DeviceCodeTokenResponse {
|
|
3
|
+
access_token: string;
|
|
4
|
+
refresh_token: string;
|
|
5
|
+
expires_in: number;
|
|
6
|
+
scope: string[];
|
|
7
|
+
token_type: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DeviceCodeLogin {
|
|
10
|
+
userCode: string;
|
|
11
|
+
verificationUri: string;
|
|
12
|
+
expiresIn: number;
|
|
13
|
+
interval: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class DeviceCodeFlow {
|
|
16
|
+
private readonly config;
|
|
17
|
+
private readonly fetchImpl;
|
|
18
|
+
private deviceCode;
|
|
19
|
+
private expiresAt;
|
|
20
|
+
private pollInterval;
|
|
21
|
+
private loginInProgress;
|
|
22
|
+
constructor(config: Pick<TwitchConnectorConfig, 'clientId' | 'scopes' | 'fetch'>);
|
|
23
|
+
startLogin(): Promise<DeviceCodeLogin>;
|
|
24
|
+
waitForAuthorization(signal?: AbortSignal): Promise<DeviceCodeTokenResponse>;
|
|
25
|
+
private readResponse;
|
|
26
|
+
private asOAuthError;
|
|
27
|
+
private delay;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=device-code-flow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-code-flow.d.ts","sourceRoot":"","sources":["../../src/auth/device-code-flow.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAW7D,MAAM,WAAW,uBAAuB;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CACpB;AAQD,qBAAa,cAAc;IAQnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAP3B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IACzC,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,eAAe,CAAS;gBAGX,MAAM,EAAE,IAAI,CACzB,qBAAqB,EACrB,UAAU,GAAG,QAAQ,GAAG,OAAO,CAClC;IAKC,UAAU,IAAI,OAAO,CAAC,eAAe,CAAC;IAqFtC,oBAAoB,CACtB,MAAM,CAAC,EAAE,WAAW,GACrB,OAAO,CAAC,uBAAuB,CAAC;YAgJrB,YAAY;IAgB1B,OAAO,CAAC,YAAY;IAuBpB,OAAO,CAAC,KAAK;CAqChB"}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { TwitchError } from '../errors/twitch-error.js';
|
|
2
|
+
export class DeviceCodeFlow {
|
|
3
|
+
config;
|
|
4
|
+
fetchImpl;
|
|
5
|
+
deviceCode = null;
|
|
6
|
+
expiresAt = 0;
|
|
7
|
+
pollInterval = 5;
|
|
8
|
+
loginInProgress = false;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.fetchImpl = config.fetch ?? fetch;
|
|
12
|
+
}
|
|
13
|
+
async startLogin() {
|
|
14
|
+
if (this.loginInProgress || this.deviceCode !== null) {
|
|
15
|
+
throw new TwitchError('A Twitch device login is already active.', undefined, 'login_already_active');
|
|
16
|
+
}
|
|
17
|
+
const body = new URLSearchParams({
|
|
18
|
+
client_id: this.config.clientId,
|
|
19
|
+
scopes: this.config.scopes.join(' '),
|
|
20
|
+
});
|
|
21
|
+
let response;
|
|
22
|
+
try {
|
|
23
|
+
response = await this.fetchImpl('https://id.twitch.tv/oauth2/device', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
27
|
+
},
|
|
28
|
+
body,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (cause) {
|
|
32
|
+
throw new TwitchError('Unable to contact Twitch to start device login.', undefined, 'device_login_network_error', { cause });
|
|
33
|
+
}
|
|
34
|
+
const data = await this.readResponse(response, 'start device login');
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const error = this.asOAuthError(data);
|
|
37
|
+
throw new TwitchError(error.message ??
|
|
38
|
+
error.error ??
|
|
39
|
+
'Failed to start Twitch device login.', response.status, error.error ?? 'device_login_failed');
|
|
40
|
+
}
|
|
41
|
+
const result = data;
|
|
42
|
+
if (typeof result.device_code !== 'string' ||
|
|
43
|
+
!result.device_code ||
|
|
44
|
+
typeof result.user_code !== 'string' ||
|
|
45
|
+
!result.user_code ||
|
|
46
|
+
typeof result.verification_uri !== 'string' ||
|
|
47
|
+
!result.verification_uri ||
|
|
48
|
+
typeof result.expires_in !== 'number' ||
|
|
49
|
+
!Number.isFinite(result.expires_in) ||
|
|
50
|
+
result.expires_in <= 0) {
|
|
51
|
+
throw new TwitchError('Twitch returned an invalid device login response.', response.status, 'invalid_device_response');
|
|
52
|
+
}
|
|
53
|
+
this.deviceCode = result.device_code;
|
|
54
|
+
this.expiresAt = Date.now() + result.expires_in * 1000;
|
|
55
|
+
this.pollInterval =
|
|
56
|
+
typeof result.interval === 'number' && result.interval > 0
|
|
57
|
+
? result.interval
|
|
58
|
+
: 5;
|
|
59
|
+
return {
|
|
60
|
+
userCode: result.user_code,
|
|
61
|
+
verificationUri: result.verification_uri,
|
|
62
|
+
expiresIn: result.expires_in,
|
|
63
|
+
interval: this.pollInterval,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async waitForAuthorization(signal) {
|
|
67
|
+
const deviceCode = this.deviceCode;
|
|
68
|
+
if (!deviceCode) {
|
|
69
|
+
throw new TwitchError('No active device login. Call startLogin() first.', undefined, 'no_active_device_login');
|
|
70
|
+
}
|
|
71
|
+
if (this.loginInProgress) {
|
|
72
|
+
throw new TwitchError('Device authorization polling is already active.', undefined, 'login_poll_already_active');
|
|
73
|
+
}
|
|
74
|
+
this.loginInProgress = true;
|
|
75
|
+
try {
|
|
76
|
+
while (Date.now() < this.expiresAt) {
|
|
77
|
+
await this.delay(this.pollInterval * 1000, signal);
|
|
78
|
+
if (signal?.aborted) {
|
|
79
|
+
throw new TwitchError('Twitch device login was cancelled.', undefined, 'login_cancelled');
|
|
80
|
+
}
|
|
81
|
+
const body = new URLSearchParams({
|
|
82
|
+
client_id: this.config.clientId,
|
|
83
|
+
scopes: this.config.scopes.join(' '),
|
|
84
|
+
device_code: deviceCode,
|
|
85
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
86
|
+
});
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
response = await this.fetchImpl('https://id.twitch.tv/oauth2/token', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
93
|
+
},
|
|
94
|
+
body,
|
|
95
|
+
signal,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (cause) {
|
|
99
|
+
if (signal?.aborted) {
|
|
100
|
+
throw new TwitchError('Twitch device login was cancelled.', undefined, 'login_cancelled', { cause });
|
|
101
|
+
}
|
|
102
|
+
throw new TwitchError('Unable to contact Twitch while waiting for device authorization.', undefined, 'device_login_network_error', { cause });
|
|
103
|
+
}
|
|
104
|
+
const data = await this.readResponse(response, 'poll device login');
|
|
105
|
+
if (response.ok) {
|
|
106
|
+
const token = data;
|
|
107
|
+
if (typeof token.access_token !== 'string' ||
|
|
108
|
+
typeof token.refresh_token !== 'string' ||
|
|
109
|
+
typeof token.expires_in !== 'number' ||
|
|
110
|
+
!Array.isArray(token.scope)) {
|
|
111
|
+
throw new TwitchError('Twitch returned an invalid device token response.', response.status, 'invalid_token_response');
|
|
112
|
+
}
|
|
113
|
+
return token;
|
|
114
|
+
}
|
|
115
|
+
const error = this.asOAuthError(data);
|
|
116
|
+
switch (error.error ?? error.message) {
|
|
117
|
+
case 'authorization_pending':
|
|
118
|
+
continue;
|
|
119
|
+
case 'slow_down':
|
|
120
|
+
this.pollInterval += 5;
|
|
121
|
+
continue;
|
|
122
|
+
case 'access_denied':
|
|
123
|
+
throw new TwitchError('Twitch device login was denied by the user.', response.status, 'access_denied');
|
|
124
|
+
case 'expired_token':
|
|
125
|
+
throw new TwitchError('The Twitch device login code has expired.', response.status, 'device_code_expired');
|
|
126
|
+
default:
|
|
127
|
+
throw new TwitchError(error.message ??
|
|
128
|
+
error.error ??
|
|
129
|
+
'Twitch device authorization failed.', response.status, error.error ?? 'device_authorization_failed');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw new TwitchError('The Twitch device login code has expired.', undefined, 'device_code_expired');
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
this.deviceCode = null;
|
|
136
|
+
this.expiresAt = 0;
|
|
137
|
+
this.pollInterval = 5;
|
|
138
|
+
this.loginInProgress = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async readResponse(response, action) {
|
|
142
|
+
try {
|
|
143
|
+
return await response.json();
|
|
144
|
+
}
|
|
145
|
+
catch (cause) {
|
|
146
|
+
throw new TwitchError(`Twitch returned an invalid response while attempting to ${action}.`, response.status, 'invalid_oauth_response', { cause });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
asOAuthError(value) {
|
|
150
|
+
if (!value || typeof value !== 'object') {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
const data = value;
|
|
154
|
+
return {
|
|
155
|
+
error: typeof data.error === 'string'
|
|
156
|
+
? data.error
|
|
157
|
+
: undefined,
|
|
158
|
+
message: typeof data.message === 'string'
|
|
159
|
+
? data.message
|
|
160
|
+
: undefined,
|
|
161
|
+
status: typeof data.status === 'number'
|
|
162
|
+
? data.status
|
|
163
|
+
: undefined,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
delay(ms, signal) {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
if (signal?.aborted) {
|
|
169
|
+
reject(new TwitchError('Twitch device login was cancelled.', undefined, 'login_cancelled'));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
signal?.removeEventListener('abort', onAbort);
|
|
174
|
+
resolve();
|
|
175
|
+
}, ms);
|
|
176
|
+
const onAbort = () => {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
signal?.removeEventListener('abort', onAbort);
|
|
179
|
+
reject(new TwitchError('Twitch device login was cancelled.', undefined, 'login_cancelled'));
|
|
180
|
+
};
|
|
181
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=device-code-flow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-code-flow.js","sourceRoot":"","sources":["../../src/auth/device-code-flow.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TwitchConnectorConfig, TwitchValidatedToken } from '../api/types.js';
|
|
2
|
+
import type { DeviceCodeFlow } from './device-code-flow.js';
|
|
3
|
+
import type { TwitchTokenStore, TwitchTokens } from './token-store.js';
|
|
4
|
+
export declare class TokenManager {
|
|
5
|
+
private readonly config;
|
|
6
|
+
private readonly tokenStore;
|
|
7
|
+
private readonly deviceCodeFlow;
|
|
8
|
+
private refreshPromise;
|
|
9
|
+
constructor(config: Pick<TwitchConnectorConfig, 'clientId' | 'scopes' | 'fetch'>, tokenStore: TwitchTokenStore, deviceCodeFlow: DeviceCodeFlow);
|
|
10
|
+
private get fetcher();
|
|
11
|
+
getTokens(): Promise<TwitchTokens>;
|
|
12
|
+
getAccessToken(): Promise<string>;
|
|
13
|
+
getValidatedToken(): Promise<TwitchValidatedToken>;
|
|
14
|
+
startLogin(): Promise<import("./device-code-flow.js").DeviceCodeLogin>;
|
|
15
|
+
completeLogin(signal?: AbortSignal): Promise<TwitchTokens>;
|
|
16
|
+
logout(): Promise<void>;
|
|
17
|
+
private refreshTokens;
|
|
18
|
+
private performRefresh;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=token-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-manager.d.ts","sourceRoot":"","sources":["../../src/auth/token-manager.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,qBAAqB,EACrB,oBAAoB,EACrB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAavE,qBAAa,YAAY;IAIrB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAIvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,cAAc;IARjC,OAAO,CAAC,cAAc,CAAsC;gBAGzC,MAAM,EAAE,IAAI,CAC3B,qBAAqB,EACrB,UAAU,GAAG,QAAQ,GAAG,OAAO,CAChC,EACgB,UAAU,EAAE,gBAAgB,EAC5B,cAAc,EAAE,cAAc;IAGjD,OAAO,KAAK,OAAO,GAElB;IAEK,SAAS,IAAI,OAAO,CAAC,YAAY,CAAC;IAclC,cAAc,IAAI,OAAO,CAAC,MAAM,CAAC;IAIjC,iBAAiB,IAAI,OAAO,CAAC,oBAAoB,CAAC;IAmBlD,UAAU;IAIV,aAAa,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC;IAc1D,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAI7B,OAAO,CAAC,aAAa;YAYP,cAAc;CA4C7B"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { TwitchError } from '../errors/twitch-error.js';
|
|
2
|
+
const TWITCH_VALIDATE_URL = 'https://id.twitch.tv/oauth2/validate';
|
|
3
|
+
const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token';
|
|
4
|
+
export class TokenManager {
|
|
5
|
+
config;
|
|
6
|
+
tokenStore;
|
|
7
|
+
deviceCodeFlow;
|
|
8
|
+
refreshPromise = null;
|
|
9
|
+
constructor(config, tokenStore, deviceCodeFlow) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.tokenStore = tokenStore;
|
|
12
|
+
this.deviceCodeFlow = deviceCodeFlow;
|
|
13
|
+
}
|
|
14
|
+
get fetcher() {
|
|
15
|
+
return this.config.fetch ?? fetch;
|
|
16
|
+
}
|
|
17
|
+
async getTokens() {
|
|
18
|
+
const tokens = await this.tokenStore.load();
|
|
19
|
+
if (!tokens) {
|
|
20
|
+
throw new TwitchError('Not authenticated with Twitch.');
|
|
21
|
+
}
|
|
22
|
+
if (tokens.expiresAt > Date.now() + 60_000) {
|
|
23
|
+
return tokens;
|
|
24
|
+
}
|
|
25
|
+
return this.refreshTokens(tokens);
|
|
26
|
+
}
|
|
27
|
+
async getAccessToken() {
|
|
28
|
+
return (await this.getTokens()).accessToken;
|
|
29
|
+
}
|
|
30
|
+
async getValidatedToken() {
|
|
31
|
+
const tokens = await this.getTokens();
|
|
32
|
+
const response = await this.fetcher(TWITCH_VALIDATE_URL, {
|
|
33
|
+
headers: {
|
|
34
|
+
Authorization: `OAuth ${tokens.accessToken}`,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new TwitchError('Twitch token validation failed.', response.status);
|
|
39
|
+
}
|
|
40
|
+
return (await response.json());
|
|
41
|
+
}
|
|
42
|
+
async startLogin() {
|
|
43
|
+
return this.deviceCodeFlow.startLogin();
|
|
44
|
+
}
|
|
45
|
+
async completeLogin(signal) {
|
|
46
|
+
const response = await this.deviceCodeFlow.waitForAuthorization(signal);
|
|
47
|
+
const tokens = {
|
|
48
|
+
accessToken: response.access_token,
|
|
49
|
+
refreshToken: response.refresh_token,
|
|
50
|
+
expiresAt: Date.now() + response.expires_in * 1000,
|
|
51
|
+
scopes: response.scope,
|
|
52
|
+
};
|
|
53
|
+
await this.tokenStore.save(tokens);
|
|
54
|
+
return tokens;
|
|
55
|
+
}
|
|
56
|
+
async logout() {
|
|
57
|
+
await this.tokenStore.clear();
|
|
58
|
+
}
|
|
59
|
+
refreshTokens(current) {
|
|
60
|
+
if (this.refreshPromise) {
|
|
61
|
+
return this.refreshPromise;
|
|
62
|
+
}
|
|
63
|
+
this.refreshPromise = this.performRefresh(current).finally(() => {
|
|
64
|
+
this.refreshPromise = null;
|
|
65
|
+
});
|
|
66
|
+
return this.refreshPromise;
|
|
67
|
+
}
|
|
68
|
+
async performRefresh(current) {
|
|
69
|
+
const body = new URLSearchParams({
|
|
70
|
+
grant_type: 'refresh_token',
|
|
71
|
+
refresh_token: current.refreshToken,
|
|
72
|
+
client_id: this.config.clientId,
|
|
73
|
+
});
|
|
74
|
+
const response = await this.fetcher(TWITCH_TOKEN_URL, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
78
|
+
},
|
|
79
|
+
body,
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new TwitchError('Twitch token refresh failed. You may need to log in again.', response.status);
|
|
83
|
+
}
|
|
84
|
+
const result = (await response.json());
|
|
85
|
+
if (!result.access_token ||
|
|
86
|
+
!result.refresh_token ||
|
|
87
|
+
!Number.isFinite(result.expires_in)) {
|
|
88
|
+
throw new TwitchError('Twitch returned an invalid refresh response.');
|
|
89
|
+
}
|
|
90
|
+
const tokens = {
|
|
91
|
+
accessToken: result.access_token,
|
|
92
|
+
refreshToken: result.refresh_token,
|
|
93
|
+
expiresAt: Date.now() + result.expires_in * 1000,
|
|
94
|
+
scopes: result.scope ?? current.scopes,
|
|
95
|
+
};
|
|
96
|
+
await this.tokenStore.save(tokens);
|
|
97
|
+
return tokens;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=token-manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-manager.js","sourceRoot":"","sources":["../../src/auth/token-manager.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface TwitchTokens {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
refreshToken: string;
|
|
4
|
+
/** Unix timestamp in milliseconds. */
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
scopes: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface TwitchTokenStore {
|
|
9
|
+
load(): Promise<TwitchTokens | null>;
|
|
10
|
+
save(tokens: TwitchTokens): Promise<void>;
|
|
11
|
+
clear(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=token-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-store.d.ts","sourceRoot":"","sources":["../../src/auth/token-store.ts"],"names":[],"mappings":"AACA,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,sCAAsC;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,IAAI,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IACrC,IAAI,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-store.js","sourceRoot":"","sources":["../../src/auth/token-store.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TwitchApiClient } from '../api/twitch-api-client.js';
|
|
2
|
+
import type { SendChatMessageOptions, SendChatMessageResult, TwitchChatMessageHandler } from './types.js';
|
|
3
|
+
export declare class TwitchChatClient {
|
|
4
|
+
private readonly api;
|
|
5
|
+
private readonly handlers;
|
|
6
|
+
constructor(api: TwitchApiClient);
|
|
7
|
+
onMessage(handler: TwitchChatMessageHandler): () => void;
|
|
8
|
+
handleEventSubMessage(payload: unknown): Promise<void>;
|
|
9
|
+
sendMessage(options: SendChatMessageOptions): Promise<SendChatMessageResult>;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=twitch-chat-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"twitch-chat-client.d.ts","sourceRoot":"","sources":["../../src/chat/twitch-chat-client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,KAAK,EACV,sBAAsB,EACtB,qBAAqB,EAErB,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAyBpB,qBAAa,gBAAgB;IAGf,OAAO,CAAC,QAAQ,CAAC,GAAG;IAFhC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAuC;gBAEnC,GAAG,EAAE,eAAe;IAEjD,SAAS,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,IAAI;IAKlD,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA0BtD,WAAW,CACf,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,qBAAqB,CAAC;CA6BlC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { TwitchError } from '../errors/twitch-error.js';
|
|
2
|
+
export class TwitchChatClient {
|
|
3
|
+
api;
|
|
4
|
+
handlers = new Set();
|
|
5
|
+
constructor(api) {
|
|
6
|
+
this.api = api;
|
|
7
|
+
}
|
|
8
|
+
onMessage(handler) {
|
|
9
|
+
this.handlers.add(handler);
|
|
10
|
+
return () => this.handlers.delete(handler);
|
|
11
|
+
}
|
|
12
|
+
async handleEventSubMessage(payload) {
|
|
13
|
+
const event = payload;
|
|
14
|
+
if (!event.message_id ||
|
|
15
|
+
!event.broadcaster_user_id ||
|
|
16
|
+
!event.chatter_user_id) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const message = {
|
|
20
|
+
messageId: event.message_id,
|
|
21
|
+
broadcasterId: event.broadcaster_user_id,
|
|
22
|
+
chatterId: event.chatter_user_id,
|
|
23
|
+
chatterName: event.chatter_user_name ?? '',
|
|
24
|
+
text: event.message?.text ?? '',
|
|
25
|
+
sentAt: event.message_timestamp ?? new Date().toISOString(),
|
|
26
|
+
raw: payload,
|
|
27
|
+
};
|
|
28
|
+
for (const handler of this.handlers) {
|
|
29
|
+
await handler(message);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async sendMessage(options) {
|
|
33
|
+
const senderId = options.senderId ?? options.broadcasterId;
|
|
34
|
+
if (!options.message.trim()) {
|
|
35
|
+
throw new TwitchError('Chat message cannot be empty.');
|
|
36
|
+
}
|
|
37
|
+
const response = await this.api.post('/chat/messages', {
|
|
38
|
+
broadcaster_id: options.broadcasterId,
|
|
39
|
+
sender_id: senderId,
|
|
40
|
+
message: options.message,
|
|
41
|
+
});
|
|
42
|
+
const result = response.data?.[0];
|
|
43
|
+
if (!result) {
|
|
44
|
+
throw new TwitchError('Twitch returned no chat-send result.');
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
messageId: result.message_id,
|
|
48
|
+
isSent: result.is_sent,
|
|
49
|
+
dropReason: result.drop_reason?.message ?? result.drop_reason?.code,
|
|
50
|
+
raw: response,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=twitch-chat-client.js.map
|