Add Twitch, error handling, revokation changes, etc

This commit is contained in:
Puyodead1 2022-12-24 16:24:58 -05:00
parent a60f147156
commit 6d6944cfee
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
19 changed files with 2278 additions and 49 deletions

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,7 @@ const ALLOWED_CONNECTIONS = ["twitch", "youtube"];
router.get("/", route({}), async (req: Request, res: Response) => { router.get("/", route({}), async (req: Request, res: Response) => {
const { connection_name, connection_id } = req.params; const { connection_name, connection_id } = req.params;
const connection = ConnectionStore.connections.get(connection_id); const connection = ConnectionStore.connections.get(connection_name);
if (!ALLOWED_CONNECTIONS.includes(connection_name) || !connection) if (!ALLOWED_CONNECTIONS.includes(connection_name) || !connection)
throw FieldErrors({ throw FieldErrors({
@ -41,7 +41,7 @@ router.get("/", route({}), async (req: Request, res: Response) => {
const connectedAccount = await ConnectedAccount.findOne({ const connectedAccount = await ConnectedAccount.findOne({
where: { where: {
type: connection_name, type: connection_name,
id: connection_id, external_id: connection_id,
user_id: req.user_id, user_id: req.user_id,
}, },
select: [ select: [
@ -64,14 +64,12 @@ router.get("/", route({}), async (req: Request, res: Response) => {
throw new ApiError("No token data", 0, 400); throw new ApiError("No token data", 0, 400);
let access_token = connectedAccount.token_data.access_token; let access_token = connectedAccount.token_data.access_token;
const { expires_at, expires_in } = connectedAccount.token_data; const { expires_at, expires_in, fetched_at } = connectedAccount.token_data;
if (expires_at && expires_at < Date.now()) { if (
if (!(connection instanceof RefreshableConnection)) (expires_at && expires_at < Date.now()) ||
throw new ApiError("Access token expired", 0, 400); (expires_in && fetched_at + expires_in * 1000 < Date.now())
const tokenData = await connection.refresh(connectedAccount); ) {
access_token = tokenData.access_token;
} else if (expires_in && expires_in < Date.now()) {
if (!(connection instanceof RefreshableConnection)) if (!(connection instanceof RefreshableConnection))
throw new ApiError("Access token expired", 0, 400); throw new ApiError("Access token expired", 0, 400);
const tokenData = await connection.refresh(connectedAccount); const tokenData = await connection.refresh(connectedAccount);

View File

@ -1,5 +1,10 @@
import { route } from "@fosscord/api"; import { route } from "@fosscord/api";
import { ConnectedAccount, DiscordApiErrors, emitEvent, ConnectionUpdateSchema } from "@fosscord/util"; import {
ConnectedAccount,
ConnectionUpdateSchema,
DiscordApiErrors,
emitEvent
} from "@fosscord/util";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
const router = Router(); const router = Router();
@ -35,6 +40,8 @@ router.patch(
//@ts-ignore For some reason the client sends this as a boolean, even tho docs say its a number? //@ts-ignore For some reason the client sends this as a boolean, even tho docs say its a number?
if (typeof body.visibility === "boolean") body.visibility = body.visibility ? 1 : 0; if (typeof body.visibility === "boolean") body.visibility = body.visibility ? 1 : 0;
//@ts-ignore For some reason the client sends this as a boolean, even tho docs say its a number?
if (typeof body.show_activity === "boolean") body.show_activity = body.show_activity ? 1 : 0;
connection.assign(req.body); connection.assign(req.body);
@ -58,7 +65,7 @@ router.delete("/", route({}), async (req: Request, res: Response) => {
user_id: req.user_id, user_id: req.user_id,
external_id: connection_id, external_id: connection_id,
type: connection_name, type: connection_name,
} },
}); });
await Promise.all([ await Promise.all([
@ -67,7 +74,7 @@ router.delete("/", route({}), async (req: Request, res: Response) => {
event: "USER_CONNECTIONS_UPDATE", event: "USER_CONNECTIONS_UPDATE",
data: account, data: account,
user_id: req.user_id, user_id: req.user_id,
}) }),
]); ]);
return res.sendStatus(200); return res.sendStatus(200);

View File

@ -1,4 +1,5 @@
import { import {
ApiError,
Config, Config,
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
@ -81,7 +82,13 @@ export default class BattleNetConnection extends Connection {
}/connections/${this.id}/callback`, }/connections/${this.id}/callback`,
}), }),
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to exchange code", 0, 400);
}
return res.json();
})
.then( .then(
( (
res: ConnectedAccountCommonOAuthTokenResponse & res: ConnectedAccountCommonOAuthTokenResponse &
@ -95,7 +102,7 @@ export default class BattleNetConnection extends Connection {
console.error( console.error(
`Error exchanging token for ${this.id} connection: ${e}`, `Error exchanging token for ${this.id} connection: ${e}`,
); );
throw DiscordApiErrors.INVALID_OAUTH_TOKEN; throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }
@ -107,10 +114,22 @@ export default class BattleNetConnection extends Connection {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to fetch user", 0, 400);
}
return res.json();
})
.then((res: BattleNetConnectionUser & BattleNetErrorResponse) => { .then((res: BattleNetConnectionUser & BattleNetErrorResponse) => {
if (res.error) throw new Error(res.error_description); if (res.error) throw new Error(res.error_description);
return res; return res;
})
.catch((e) => {
console.error(
`Error fetching user for ${this.id} connection: ${e}`,
);
throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }

View File

@ -1,4 +1,5 @@
import { import {
ApiError,
Config, Config,
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
@ -81,12 +82,18 @@ export default class DiscordConnection extends Connection {
}/connections/${this.id}/callback`, }/connections/${this.id}/callback`,
}), }),
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to exchange token", 0, 400);
}
return res.json();
})
.catch((e) => { .catch((e) => {
console.error( console.error(
`Error exchanging token for ${this.id} connection: ${e}`, `Error exchanging token for ${this.id} connection: ${e}`,
); );
throw DiscordApiErrors.INVALID_OAUTH_TOKEN; throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }
@ -97,7 +104,20 @@ export default class DiscordConnection extends Connection {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}).then((res) => res.json()); })
.then((res) => {
if (!res.ok) {
throw new ApiError("Failed to fetch user", 0, 400);
}
return res.json();
})
.catch((e) => {
console.error(
`Error fetching user for ${this.id} connection: ${e}`,
);
throw DiscordApiErrors.GENERAL_ERROR;
});
} }
async handleCallback( async handleCallback(

View File

@ -1,4 +1,5 @@
import { import {
ApiError,
Config, Config,
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
@ -86,12 +87,18 @@ export default class EpicGamesConnection extends Connection {
code, code,
}), }),
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to exchange code", 0, 400);
}
return res.json();
})
.catch((e) => { .catch((e) => {
console.error( console.error(
`Error exchanging token for ${this.id} connection: ${e}`, `Error exchanging token for ${this.id} connection: ${e}`,
); );
throw DiscordApiErrors.INVALID_OAUTH_TOKEN; throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }
@ -106,7 +113,20 @@ export default class EpicGamesConnection extends Connection {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}).then((res) => res.json()); })
.then((res) => {
if (!res.ok) {
throw new ApiError("Failed to fetch user", 0, 400);
}
return res.json();
})
.catch((e) => {
console.error(
`Error fetching user for ${this.id} connection: ${e}`,
);
throw DiscordApiErrors.GENERAL_ERROR;
});
} }
async handleCallback( async handleCallback(

View File

@ -1,4 +1,5 @@
import { import {
ApiError,
Config, Config,
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
@ -88,7 +89,13 @@ export default class FacebookConnection extends Connection {
Accept: "application/json", Accept: "application/json",
}, },
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to exchange code", 0, 400);
}
return res.json();
})
.then( .then(
( (
res: ConnectedAccountCommonOAuthTokenResponse & res: ConnectedAccountCommonOAuthTokenResponse &
@ -102,7 +109,7 @@ export default class FacebookConnection extends Connection {
console.error( console.error(
`Error exchanging token for ${this.id} connection: ${e}`, `Error exchanging token for ${this.id} connection: ${e}`,
); );
throw DiscordApiErrors.INVALID_OAUTH_TOKEN; throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }
@ -114,10 +121,22 @@ export default class FacebookConnection extends Connection {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to fetch user", 0, 400);
}
return res.json();
})
.then((res: UserResponse & FacebookErrorResponse) => { .then((res: UserResponse & FacebookErrorResponse) => {
if (res.error) throw new Error(res.error.message); if (res.error) throw new Error(res.error.message);
return res; return res;
})
.catch((e) => {
console.error(
`Error fetching user for ${this.id} connection: ${e}`,
);
throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }

View File

@ -1,4 +1,5 @@
import { import {
ApiError,
Config, Config,
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
@ -70,12 +71,18 @@ export default class GitHubConnection extends Connection {
Accept: "application/json", Accept: "application/json",
}, },
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to exchange code", 0, 400);
}
return res.json();
})
.catch((e) => { .catch((e) => {
console.error( console.error(
`Error exchanging token for ${this.id} connection: ${e}`, `Error exchanging code for ${this.id} connection: ${e}`,
); );
throw DiscordApiErrors.INVALID_OAUTH_TOKEN; throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }
@ -86,7 +93,20 @@ export default class GitHubConnection extends Connection {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}).then((res) => res.json()); })
.then((res) => {
if (!res.ok) {
throw new ApiError("Failed to fetch user", 0, 400);
}
return res.json();
})
.catch((e) => {
console.error(
`Error fetching user for ${this.id} connection: ${e}`,
);
throw DiscordApiErrors.GENERAL_ERROR;
});
} }
async handleCallback( async handleCallback(

View File

@ -1,4 +1,5 @@
import { import {
ApiError,
Config, Config,
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
@ -90,12 +91,18 @@ export default class RedditConnection extends Connection {
}/connections/${this.id}/callback`, }/connections/${this.id}/callback`,
}), }),
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to code", 0, 400);
}
return res.json();
})
.catch((e) => { .catch((e) => {
console.error( console.error(
`Error exchanging token for ${this.id} connection: ${e}`, `Error exchanging code for ${this.id} connection: ${e}`,
); );
throw DiscordApiErrors.INVALID_OAUTH_TOKEN; throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }
@ -106,7 +113,20 @@ export default class RedditConnection extends Connection {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}).then((res) => res.json()); })
.then((res) => {
if (!res.ok) {
throw new ApiError("Failed to fetch user", 0, 400);
}
return res.json();
})
.catch((e) => {
console.error(
`Error fetching user for ${this.id} connection: ${e}`,
);
throw DiscordApiErrors.GENERAL_ERROR;
});
} }
async handleCallback( async handleCallback(

View File

@ -1,4 +1,5 @@
import { import {
ApiError,
Config, Config,
ConnectedAccount, ConnectedAccount,
ConnectedAccountCommonOAuthTokenResponse, ConnectedAccountCommonOAuthTokenResponse,
@ -99,21 +100,28 @@ export default class SpotifyConnection extends RefreshableConnection {
}/connections/${this.id}/callback`, }/connections/${this.id}/callback`,
}), }),
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to refresh token", 0, 400);
}
return res.json();
})
.then( .then(
( (
res: ConnectedAccountCommonOAuthTokenResponse & res: ConnectedAccountCommonOAuthTokenResponse &
TokenErrorResponse, TokenErrorResponse,
) => { ) => {
if (res.error) throw new Error(res.error_description); if (res.error)
throw new ApiError(res.error_description, 0, 400);
return res; return res;
}, },
) )
.catch((e) => { .catch((e) => {
console.error( console.error(
`Error exchanging token for ${this.id} connection: ${e}`, `Error exchanging code for ${this.id} connection: ${e}`,
); );
throw DiscordApiErrors.INVALID_OAUTH_TOKEN; throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }
@ -137,13 +145,26 @@ export default class SpotifyConnection extends RefreshableConnection {
refresh_token, refresh_token,
}), }),
}) })
.then((res) => res.json()) .then(async (res) => {
if ([400, 401].includes(res.status)) {
// assume the token was revoked
await connectedAccount.revoke();
return DiscordApiErrors.CONNECTION_REVOKED;
}
// otherwise throw a general error
if (!res.ok) {
throw new ApiError("Failed to refresh token", 0, 400);
}
return await res.json();
})
.then( .then(
( (
res: ConnectedAccountCommonOAuthTokenResponse & res: ConnectedAccountCommonOAuthTokenResponse &
TokenErrorResponse, TokenErrorResponse,
) => { ) => {
if (res.error) throw new Error(res.error_description); if (res.error)
throw new ApiError(res.error_description, 0, 400);
return res; return res;
}, },
) )
@ -151,7 +172,7 @@ export default class SpotifyConnection extends RefreshableConnection {
console.error( console.error(
`Error refreshing token for ${this.id} connection: ${e}`, `Error refreshing token for ${this.id} connection: ${e}`,
); );
throw DiscordApiErrors.INVALID_OAUTH_TOKEN; throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }
@ -163,10 +184,22 @@ export default class SpotifyConnection extends RefreshableConnection {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}) })
.then((res) => res.json()) .then((res) => {
if (!res.ok) {
throw new ApiError("Failed to fetch user", 0, 400);
}
return res.json();
})
.then((res: UserResponse & ErrorResponse) => { .then((res: UserResponse & ErrorResponse) => {
if (res.error) throw new Error(res.error.message); if (res.error) throw new Error(res.error.message);
return res; return res;
})
.catch((e) => {
console.error(
`Error fetching user for ${this.id} connection: ${e}`,
);
throw DiscordApiErrors.GENERAL_ERROR;
}); });
} }
@ -182,7 +215,7 @@ export default class SpotifyConnection extends RefreshableConnection {
if (exists) return null; if (exists) return null;
return await this.createConnection({ return await this.createConnection({
token_data: tokenData, token_data: { ...tokenData, fetched_at: Date.now() },
user_id: userId, user_id: userId,
external_id: userInfo.id, external_id: userInfo.id,
friend_sync: params.friend_sync, friend_sync: params.friend_sync,

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

View File

@ -23,7 +23,7 @@ export default abstract class RefreshableConnection extends Connection {
connectedAccount: ConnectedAccount, connectedAccount: ConnectedAccount,
): Promise<ConnectedAccountCommonOAuthTokenResponse> { ): Promise<ConnectedAccountCommonOAuthTokenResponse> {
const tokenData = await this.refreshToken(connectedAccount); const tokenData = await this.refreshToken(connectedAccount);
connectedAccount.token_data = tokenData; connectedAccount.token_data = { ...tokenData, fetched_at: Date.now() };
await connectedAccount.save(); await connectedAccount.save();
return tokenData; return tokenData;
} }

View File

@ -7,7 +7,7 @@ export class ConnectedAccountDTO {
friend_sync?: boolean; friend_sync?: boolean;
name: string; name: string;
revoked?: boolean; revoked?: boolean;
show_activity?: boolean; show_activity?: number;
type: string; type: string;
verified?: boolean; verified?: boolean;
visibility?: number; visibility?: number;

View File

@ -51,7 +51,7 @@ export class ConnectedAccount extends BaseClass {
revoked?: boolean = false; revoked?: boolean = false;
@Column({ select: false }) @Column({ select: false })
show_activity?: boolean = true; show_activity?: number = 0;
@Column() @Column()
type: string; type: string;
@ -75,5 +75,11 @@ export class ConnectedAccount extends BaseClass {
two_way_link?: boolean = false; two_way_link?: boolean = false;
@Column({ select: false, nullable: true, type: "simple-json" }) @Column({ select: false, nullable: true, type: "simple-json" })
token_data?: ConnectedAccountTokenData; token_data?: ConnectedAccountTokenData | null;
async revoke() {
this.revoked = true;
this.token_data = null;
await this.save();
}
} }

View File

@ -13,4 +13,5 @@ export interface ConnectedAccountTokenData {
refresh_token?: string; refresh_token?: string;
expires_in?: number; expires_in?: number;
expires_at?: number; expires_at?: number;
fetched_at: number;
} }

View File

@ -7,7 +7,7 @@ export interface ConnectedAccountSchema {
friend_sync?: boolean; friend_sync?: boolean;
name: string; name: string;
revoked?: boolean; revoked?: boolean;
show_activity?: boolean; show_activity?: number;
type: string; type: string;
verified?: boolean; verified?: boolean;
visibility?: number; visibility?: number;

View File

@ -1,3 +1,4 @@
export interface ConnectionUpdateSchema { export interface ConnectionUpdateSchema {
visibility?: boolean; visibility?: boolean;
show_activity?: boolean;
} }

View File

@ -787,6 +787,11 @@ export const DiscordApiErrors = {
40006, 40006,
), ),
USER_BANNED: new ApiError("The user is banned from this guild", 40007), USER_BANNED: new ApiError("The user is banned from this guild", 40007),
CONNECTION_REVOKED: new ApiError(
"The connection has been revoked",
40012,
400,
),
TARGET_USER_IS_NOT_CONNECTED_TO_VOICE: new ApiError( TARGET_USER_IS_NOT_CONNECTED_TO_VOICE: new ApiError(
"Target user is not connected to voice", "Target user is not connected to voice",
40032, 40032,