Added connections files
This commit is contained in:
parent
9addb4ad35
commit
d8e69ba1bd
22 changed files with 1695 additions and 0 deletions
5
src/connections/BattleNet/BattleNetSettings.ts
Normal file
5
src/connections/BattleNet/BattleNetSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class BattleNetSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
125
src/connections/BattleNet/index.ts
Normal file
125
src/connections/BattleNet/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/Discord/DiscordSettings.ts
Normal file
5
src/connections/Discord/DiscordSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class DiscordSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
124
src/connections/Discord/index.ts
Normal file
124
src/connections/Discord/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/EpicGames/EpicGamesSettings.ts
Normal file
5
src/connections/EpicGames/EpicGamesSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class EpicGamesSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
137
src/connections/EpicGames/index.ts
Normal file
137
src/connections/EpicGames/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/Facebook/FacebookSettings.ts
Normal file
5
src/connections/Facebook/FacebookSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class FacebookSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
131
src/connections/Facebook/index.ts
Normal file
131
src/connections/Facebook/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/GitHub/GitHubSettings.ts
Normal file
5
src/connections/GitHub/GitHubSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class GitHubSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
118
src/connections/GitHub/index.ts
Normal file
118
src/connections/GitHub/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/Reddit/RedditSettings.ts
Normal file
5
src/connections/Reddit/RedditSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class RedditSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
137
src/connections/Reddit/index.ts
Normal file
137
src/connections/Reddit/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/Spotify/SpotifySettings.ts
Normal file
5
src/connections/Spotify/SpotifySettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class SpotifySettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
185
src/connections/Spotify/index.ts
Normal file
185
src/connections/Spotify/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/Twitch/TwitchSettings.ts
Normal file
5
src/connections/Twitch/TwitchSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class TwitchSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
172
src/connections/Twitch/index.ts
Normal file
172
src/connections/Twitch/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/Twitter/TwitterSettings.ts
Normal file
5
src/connections/Twitter/TwitterSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class TwitterSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
178
src/connections/Twitter/index.ts
Normal file
178
src/connections/Twitter/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/Xbox/XboxSettings.ts
Normal file
5
src/connections/Xbox/XboxSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class XboxSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
191
src/connections/Xbox/index.ts
Normal file
191
src/connections/Xbox/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/connections/Youtube/YoutubeSettings.ts
Normal file
5
src/connections/Youtube/YoutubeSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class YoutubeSettings {
|
||||||
|
enabled: boolean = false;
|
||||||
|
clientId: string | null = null;
|
||||||
|
clientSecret: string | null = null;
|
||||||
|
}
|
142
src/connections/Youtube/index.ts
Normal file
142
src/connections/Youtube/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue