Added connections files

This commit is contained in:
Toastie 2024-09-07 17:23:54 +12:00
parent 9addb4ad35
commit d8e69ba1bd
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
22 changed files with 1695 additions and 0 deletions

View file

@ -0,0 +1,5 @@
export class BattleNetSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,125 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
} from "@valkyrie/util";
import wretch from "wretch";
import { BattleNetSettings } from "./BattleNetSettings";
interface BattleNetConnectionUser {
sub: string;
id: number;
battletag: string;
}
// interface BattleNetErrorResponse {
// error: string;
// error_description: string;
// }
export default class BattleNetConnection extends Connection {
public readonly id = "battlenet";
public readonly authorizeUrl = "https://oauth.battle.net/authorize";
public readonly tokenUrl = "https://oauth.battle.net/token";
public readonly userInfoUrl = "https://us.battle.net/oauth/userinfo";
public readonly scopes = [];
settings: BattleNetSettings = new BattleNetSettings();
init(): void {
this.settings = ConnectionLoader.getConnectionConfig<BattleNetSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("scope", this.scopes.join(" "));
url.searchParams.append("state", state);
url.searchParams.append("response_type", "code");
return url.toString();
}
getTokenUrl(): string {
return this.tokenUrl;
}
async exchangeCode(
state: string,
code: string,
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
this.validateState(state);
const url = this.getTokenUrl();
return wretch(url.toString())
.headers({
Accept: "application/json",
})
.body(
new URLSearchParams({
grant_type: "authorization_code",
code: code,
client_id: this.settings.clientId as string,
client_secret: this.settings.clientSecret as string,
redirect_uri: this.getRedirectUri(),
}),
)
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<BattleNetConnectionUser> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
})
.get()
.json<BattleNetConnectionUser>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id.toString());
if (exists) return null;
return await this.createConnection({
user_id: userId,
external_id: userInfo.id.toString(),
friend_sync: params.friend_sync,
name: userInfo.battletag,
type: this.id,
});
}
}

View file

@ -0,0 +1,5 @@
export class DiscordSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,124 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
} from "@valkyrie/util";
import wretch from "wretch";
import { DiscordSettings } from "./DiscordSettings";
interface UserResponse {
id: string;
username: string;
discriminator: string;
avatar_url: string | null;
}
export default class DiscordConnection extends Connection {
public readonly id = "discord";
public readonly authorizeUrl = "https://discord.com/api/oauth2/authorize";
public readonly tokenUrl = "https://discord.com/api/oauth2/token";
public readonly userInfoUrl = "https://discord.com/api/users/@me";
public readonly scopes = ["identify"];
settings: DiscordSettings = new DiscordSettings();
init(): void {
this.settings = ConnectionLoader.getConnectionConfig<DiscordSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("state", state);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("scope", this.scopes.join(" "));
url.searchParams.append("response_type", "code");
// controls whether, on repeated authorizations, the consent screen is shown
url.searchParams.append("consent", "none");
url.searchParams.append("redirect_uri", this.getRedirectUri());
return url.toString();
}
getTokenUrl(): string {
return this.tokenUrl;
}
async exchangeCode(
state: string,
code: string,
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
this.validateState(state);
const url = this.getTokenUrl();
return wretch(url.toString())
.headers({
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
})
.body(
new URLSearchParams({
client_id: this.settings.clientId as string,
client_secret: this.settings.clientSecret as string,
grant_type: "authorization_code",
code: code,
redirect_uri: this.getRedirectUri(),
}),
)
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<UserResponse> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
})
.get()
.json<UserResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id);
if (exists) return null;
return await this.createConnection({
user_id: userId,
external_id: userInfo.id,
friend_sync: params.friend_sync,
name: `${userInfo.username}#${userInfo.discriminator}`,
type: this.id,
});
}
}

View file

