summary refs log tree commit diff
path: root/src/connections/Twitch/index.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/connections/Twitch/index.ts')
-rw-r--r--src/connections/Twitch/index.ts196
1 files changed, 196 insertions, 0 deletions
diff --git a/src/connections/Twitch/index.ts b/src/connections/Twitch/index.ts
new file mode 100644

index 00000000..ce04f098 --- /dev/null +++ b/src/connections/Twitch/index.ts
@@ -0,0 +1,196 @@ +import { + ApiError, + Config, + ConnectedAccount, + ConnectedAccountCommonOAuthTokenResponse, + ConnectionCallbackSchema, + ConnectionLoader, + DiscordApiErrors, +} from "@fosscord/util"; +import fetch from "node-fetch"; +import RefreshableConnection from "../../util/connections/RefreshableConnection"; +import { TwitchSettings } from "./TwitchSettings"; + +interface TwitchConnectionUserResponse { + data: { + id: string; + login: string; + display_name: string; + type: string; + broadcaster_type: string; + description: string; + profile_image_url: string; + offline_image_url: string; + view_count: number; + created_at: string; + }[]; +} + +export default class TwitchConnection extends RefreshableConnection { + public readonly id = "twitch"; + public readonly authorizeUrl = "https://id.twitch.tv/oauth2/authorize"; + public readonly tokenUrl = "https://id.twitch.tv/oauth2/token"; + public readonly userInfoUrl = "https://api.twitch.tv/helix/users"; + public readonly scopes = [ + "channel_subscriptions", + "channel_check_subscription", + "channel:read:subscriptions", + ]; + settings: TwitchSettings = new TwitchSettings(); + + init(): void { + this.settings = ConnectionLoader.getConnectionConfig( + this.id, + this.settings, + ) as TwitchSettings; + } + + getAuthorizationUrl(userId: string): string { + const state = this.createState(userId); + const url = new URL(this.authorizeUrl); + + url.searchParams.append("client_id", this.settings.clientId!); + // TODO: probably shouldn't rely on cdn as this could be different from what we actually want. we should have an api endpoint setting. + url.searchParams.append( + "redirect_uri", + `${ + Config.get().cdn.endpointPrivate || "http://localhost:3001" + }/connections/${this.id}/callback`, + ); + url.searchParams.append("response_type", "code"); + url.searchParams.append("scope", this.scopes.join(" ")); + url.searchParams.append("state", state); + return url.toString(); + } + + getTokenUrl(): string { + return this.tokenUrl; + } + + async exchangeCode( + state: string, + code: string, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + this.validateState(state); + + const url = this.getTokenUrl(); + + return fetch(url.toString(), { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + redirect_uri: `${ + Config.get().cdn.endpointPrivate || "http://localhost:3001" + }/connections/${this.id}/callback`, + }), + }) + .then((res) => { + if (!res.ok) { + throw new ApiError("Failed to exchange code", 0, 400); + } + + return res.json(); + }) + .catch((e) => { + console.error( + `Error exchanging code for ${this.id} connection: ${e}`, + ); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async refreshToken( + connectedAccount: ConnectedAccount, + ): Promise<ConnectedAccountCommonOAuthTokenResponse> { + if (!connectedAccount.token_data?.refresh_token) + throw new Error("No refresh token available."); + const refresh_token = connectedAccount.token_data.refresh_token; + + const url = this.getTokenUrl(); + + return fetch(url.toString(), { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.settings.clientId!, + client_secret: this.settings.clientSecret!, + refresh_token: refresh_token, + }), + }) + .then(async (res) => { + if ([400, 401].includes(res.status)) { + // assume the token was revoked + await connectedAccount.revoke(); + return DiscordApiErrors.CONNECTION_REVOKED; + } + // otherwise throw a general error + if (!res.ok) { + throw new ApiError("Failed to refresh token", 0, 400); + } + + return await res.json(); + }) + .catch((e) => { + console.error( + `Error refreshing token for ${this.id} connection: ${e}`, + ); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async getUser(token: string): Promise<TwitchConnectionUserResponse> { + const url = new URL(this.userInfoUrl); + return fetch(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Client-Id": this.settings.clientId!, + }, + }) + .then((res) => { + if (!res.ok) { + throw new ApiError("Failed to fetch user", 0, 400); + } + + return res.json(); + }) + .catch((e) => { + console.error( + `Error fetching user for ${this.id} connection: ${e}`, + ); + throw DiscordApiErrors.GENERAL_ERROR; + }); + } + + async handleCallback( + params: ConnectionCallbackSchema, + ): Promise<ConnectedAccount | null> { + const userId = this.getUserId(params.state); + const tokenData = await this.exchangeCode(params.state, params.code!); + const userInfo = await this.getUser(tokenData.access_token); + + const exists = await this.hasConnection(userId, userInfo.data[0].id); + + if (exists) return null; + + return await this.createConnection({ + token_data: { ...tokenData, fetched_at: Date.now() }, + user_id: userId, + external_id: userInfo.data[0].id, + friend_sync: params.friend_sync, + name: userInfo.data[0].display_name, + type: this.id, + }); + } +}