diff --git a/src/discordService.ts b/src/discordService.ts index b41a899..1a7b66a 100644 --- a/src/discordService.ts +++ b/src/discordService.ts @@ -1,19 +1,78 @@ import * as RPC from 'discord-rpc'; import axios from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; import { CONFIG } from './config'; import { VoiceSettingsPayload } from './types'; +interface TokenData { + accessToken: string; + refreshToken: string; + expiresAt: number; // Unix ms timestamp +} + class DiscordService { private client: RPC.Client; private loggedIn = false; private isLoginInProgress = false; private voiceUpdateCallbacks: Array<(d: VoiceSettingsPayload) => void> = []; + private static TOKEN_FILE = path.join(__dirname, '../', '.discord-token.json'); + constructor() { this.client = new RPC.Client({ transport: 'ipc' }); this.setupEvents(); } + private saveTokens(data: TokenData): void { + try { + fs.writeFileSync(DiscordService.TOKEN_FILE, JSON.stringify(data, null, 2)); + console.log('Tokens saved locally'); + } catch (err) { + console.error('Failed to save tokens:', err); + } + } + + private loadTokens(): TokenData | null { + try { + if (!fs.existsSync(DiscordService.TOKEN_FILE)) return null; + const txt = fs.readFileSync(DiscordService.TOKEN_FILE, 'utf-8'); + return JSON.parse(txt) as TokenData; + } catch (err) { + console.error('Error reading token file:', err); + return null; + } + } + + private isTokenValid(tokenData: TokenData): boolean { + return Date.now() < tokenData.expiresAt - 5000; // small buffer + } + + private async refreshAccessToken(refreshToken: string): Promise { + try { + const params = new URLSearchParams({ + client_id: CONFIG.CLIENT_ID, + client_secret: CONFIG.CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + + const response = await axios.post('https://discord.com/api/oauth2/token', params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); + + const newTokenData: TokenData = { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + expiresAt: Date.now() + response.data.expires_in * 1000, + }; + + this.saveTokens(newTokenData); + return newTokenData; + } catch (error) { + console.error('Token refresh failed:', error); + return null; + } + } + private setupEvents() { this.client.on('ready', () => { console.log(`Logged in as ${this.client.user?.username || 'Unknown User'}`); @@ -55,21 +114,65 @@ class DiscordService { } public async login() { + if (this.loggedIn || this.isLoginInProgress) return; + await this._login(); + // load tokens and setup refresh + const tokens = this.loadTokens(); + if (tokens) { + const { refreshToken, expiresAt } = tokens; + setTimeout(() => { + if (DiscordService.refreshed) return; + DiscordService.refreshed = true; + console.log('Starting token refresh flow...'); + this.refreshTokenFlow(refreshToken).catch(console.error); + }, expiresAt - Date.now() - 60000); // refresh 1 min before expiry + } + } + + private async _login() { if (this.loggedIn) return; try { - console.log('Waiting for authorization (Check Discord popup)...'); this.isLoginInProgress = true; - await this.client.login({ + // Try to load existing token + const savedTokens = this.loadTokens(); + + if (savedTokens) { + if (this.isTokenValid(savedTokens)) { + console.log('Using saved token...'); + await this.authAndLoginClient({ + clientId: CONFIG.CLIENT_ID, + accessToken: savedTokens.accessToken, + }); + this.loggedIn = true; + console.log('Authenticated with saved token!'); + return; + } else { + // Try to refresh + console.log('Token expired, refreshing...'); + const refreshed = await this.refreshAccessToken(savedTokens.refreshToken); + if (refreshed) { + await this.authAndLoginClient({ + clientId: CONFIG.CLIENT_ID, + accessToken: refreshed.accessToken, + }); + this.loggedIn = true; + console.log('Authenticated with refreshed token!'); + return; + } + } + } + + // Fall back to full OAuth flow + console.log('No valid token found. Starting authorization...'); + + const client = await this.authAndLoginClient({ clientId: CONFIG.CLIENT_ID, clientSecret: CONFIG.CLIENT_SECRET, - scopes: ['rpc', 'rpc.voice.read', 'rpc.voice.write'], - redirectUri: CONFIG.REDIRECT_URI, }); - this.loggedIn = true; console.log('Authenticated successfully with Discord!'); - } catch (error: any) { + } catch (error: unknown) { if (axios.isAxiosError(error)) { console.error('Token Exchange Failed:', error.response?.data || error.message); } else { @@ -87,6 +190,71 @@ class DiscordService { public getClient() { return this.client; } + + // dev: refresh only once + static refreshed = false; + private async authAndLoginClient({ + clientId, + clientSecret, + accessToken, + }: { + clientId: string; + } & ( + | { + clientSecret: string; + accessToken?: string; + } + | { + accessToken: string; + clientSecret?: string; + } + )): Promise { + const scopes = ['rpc', 'rpc.voice.read', 'rpc.voice.write']; + if (clientSecret) { + this.client = await this.client.login({ + clientId, + clientSecret, + scopes, + redirectUri: CONFIG.REDIRECT_URI, + }); + console.log('[Auth] RPC client logged in with client secret'); + // store the access token for later use + const accessToken = this.client.accessToken; + const refreshToken = this.client.refreshToken; + const expiresAt = this.client.accessTokenExpiresAt || 0; + if (accessToken && refreshToken && expiresAt) { + this.saveTokens({ accessToken, refreshToken, expiresAt }); + } else { + console.warn('Could not retrieve tokens after login with client secret.'); + } + return this.client; + } + if (accessToken) { + this.client = await this.client.login({ + clientId, + accessToken: accessToken, + scopes, + }); + console.log('[Auth] RPC client logged in'); + return this.client; + } else { + throw new Error('Either clientSecret or accessToken must be provided'); + } + } + + private async refreshTokenFlow(refreshToken: string): Promise { + console.log('Refreshing access token...'); + const newTokens = await this.refreshAccessToken(refreshToken); + console.log('Token refresh attempt finished.'); + if (newTokens) { + // directly update client tokens + this.client.accessToken = newTokens.accessToken; + this.client.refreshToken = newTokens.refreshToken; + this.client.accessTokenExpiresAt = newTokens.expiresAt; + } else { + throw new Error('Failed to refresh token'); + } + } } export const discordService = new DiscordService();