@ -0,0 +1,5 @@
export class EpicGamesSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,137 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
} from "@valkyrie/util";
import wretch from "wretch";
import { EpicGamesSettings } from "./EpicGamesSettings";
export interface UserResponse {
accountId: string;
displayName: string;
preferredLanguage: string;
}
export interface EpicTokenResponse
extends ConnectedAccountCommonOAuthTokenResponse {
expires_at: string;
refresh_expires_in: number;
refresh_expires_at: string;
account_id: string;
client_id: string;
application_id: string;
}
export default class EpicGamesConnection extends Connection {
public readonly id = "epicgames";
public readonly authorizeUrl = "https://www.epicgames.com/id/authorize";
public readonly tokenUrl = "https://api.epicgames.dev/epic/oauth/v1/token";
public readonly userInfoUrl =
"https://api.epicgames.dev/epic/id/v1/accounts";
public readonly scopes = ["basic profile"];
settings: EpicGamesSettings = new EpicGamesSettings();
init(): void {
this.settings = ConnectionLoader.getConnectionConfig<EpicGamesSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
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<EpicTokenResponse> {
this.validateState(state);
const url = this.getTokenUrl();
return wretch(url.toString())
.headers({
Accept: "application/json",
Authorization: `Basic ${Buffer.from(
`${this.settings.clientId}:${this.settings.clientSecret}`,
).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
})
.body(
new URLSearchParams({
grant_type: "authorization_code",
code,
}),
)
.post()
.json<EpicTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<UserResponse[]> {
const { sub } = JSON.parse(
Buffer.from(token.split(".")[1], "base64").toString("utf8"),
);
const url = new URL(this.userInfoUrl);
url.searchParams.append("accountId", sub);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
})
.get()
.json<UserResponse[]>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo[0].accountId);
if (exists) return null;
return await this.createConnection({
user_id: userId,
external_id: userInfo[0].accountId,
friend_sync: params.friend_sync,
name: userInfo[0].displayName,
type: this.id,
});
}
}

View file

@ -0,0 +1,5 @@
export class FacebookSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,131 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
} from "@valkyrie/util";
import wretch from "wretch";
import { FacebookSettings } from "./FacebookSettings";
export interface FacebookErrorResponse {
error: {
message: string;
type: string;
code: number;
fbtrace_id: string;
};
}
interface UserResponse {
name: string;
id: string;
}
export default class FacebookConnection extends Connection {
public readonly id = "facebook";
public readonly authorizeUrl =
"https://www.facebook.com/v14.0/dialog/oauth";
public readonly tokenUrl =
"https://graph.facebook.com/v14.0/oauth/access_token";
public readonly userInfoUrl = "https://graph.facebook.com/v14.0/me";
public readonly scopes = ["public_profile"];
settings: FacebookSettings = new FacebookSettings();
init(): void {
this.settings = ConnectionLoader.getConnectionConfig<FacebookSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("state", state);
url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" "));
url.searchParams.append("display", "popup");
return url.toString();
}
getTokenUrl(code: string): string {
const url = new URL(this.tokenUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append(
"client_secret",
this.settings.clientSecret as string,
);
url.searchParams.append("code", code);
url.searchParams.append("redirect_uri", this.getRedirectUri());
return url.toString();
}
async exchangeCode(
state: string,
code: string,
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
this.validateState(state);
const url = this.getTokenUrl(code);
return wretch(url.toString())
.headers({
Accept: "application/json",
})
.get()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<UserResponse> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
})
.get()
.json<UserResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id);
if (exists) return null;
return await this.createConnection({
user_id: userId,
external_id: userInfo.id,
friend_sync: params.friend_sync,
name: userInfo.name,
type: this.id,
});
}
}

View file

@ -0,0 +1,5 @@
export class GitHubSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,118 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
} from "@valkyrie/util";
import wretch from "wretch";
import { GitHubSettings } from "./GitHubSettings";
interface UserResponse {
login: string;
id: number;
name: string;
}
export default class GitHubConnection extends Connection {
public readonly id = "github";
public readonly authorizeUrl = "https://github.com/login/oauth/authorize";
public readonly tokenUrl = "https://github.com/login/oauth/access_token";
public readonly userInfoUrl = "https://api.github.com/user";
public readonly scopes = ["read:user"];
settings: GitHubSettings = new GitHubSettings();
init(): void {
this.settings = ConnectionLoader.getConnectionConfig<GitHubSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("scope", this.scopes.join(" "));
url.searchParams.append("state", state);
return url.toString();
}
getTokenUrl(code: string): string {
const url = new URL(this.tokenUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append(
"client_secret",
this.settings.clientSecret as string,
);
url.searchParams.append("code", code);
return url.toString();
}
async exchangeCode(
state: string,
code: string,
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
this.validateState(state);
const url = this.getTokenUrl(code);
return wretch(url.toString())
.headers({
Accept: "application/json",
})
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<UserResponse> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
})
.get()
.json<UserResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id.toString());
if (exists) return null;
return await this.createConnection({
user_id: userId,
external_id: userInfo.id.toString(),
friend_sync: params.friend_sync,
name: userInfo.login,
type: this.id,
});
}
}

View file

@ -0,0 +1,5 @@
export class RedditSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,137 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
} from "@valkyrie/util";
import wretch from "wretch";
import { RedditSettings } from "./RedditSettings";
export interface UserResponse {
verified: boolean;
coins: number;
id: string;
is_mod: boolean;
has_verified_email: boolean;
total_karma: number;
name: string;
created: number;
gold_creddits: number;
created_utc: number;
}
export interface ErrorResponse {
message: string;
error: number;
}
export default class RedditConnection extends Connection {
public readonly id = "reddit";
public readonly authorizeUrl = "https://www.reddit.com/api/v1/authorize";
public readonly tokenUrl = "https://www.reddit.com/api/v1/access_token";
public readonly userInfoUrl = "https://oauth.reddit.com/api/v1/me";
public readonly scopes = ["identity"];
settings: RedditSettings = new RedditSettings();
init(): void {
this.settings = ConnectionLoader.getConnectionConfig<RedditSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
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 wretch(url.toString())
.headers({
Accept: "application/json",
Authorization: `Basic ${Buffer.from(
`${this.settings.clientId}:${this.settings.clientSecret}`,
).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
})
.body(
new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: this.getRedirectUri(),
}),
)
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<UserResponse> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
})
.get()
.json<UserResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id.toString());
if (exists) return null;
// TODO: connection metadata
return await this.createConnection({
user_id: userId,
external_id: userInfo.id.toString(),
friend_sync: params.friend_sync,
name: userInfo.name,
verified: userInfo.has_verified_email,
type: this.id,
});
}
}

View file

@ -0,0 +1,5 @@
export class SpotifySettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,185 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
RefreshableConnection,
} from "@valkyrie/util";
import wretch from "wretch";
import { SpotifySettings } from "./SpotifySettings";
export interface UserResponse {
display_name: string;
id: string;
}
export interface TokenErrorResponse {
error: string;
error_description: string;
}
export interface ErrorResponse {
error: {
status: number;
message: string;
};
}
export default class SpotifyConnection extends RefreshableConnection {
public readonly id = "spotify";
public readonly authorizeUrl = "https://accounts.spotify.com/authorize";
public readonly tokenUrl = "https://accounts.spotify.com/api/token";
public readonly userInfoUrl = "https://api.spotify.com/v1/me";
public readonly scopes = [
"user-read-private",
"user-read-playback-state",
"user-modify-playback-state",
"user-read-currently-playing",
];
settings: SpotifySettings = new SpotifySettings();
init(): void {
/**
* The way Discord shows the currently playing song is by using Spotifys partner API. This is obviously not possible for us.
* So to prevent spamming the spotify api we disable the ability to refresh.
*/
this.refreshEnabled = false;
this.settings = ConnectionLoader.getConnectionConfig<SpotifySettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
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 wretch(url.toString())
.headers({
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`,
})
.body(
new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: this.getRedirectUri(),
}),
)
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(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 wretch(url.toString())
.headers({
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`,
})
.body(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token,
}),
)
.post()
.unauthorized(async () => {
// assume the token was revoked
await connectedAccount.revoke();
return DiscordApiErrors.CONNECTION_REVOKED;
})
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<UserResponse> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
})
.get()
.json<UserResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.id);
if (exists) return null;
return await this.createConnection({
token_data: { ...tokenData, fetched_at: Date.now() },
user_id: userId,
external_id: userInfo.id,
friend_sync: params.friend_sync,
name: userInfo.display_name,
type: this.id,
});
}
}

View file

@ -0,0 +1,5 @@
export class TwitchSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,172 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
RefreshableConnection,
} from "@valkyrie/util";
import wretch from "wretch";
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<TwitchSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
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 wretch(url.toString())
.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 as string,
client_secret: this.settings.clientSecret as string,
redirect_uri: this.getRedirectUri(),
}),
)
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(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 wretch(url.toString())
.headers({
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
})
.body(
new URLSearchParams({
grant_type: "refresh_token",
client_id: this.settings.clientId as string,
client_secret: this.settings.clientSecret as string,
refresh_token: refresh_token,
}),
)
.post()
.unauthorized(async () => {
// assume the token was revoked
await connectedAccount.revoke();
return DiscordApiErrors.CONNECTION_REVOKED;
})
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<TwitchConnectionUserResponse> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
"Client-Id": this.settings.clientId as string,
})
.get()
.json<TwitchConnectionUserResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, 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,
});
}
}

View file

@ -0,0 +1,5 @@
export class TwitterSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,178 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
RefreshableConnection,
} from "@valkyrie/util";
import wretch from "wretch";
import { TwitterSettings } from "./TwitterSettings";
interface TwitterUserResponse {
data: {
id: string;
name: string;
username: string;
created_at: string;
location: string;
url: string;
description: string;
verified: string;
};
}
// interface TwitterErrorResponse {
// error: string;
// error_description: string;
// }
export default class TwitterConnection extends RefreshableConnection {
public readonly id = "twitter";
public readonly authorizeUrl = "https://twitter.com/i/oauth2/authorize";
public readonly tokenUrl = "https://api.twitter.com/2/oauth2/token";
public readonly userInfoUrl =
"https://api.twitter.com/2/users/me?user.fields=created_at%2Cdescription%2Cid%2Cname%2Cusername%2Cverified%2Clocation%2Curl";
public readonly scopes = ["users.read", "tweet.read"];
settings: TwitterSettings = new TwitterSettings();
init(): void {
this.settings = ConnectionLoader.getConnectionConfig<TwitterSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" "));
url.searchParams.append("state", state);
url.searchParams.append("code_challenge", "challenge"); // TODO: properly use PKCE challenge
url.searchParams.append("code_challenge_method", "plain");
return url.toString();
}
getTokenUrl(): string {
return this.tokenUrl;
}
async exchangeCode(
state: string,
code: string,
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
this.validateState(state);
const url = this.getTokenUrl();
return wretch(url.toString())
.headers({
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`,
})
.body(
new URLSearchParams({
grant_type: "authorization_code",
code: code,
client_id: this.settings.clientId as string,
redirect_uri: this.getRedirectUri(),
code_verifier: "challenge", // TODO: properly use PKCE challenge
}),
)
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(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 wretch(url.toString())
.headers({
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`,
})
.body(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token,
client_id: this.settings.clientId as string,
redirect_uri: this.getRedirectUri(),
code_verifier: "challenge", // TODO: properly use PKCE challenge
}),
)
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<TwitterUserResponse> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
})
.get()
.json<TwitterUserResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.data.id);
if (exists) return null;
return await this.createConnection({
token_data: { ...tokenData, fetched_at: Date.now() },
user_id: userId,
external_id: userInfo.data.id,
friend_sync: params.friend_sync,
name: userInfo.data.name,
type: this.id,
});
}
}

View file

@ -0,0 +1,5 @@
export class XboxSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,191 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
} from "@valkyrie/util";
import wretch from "wretch";
import { XboxSettings } from "./XboxSettings";
interface XboxUserResponse {
IssueInstant: string;
NotAfter: string;
Token: string;
DisplayClaims: {
xui: {
gtg: string;
xid: string;
uhs: string;
agg: string;
usr: string;
utr: string;
prv: string;
}[];
};
}
// interface XboxErrorResponse {
// error: string;
// error_description: string;
// }
export default class XboxConnection extends Connection {
public readonly id = "xbox";
public readonly authorizeUrl =
"https://login.live.com/oauth20_authorize.srf";
public readonly tokenUrl = "https://login.live.com/oauth20_token.srf";
public readonly userInfoUrl =
"https://xsts.auth.xboxlive.com/xsts/authorize";
public readonly userAuthUrl =
"https://user.auth.xboxlive.com/user/authenticate";
public readonly scopes = ["Xboxlive.signin", "Xboxlive.offline_access"];
settings: XboxSettings = new XboxSettings();
init(): void {
this.settings = ConnectionLoader.getConnectionConfig<XboxSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
url.searchParams.append("response_type", "code");
url.searchParams.append("scope", this.scopes.join(" "));
url.searchParams.append("state", state);
url.searchParams.append("approval_prompt", "auto");
return url.toString();
}
getTokenUrl(): string {
return this.tokenUrl;
}
async getUserToken(token: string): Promise<string> {
return wretch(this.userAuthUrl)
.headers({
"x-xbl-contract-version": "3",
"Content-Type": "application/json",
Accept: "application/json",
})
.body(
JSON.stringify({
RelyingParty: "http://auth.xboxlive.com",
TokenType: "JWT",
Properties: {
AuthMethod: "RPS",
SiteName: "user.auth.xboxlive.com",
RpsTicket: `d=${token}`,
},
}),
)
.post()
.json((res: XboxUserResponse) => res.Token)
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async exchangeCode(
state: string,
code: string,
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
this.validateState(state);
const url = this.getTokenUrl();
return wretch(url.toString())
.headers({
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${this.settings.clientId as string}:${
this.settings.clientSecret as string
}`,
).toString("base64")}`,
})
.body(
new URLSearchParams({
grant_type: "authorization_code",
code: code,
client_id: this.settings.clientId as string,
redirect_uri: this.getRedirectUri(),
scope: this.scopes.join(" "),
}),
)
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<XboxUserResponse> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
"x-xbl-contract-version": "3",
"Content-Type": "application/json",
Accept: "application/json",
})
.body(
JSON.stringify({
RelyingParty: "http://xboxlive.com",
TokenType: "JWT",
Properties: {
UserTokens: [token],
SandboxId: "RETAIL",
},
}),
)
.post()
.json<XboxUserResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userToken = await this.getUserToken(tokenData.access_token);
const userInfo = await this.getUser(userToken);
const exists = await this.hasConnection(
userId,
userInfo.DisplayClaims.xui[0].xid,
);
if (exists) return null;
return await this.createConnection({
token_data: { ...tokenData, fetched_at: Date.now() },
user_id: userId,
external_id: userInfo.DisplayClaims.xui[0].xid,
friend_sync: params.friend_sync,
name: userInfo.DisplayClaims.xui[0].gtg,
type: this.id,
});
}
}

View file

@ -0,0 +1,5 @@
export class YoutubeSettings {
enabled: boolean = false;
clientId: string | null = null;
clientSecret: string | null = null;
}

View file

@ -0,0 +1,142 @@
import {
ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse,
Connection,
ConnectionCallbackSchema,
ConnectionLoader,
DiscordApiErrors,
} from "@valkyrie/util";
import wretch from "wretch";
import { YoutubeSettings } from "./YoutubeSettings";
interface YouTubeConnectionChannelListResult {
items: {
snippet: {
// thumbnails: Thumbnails;
title: string;
country: string;
publishedAt: string;
// localized: Localized;
description: string;
};
kind: string;
etag: string;
id: string;
}[];
kind: string;
etag: string;
pageInfo: {
resultsPerPage: number;
totalResults: number;
};
}
export default class YoutubeConnection extends Connection {
public readonly id = "youtube";
public readonly authorizeUrl =
"https://accounts.google.com/o/oauth2/v2/auth";
public readonly tokenUrl = "https://oauth2.googleapis.com/token";
public readonly userInfoUrl =
"https://www.googleapis.com/youtube/v3/channels?mine=true&part=snippet";
public readonly scopes = [
"https://www.googleapis.com/auth/youtube.readonly",
];
settings: YoutubeSettings = new YoutubeSettings();
init(): void {
this.settings = ConnectionLoader.getConnectionConfig<YoutubeSettings>(
this.id,
this.settings,
);
if (
this.settings.enabled &&
(!this.settings.clientId || !this.settings.clientSecret)
)
throw new Error(`Invalid settings for connection ${this.id}`);
}
getAuthorizationUrl(userId: string): string {
const state = this.createState(userId);
const url = new URL(this.authorizeUrl);
url.searchParams.append("client_id", this.settings.clientId as string);
url.searchParams.append("redirect_uri", this.getRedirectUri());
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 wretch(url.toString())
.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 as string,
client_secret: this.settings.clientSecret as string,
redirect_uri: this.getRedirectUri(),
}),
)
.post()
.json<ConnectedAccountCommonOAuthTokenResponse>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async getUser(token: string): Promise<YouTubeConnectionChannelListResult> {
const url = new URL(this.userInfoUrl);
return wretch(url.toString())
.headers({
Authorization: `Bearer ${token}`,
})
.get()
.json<YouTubeConnectionChannelListResult>()
.catch((e) => {
console.error(e);
throw DiscordApiErrors.GENERAL_ERROR;
});
}
async handleCallback(
params: ConnectionCallbackSchema,
): Promise<ConnectedAccount | null> {
const { state, code } = params;
if (!code) throw new Error("No code provided");
const userId = this.getUserId(state);
const tokenData = await this.exchangeCode(state, code);
const userInfo = await this.getUser(tokenData.access_token);
const exists = await this.hasConnection(userId, userInfo.items[0].id);
if (exists) return null;
return await this.createConnection({
token_data: { ...tokenData, fetched_at: Date.now() },
user_id: userId,
external_id: userInfo.items[0].id,
friend_sync: params.friend_sync,
name: userInfo.items[0].snippet.title,
type: this.id,
});
}
}