diff --git a/package.json b/package.json index 5371649300..f105a9b165 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "\u0079\u006f\u0075\u0074\u0075\u0062\u0065\u002d\u006d\u0075\u0073\u0069\u0063", - "desktopName": "com.github.th_ch.\u0079\u006f\u0075\u0074\u0075\u0062\u0065\u005f\u006d\u0075\u0073\u0069\u0063", - "productName": "\u0059\u006f\u0075\u0054\u0075\u0062\u0065\u0020\u004d\u0075\u0073\u0069\u0063", + "name": "youtube-music", + "desktopName": "com.github.th_ch.youtube_music", + "productName": "YouTube Music", "version": "3.11.0", - "description": "\u0059\u006f\u0075\u0054\u0075\u0062\u0065\u0020\u004d\u0075\u0073\u0069\u0063 Desktop App - including custom plugins", + "description": "YouTube Music Desktop App - including custom plugins", "main": "./dist/main/index.js", "type": "module", "license": "MIT", diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index ab71a62262..7d4ec91801 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -492,6 +492,17 @@ } } }, + "slack-now-playing": { + "description": "Sets your Slack status to the currently playing song", + "menu": { + "settings": "Settings", + "token": "Slack API Token", + "cookie-token": "Slack Cookie Token", + "emoji-name": "Custom Emoji Name" + }, + "name": "Slack Now Playing", + "status-text": "Now Playing: {{artist}} - {{title}}" + }, "downloader": { "backend": { "dialog": { diff --git a/src/i18n/resources/es.json b/src/i18n/resources/es.json index fd1d5ce668..6825dc5829 100644 --- a/src/i18n/resources/es.json +++ b/src/i18n/resources/es.json @@ -458,6 +458,17 @@ }, "name": "Desactivar reproducción automática" }, + "slack-now-playing": { + "description": "Establece tu estado de Slack con la canción que estás reproduciendo", + "menu": { + "settings": "Configuración", + "token": "Token de API de Slack", + "cookie-token": "Token de Cookie de Slack", + "emoji-name": "Nombre del emoji personalizado" + }, + "name": "Estado de Slack", + "status-text": "Reproduciendo: {{artist}} - {{title}}" + }, "discord": { "backend": { "already-connected": "Se intentó conectar con una conexión activa", diff --git a/src/plugins/slack-now-playing/index.ts b/src/plugins/slack-now-playing/index.ts new file mode 100644 index 0000000000..c918ba8391 --- /dev/null +++ b/src/plugins/slack-now-playing/index.ts @@ -0,0 +1,18 @@ +import { createPlugin } from '@/utils'; +import { onMenu } from './menu'; +import { backend, type SlackNowPlayingConfig } from './main'; +import { t } from '@/i18n'; + +export default createPlugin({ + name: () => t('plugins.slack-now-playing.name'), + description: () => t('plugins.slack-now-playing.description'), + restartNeeded: true, + config: { + enabled: false, + token: '', + cookieToken: '', + emojiName: 'my-album-art', + } as SlackNowPlayingConfig, + menu: onMenu, + backend, +}); diff --git a/src/plugins/slack-now-playing/main.ts b/src/plugins/slack-now-playing/main.ts new file mode 100644 index 0000000000..20f59768c5 --- /dev/null +++ b/src/plugins/slack-now-playing/main.ts @@ -0,0 +1,928 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +import { net } from 'electron'; + +import { SlackApiClient, SlackError } from './slack-api-client'; +import { createBackend } from '@/utils'; +import { registerCallback, SongInfoEvent } from '@/providers/song-info'; +import { t } from '@/i18n'; + +import type { SongInfo } from '@/providers/song-info'; +import type { BackendContext } from '@/types/contexts'; + +// Plugin config type +export interface SlackNowPlayingConfig { + enabled: boolean; + token: string; + cookieToken: string; + emojiName: string; + alternativeTitles?: boolean; +} + +/** + * Type guard to check if an object is a valid SlackNowPlayingConfig + * @param config The object to check + * @returns True if the object is a valid SlackNowPlayingConfig + */ +function isSlackNowPlayingConfig( + config: unknown, +): config is SlackNowPlayingConfig { + if (!config || typeof config !== 'object') return false; + + const c = config as Partial; + return ( + typeof c.enabled === 'boolean' && + typeof c.token === 'string' && + typeof c.cookieToken === 'string' && + typeof c.emojiName === 'string' + ); +} + +const defaultEmojis = [ + ':cd:', + ':headphones:', + ':musical_note:', + ':notes:', + ':radio:', +]; + +// Cache to store album art file paths by URL to avoid repeated downloads +type AlbumArtCache = { + [url: string]: { + filePath: string; + timestamp: number; + }; +}; + +const state = { + lastStatus: '', + lastEmoji: '', + window: undefined as Electron.BrowserWindow | undefined, + tempFiles: new Set(), // Track temporary files for cleanup + albumArtCache: {} as AlbumArtCache, // Cache album art files + cacheExpiryMs: 30 * 60 * 1000, // Cache expiry time (30 minutes) + cacheCleanupTimer: undefined as NodeJS.Timeout | undefined, // Timer for periodic cache cleanup + context: undefined as BackendContext | undefined, // Store the plugin context + currentConfig: undefined as SlackNowPlayingConfig | undefined, // Current configuration +}; + +/** + * Register a temporary file for cleanup when the plugin is stopped + * @param filePath Path to the temporary file + */ +function registerFileForCleanup(filePath: string): void { + state.tempFiles.add(filePath); +} + +/** + * Clean up all temporary files created by the plugin + */ +async function cleanupTempFiles(): Promise { + const fsPromises = fs.promises; + + for (const filePath of state.tempFiles) { + try { + // Check if the file exists before attempting to delete it + await fsPromises + .access(filePath, fs.constants.F_OK) + .then(() => fsPromises.unlink(filePath)) + .then(() => { + // Remove the file from the set once it's deleted + state.tempFiles.delete(filePath); + }) + .catch((error: NodeJS.ErrnoException) => { + // Ignore errors if the file doesn't exist + if (error.code !== 'ENOENT') { + console.error(`Error deleting temporary file ${filePath}:`, error); + } + }); + } catch (error: unknown) { + // Catch any unexpected errors + if (error instanceof Error) { + console.error(`Error during cleanup of ${filePath}:`, error.message); + } else { + console.error(`Error during cleanup of ${filePath}:`, String(error)); + } + } + } +} + +/** + * Clean up expired cache entries to prevent the cache from growing too large + */ +async function cleanupExpiredCache(): Promise { + const now = Date.now(); + const fsPromises = fs.promises; + + // Check each cache entry + for (const [url, cacheEntry] of Object.entries(state.albumArtCache)) { + // If the entry is expired + if (now - cacheEntry.timestamp > state.cacheExpiryMs) { + // Remove from cache + delete state.albumArtCache[url]; + + // Try to delete the file if it's not needed elsewhere + try { + await fsPromises.access(cacheEntry.filePath, fs.constants.F_OK); + await fsPromises.unlink(cacheEntry.filePath); + state.tempFiles.delete(cacheEntry.filePath); + } catch { + // Ignore errors if the file doesn't exist or is in use + } + } + } +} + +/** + * Result of configuration validation + */ +type ValidationResult = { + valid: boolean; + errors: string[]; +}; + +/** + * Validates the Slack Now Playing configuration + * @param config The configuration to validate + * @returns A validation result object + */ +function validateConfig(config: SlackNowPlayingConfig): ValidationResult { + const errors: string[] = []; + + // Check token + if (!config.token) { + errors.push('Missing Slack API token'); + } else if (!config.token.startsWith('xoxc-')) { + errors.push('Invalid Slack API token format (should start with "xoxc-")'); + } + + // Check cookie token + if (!config.cookieToken) { + errors.push('Missing Slack cookie token'); + } else if (!config.cookieToken.startsWith('xoxd-')) { + errors.push( + 'Invalid Slack cookie token format (should start with "xoxd-")', + ); + } + + // Check emoji name + if (!config.emojiName) { + errors.push('Missing custom emoji name'); + } else if (!/^[a-z0-9_-]+$/.test(config.emojiName)) { + errors.push( + 'Invalid emoji name format (should only contain lowercase letters, numbers, hyphens, and underscores)', + ); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Validates the configuration and throws an error if invalid + * @param config The configuration to validate + * @throws Error if the configuration is invalid + */ +function assertValidConfig( + config: SlackNowPlayingConfig, +): asserts config is SlackNowPlayingConfig { + const result = validateConfig(config); + if (!result.valid) { + throw new Error( + `Invalid Slack Now Playing configuration: ${result.errors.join(', ')}`, + ); + } +} + +/** + * Updates the Slack status with the currently playing song + * @param songInfo Information about the current song + * @param config Plugin configuration + */ +async function setNowPlaying( + songInfo: SongInfo, + config: SlackNowPlayingConfig, +) { + try { + // Validate configuration + const validationResult = validateConfig(config); + if (!validationResult.valid) { + console.error( + `Cannot set Slack status: ${validationResult.errors.join(', ')}`, + ); + return; + } + + // Skip if song is paused + if (songInfo.isPaused) { + return; + } + + const title = songInfo.alternativeTitle ?? songInfo.title; + const artistPart = songInfo.artist || 'Unknown Artist'; + const truncatedArtist = + artistPart.length > 50 ? artistPart.substring(0, 50) + '...' : artistPart; + + // Use localized version of the status text + let statusText = t('plugins.slack-now-playing.status-text') + .replace('{{artist}}', truncatedArtist) + .replace('{{title}}', title); + + // Ensure the status text doesn't exceed Slack's limit + if (statusText.length > 97) + statusText = statusText.substring(0, 97) + '...'; + + // Calculate expiration time (current time + remaining song duration) + const elapsed = songInfo.elapsedSeconds ?? 0; + const remaining = Math.max(0, Math.floor(songInfo.songDuration - elapsed)); + const expirationTime = Math.floor(Date.now() / 1000) + remaining; + + await updateSlackStatusWithEmoji( + statusText, + expirationTime, + songInfo, + config, + ); + } catch (error: unknown) { + // Provide more detailed error information based on error type + if (error instanceof Error) { + console.error(`Error setting Slack status: ${error.message}`, { + name: error.name, + stack: error.stack, + }); + } else { + console.error(`Error setting Slack status: ${String(error)}`); + } + + // Re-throw specific errors that should be handled by the caller + if ( + error instanceof Error && + (error.message.includes('token') || + error.message.includes('authentication')) + ) { + throw new Error( + 'Slack authentication failed. Please check your API token and cookie token.', + ); + } + } +} + +/** + * Updates the Slack status with emoji and text + * @param statusText The status text to set + * @param expirationTime When the status should expire + * @param songInfo Information about the current song + * @param config Plugin configuration + * @throws Error if the Slack API request fails + */ +async function updateSlackStatusWithEmoji( + statusText: string, + expirationTime: number, + songInfo: SongInfo, + config: SlackNowPlayingConfig, +): Promise { + try { + // Validate configuration + const validationResult = validateConfig(config); + if (!validationResult.valid) { + throw new Error( + `Cannot update Slack status: ${validationResult.errors.join(', ')}`, + ); + } + + const client = new SlackApiClient(config.token, config.cookieToken); + + // Get the appropriate emoji for the current song + const statusEmoji = await getStatusEmoji(songInfo, config); + + // Prepare the status update payload + const statusUpdatePayload = { + profile: JSON.stringify({ + status_text: statusText, + status_emoji: statusEmoji, + status_expiration: expirationTime, + }), + }; + + // Update the status + // The client now handles API errors internally + await client.post('users.profile.set', statusUpdatePayload); + + // Update state with the new status and emoji + state.lastStatus = statusText; + state.lastEmoji = statusEmoji; + } catch (error: unknown) { + // Handle SlackError specifically + if (error instanceof SlackError) { + console.error(`Slack API error updating status: ${error.message}`, { + endpoint: error.endpoint, + statusCode: error.statusCode, + responseError: error.responseData?.error, + }); + } + // Handle other errors + else if (error instanceof Error) { + console.error(`Error updating Slack status: ${error.message}`, { + name: error.name, + stack: error.stack, + }); + } else { + console.error(`Error updating Slack status: ${String(error)}`); + } + + // Re-throw the error to be handled by the caller + throw error; + } +} + +async function getStatusEmoji( + songInfo: SongInfo, + config: SlackNowPlayingConfig, +): Promise { + if (songInfo.imageSrc && (await uploadEmojiToSlack(songInfo, config))) { + return `:${config.emojiName}:`; + } + + const randomIndex = Math.floor(Math.random() * defaultEmojis.length); + return defaultEmojis[randomIndex]; +} + +/** + * Uploads album art to Slack as a custom emoji + * @param songInfo Information about the current song + * @param config Plugin configuration + * @returns True if the emoji was successfully uploaded, false otherwise + */ +async function uploadEmojiToSlack( + songInfo: SongInfo, + config: SlackNowPlayingConfig, +): Promise { + try { + // Validate configuration + const validationResult = validateConfig(config); + if (!validationResult.valid) { + console.error( + `Cannot upload emoji to Slack: ${validationResult.errors.join(', ')}`, + ); + return false; + } + + const client = new SlackApiClient(config.token, config.cookieToken); + + // Save album art to a temporary file + const filePath = await saveAlbumArtToFile(songInfo); + if (!filePath) { + console.warn('Failed to save album art to file'); + return false; + } + + // Make sure the emoji doesn't already exist + const emojiDeleted = await ensureEmojiDoesNotExist(config); + if (!emojiDeleted) { + console.warn('Failed to ensure emoji does not exist'); + return false; + } + + // Prepare the form data for the API request using native Node.js APIs + const formData = new FormData(); + formData.append('mode', 'data'); + formData.append('name', config.emojiName); + try { + // Read the file as a Buffer and append directly to FormData + const fileBuffer = await fs.promises.readFile(filePath); + const filename = path.basename(filePath) || 'emoji.png'; + // Convert Buffer to Uint8Array for File constructor compatibility + const imageFile = new File([new Uint8Array(fileBuffer)], filename); + formData.append('image', imageFile, filename); + } catch (fileError: unknown) { + console.error( + `Error preparing album art file: ${fileError instanceof Error ? fileError.message : String(fileError)}`, + ); + return false; + } + + try { + // The post method now returns a properly typed response + await client.post<{ ok: boolean }>('emoji.add', formData, true); + return true; + } catch (apiError: unknown) { + // Handle specific API error types + if (apiError instanceof SlackError && apiError.responseData) { + const errorCode = apiError.responseData.error; + + if (errorCode === 'invalid_name') { + console.error( + `Invalid emoji name: ${config.emojiName}. Emoji names can only contain lowercase letters, numbers, hyphens, and underscores.`, + ); + } else if (errorCode === 'too_large') { + console.error( + 'Album art image is too large for Slack emoji (max 128KB).', + ); + } else if (errorCode === 'name_taken') { + console.error( + `Emoji name '${config.emojiName}' is already taken. This should not happen as we check for existing emojis.`, + ); + } else { + console.error( + `Error uploading emoji: ${errorCode}`, + apiError.responseData, + ); + } + // Log the full Slack error response for diagnostics + console.error('Slack error full response:', apiError.responseData); + } else { + console.error( + `Error uploading emoji to Slack: ${apiError instanceof Error ? apiError.message : String(apiError)}`, + ); + } + return false; + } + } catch (error: unknown) { + // Handle any other unexpected errors + if (error instanceof Error) { + console.error( + `Unexpected error uploading emoji to Slack: ${error.message}`, + { + name: error.name, + stack: error.stack, + }, + ); + } else { + console.error( + `Unexpected error uploading emoji to Slack: ${String(error)}`, + ); + } + return false; + } +} +/** + * Downloads and saves album art to a temporary file + * @param songInfo Information about the current song + * @returns Path to the saved file, or null if the operation failed + */ +async function saveAlbumArtToFile(songInfo: SongInfo): Promise { + if (!songInfo.imageSrc) { + console.warn('No image source available for album art'); + return null; + } + + try { + const imageUrl = songInfo.imageSrc; + const now = Date.now(); + + // Check if we have a cached version of this image + const cachedImage = state.albumArtCache[imageUrl]; + if (cachedImage) { + // Check if the cached file exists and is not expired + const cacheAge = now - cachedImage.timestamp; + if (cacheAge < state.cacheExpiryMs) { + try { + // Verify the file still exists + await fs.promises.access(cachedImage.filePath, fs.constants.F_OK); + return cachedImage.filePath; + } catch { + // File doesn't exist anymore, remove from cache + delete state.albumArtCache[imageUrl]; + } + } else { + // Cache entry expired, remove it + delete state.albumArtCache[imageUrl]; + // Try to clean up the old file + try { + await fs.promises.unlink(cachedImage.filePath); + state.tempFiles.delete(cachedImage.filePath); + } catch { + // Ignore errors if the file doesn't exist + } + } + } + + // Create a unique filename to prevent conflicts + const tempDir = os.tmpdir(); + const timestamp = now; + const randomString = Math.random().toString(36).substring(2, 10); + const filename = `album-art-${timestamp}-${randomString}.jpg`; + const filePath = path.join(tempDir, filename); + + // Fetch the image + let response: Response; + try { + response = await net.fetch(imageUrl); + + if (!response.ok) { + console.error( + `Failed to fetch album art: HTTP ${response.status} ${response.statusText}`, + ); + return null; + } + } catch (fetchError) { + console.error( + `Network error fetching album art: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`, + ); + return null; + } + + // Convert the response to a buffer + let imageBuffer: Buffer; + try { + imageBuffer = Buffer.from(await response.arrayBuffer()); + + if (imageBuffer.length === 0) { + console.error('Received empty album art image'); + return null; + } + } catch (bufferError) { + console.error( + `Error processing album art data: ${bufferError instanceof Error ? bufferError.message : String(bufferError)}`, + ); + return null; + } + + // Write the buffer to a file using async file operations + try { + // Import the promises API from fs + const fsPromises = fs.promises; + await fsPromises.writeFile(filePath, imageBuffer); + + // Register the file for cleanup when the app exits + registerFileForCleanup(filePath); + + // Add to cache + state.albumArtCache[imageUrl] = { + filePath, + timestamp: now, + }; + + return filePath; + } catch (fileError) { + console.error( + `Error writing album art to file: ${fileError instanceof Error ? fileError.message : String(fileError)}`, + ); + return null; + } + } catch (error: unknown) { + // Catch any other unexpected errors + if (error instanceof Error) { + console.error(`Error saving album art to file: ${error.message}`, { + name: error.name, + stack: error.stack, + }); + } else { + console.error(`Error saving album art to file: ${String(error)}`); + } + return null; + } +} + +/** + * Checks if the emoji already exists and deletes it if necessary + * @param config Plugin configuration + * @returns True if the emoji doesn't exist or was successfully deleted, false otherwise + */ +async function ensureEmojiDoesNotExist( + config: SlackNowPlayingConfig, +): Promise { + try { + // Validate configuration + const validationResult = validateConfig(config); + if (!validationResult.valid) { + console.error( + `Cannot check emoji existence: ${validationResult.errors.join(', ')}`, + ); + return false; + } + + const client = new SlackApiClient(config.token, config.cookieToken); + + try { + interface EmojiListResponse { + emoji: Record; + [key: string]: unknown; + } + const response = await client.get('emoji.list'); + if (!response || !response.emoji) { + return false; + } + if ( + response.emoji && + typeof response.emoji === 'object' && + config.emojiName in response.emoji + ) { + // Emoji already exists, attempting to delete it + return await deleteExistingEmoji(config); + } else { + // Emoji doesn't exist, no need to delete + return true; + } + } catch (apiError: unknown) { + // Handle specific API error types + if (apiError instanceof SlackError) { + const errorCode = apiError.responseData?.error; + + if (errorCode === 'invalid_auth' || errorCode === 'token_expired') { + console.error( + 'Slack authentication failed. Please check your API token and cookie token.', + ); + } else if (errorCode === 'rate_limited') { + console.error( + 'Slack API rate limit exceeded. Please try again later.', + ); + } else { + console.error( + `Error checking emoji list: ${errorCode || apiError.message}`, + ); + } + } else { + console.error( + `[Slack] Error checking emoji list: ${apiError instanceof Error ? apiError.message : String(apiError)}`, + ); + if (apiError instanceof SlackError && apiError.responseData) { + console.error('[Slack] Slack error response:', apiError.responseData); + } + } + return false; + } + } catch (error: unknown) { + // Handle any other unexpected errors + if (error instanceof Error) { + console.error( + `[Slack] Unexpected error in ensureEmojiDoesNotExist: ${error.message}`, + { + name: error.name, + stack: error.stack, + }, + ); + } else { + console.error( + `[Slack] Unexpected error in ensureEmojiDoesNotExist: ${String(error)}`, + ); + } + return false; + } +} + +/** + * Deletes an existing emoji from Slack + * @param config Plugin configuration + * @returns True if the emoji was successfully deleted or doesn't exist, false otherwise + */ +async function deleteExistingEmoji( + config: SlackNowPlayingConfig, +): Promise { + try { + // Validate configuration + const validationResult = validateConfig(config); + if (!validationResult.valid) { + console.error( + `Cannot delete emoji: ${validationResult.errors.join(', ')}`, + ); + return false; + } + + const client = new SlackApiClient(config.token, config.cookieToken); + + try { + // Delete the emoji - no need to include the token in the data anymore + const data = { name: config.emojiName }; + await client.post('emoji.remove', data); + + // If we got here, the request was successful + // Emoji deleted successfully + return true; + } catch (apiError: unknown) { + // Handle specific API error types + if (apiError instanceof SlackError && apiError.responseData) { + const errorCode = apiError.responseData.error; + + // Consider 'emoji_not_found' as a successful outcome + if (errorCode === 'emoji_not_found') { + // Emoji not found, no need to delete + console.error( + `Unexpected error deleting emoji: ${apiError.message}`, + { + name: apiError.name, + stack: apiError.stack, + }, + ); + return true; + } + } + console.error(`Unexpected error deleting emoji: ${String(apiError)}`); + return false; + } + } catch (error: unknown) { + // Handle any other unexpected errors + if (error instanceof Error) { + console.error( + `[Slack] Unexpected error in deleteExistingEmoji: ${error.message}`, + { + name: error.name, + stack: error.stack, + }, + ); + } else { + console.error( + `[Slack] Unexpected error in deleteExistingEmoji: ${String(error)}`, + ); + } + return false; + } +} + +/** + * Register exit handlers to clean up resources when the application exits + */ +function registerExitHandlers(): void { + // Handle process exit events + process.on('exit', () => { + // Use synchronous operations for the exit event + for (const filePath of state.tempFiles) { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch { + // Can't log during exit event, but we tried our best to clean up + } + } + }); + + // Handle other termination signals + ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => { + process.on(signal, async () => { + await cleanupTempFiles(); + process.exit(0); + }); + }); +} + +export const backend = createBackend({ + /** + * Start the Slack Now Playing plugin + * @param ctx The plugin context + */ + async start(ctx) { + // Store the context and window for later use + state.context = ctx as BackendContext; + state.window = ctx.window; + + // Register exit handlers for cleanup + registerExitHandlers(); + + // Set up periodic cache cleanup (every hour) + const cacheCleanupInterval = 60 * 60 * 1000; // 1 hour in milliseconds + const cacheCleanupTimer = setInterval(async () => { + try { + await cleanupExpiredCache(); + } catch { + // Ignore errors in the background task + } + }, cacheCleanupInterval); + + // Store the timer so we can clear it when the plugin stops + state.cacheCleanupTimer = cacheCleanupTimer; + + // Get the initial config and store it + const initialConfig = await ctx.getConfig(); + state.currentConfig = initialConfig as SlackNowPlayingConfig; + + // Register callback to listen for song changes + + registerCallback(async (songInfo: SongInfo, event: SongInfoEvent) => { + // Skip time change events + if (event === SongInfoEvent.TimeChanged) return; + + try { + // Get the latest config each time + const latestConfig = await ctx.getConfig(); + const config = latestConfig as SlackNowPlayingConfig; + state.currentConfig = config; // Update stored config + + // Only update if plugin is enabled + if (!config.enabled) { + return; + } + + // Update Slack status with current song + // Check if config has the expected structure using our type guard + if (!isSlackNowPlayingConfig(config)) { + // Log a warning only on the first occurrence to avoid spamming the console + if (!state.lastStatus) { + console.warn('Invalid Slack Now Playing configuration structure'); + } + return; + } + + // Validate the configuration + const validationResult = validateConfig(config); + if (!validationResult.valid) { + // Log a warning only on the first occurrence to avoid spamming the console + if (!state.lastStatus) { + console.warn( + `Slack Now Playing configuration validation failed: ${validationResult.errors.join(', ')}`, + ); + } + return; + } + + // Process the song info with the latest config + await setNowPlaying(songInfo, config).catch((error) => { + // Handle specific error types + if (error instanceof Error) { + // Check for authentication errors + if ( + error.message.includes('authentication') || + error.message.includes('token') + ) { + console.error( + 'Slack authentication failed. Please check your API token and cookie token.', + ); + } + // Check for rate limiting errors + else if ( + error.message.includes('rate limit') || + error.message.includes('rate_limited') + ) { + console.error( + 'Slack API rate limit exceeded. Please try again later.', + ); + } + // Generic error handling + else { + console.error(`Error in Slack Now Playing: ${error.message}`); + } + } else { + console.error(`Error in Slack Now Playing: ${String(error)}`); + } + }); + } catch (error: unknown) { + // Handle unexpected errors in the callback itself + if (error instanceof Error) { + console.error(`Error processing song info: ${error.message}`, { + name: error.name, + stack: error.stack, + }); + } else { + console.error(`Error processing song info: ${String(error)}`); + } + } + }); + }, + + /** + * Stop the Slack Now Playing plugin and clean up resources + */ + async stop() { + // Clear the cache cleanup timer + if (state.cacheCleanupTimer) { + clearInterval(state.cacheCleanupTimer); + state.cacheCleanupTimer = undefined; + } + + // Clean up any temporary files created by the plugin + await cleanupTempFiles(); + + // Run a final cache cleanup + await cleanupExpiredCache(); + + // Clear the window reference + state.window = undefined; + + // Note: We don't unregister the callback as there's no API for that + // It will be garbage collected when the plugin is unloaded + }, + + /** + * Handle configuration changes + * This is called when the user updates the plugin configuration + */ + async onConfigChange() { + if (state.context) { + try { + // Get the latest configuration + const latestConfig = await state.context.getConfig(); + const config = latestConfig; + + // Update the stored configuration + state.currentConfig = config; + + // Validate the configuration + try { + // Use assertValidConfig to validate the configuration + assertValidConfig(config); + // Configuration updated successfully + } catch (error: unknown) { + console.warn( + `Slack Now Playing configuration validation failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } catch (error: unknown) { + console.error('Error updating Slack Now Playing configuration:', error); + } + } + }, +}); diff --git a/src/plugins/slack-now-playing/menu.ts b/src/plugins/slack-now-playing/menu.ts new file mode 100644 index 0000000000..8ee5135ca5 --- /dev/null +++ b/src/plugins/slack-now-playing/menu.ts @@ -0,0 +1,180 @@ +import prompt from 'custom-electron-prompt'; +import { type BrowserWindow, dialog } from 'electron'; + +import promptOptions from '@/providers/prompt-options'; +import { t } from '@/i18n'; + +import type { SlackNowPlayingConfig } from './main'; +import type { MenuContext } from '@/types/contexts'; +import type { MenuTemplate } from '@/menu'; + +/** + * Result of configuration validation + */ +type ValidationResult = { + valid: boolean; + errors: string[]; +}; + +/** + * Validates the Slack Now Playing configuration + * @param config The configuration to validate + * @returns A validation result object + */ +function validateConfig(config: SlackNowPlayingConfig): ValidationResult { + const errors: string[] = []; + + // Check token + if (!config.token) { + errors.push('Missing Slack API token'); + } else if (!config.token.startsWith('xoxc-')) { + errors.push('Invalid Slack API token format (should start with "xoxc-")'); + } + + // Check cookie token + if (!config.cookieToken) { + errors.push('Missing Slack cookie token'); + } else if (!config.cookieToken.startsWith('xoxd-')) { + errors.push( + 'Invalid Slack cookie token format (should start with "xoxd-")', + ); + } + + // Check emoji name + if (!config.emojiName) { + errors.push('Missing custom emoji name'); + } else if (!/^[a-z0-9_-]+$/.test(config.emojiName)) { + errors.push( + 'Invalid emoji name format (should only contain lowercase letters, numbers, hyphens, and underscores)', + ); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Prompts user for Slack Now Playing plugin settings + * @param options Current plugin configuration + * @param setConfig Function to save the updated configuration + * @param window Browser window instance + * @returns Promise that resolves when the configuration is saved + */ +async function promptSlackNowPlayingOptions( + options: SlackNowPlayingConfig, + setConfig: (config: SlackNowPlayingConfig) => void, + window: BrowserWindow, +): Promise { + const output = await prompt( + { + title: t('plugins.slack-now-playing.name'), + label: `
+

${t('plugins.slack-now-playing.name')}

+

HOW TO GET YOUR SLACK TOKENS:

+

Open Slack in browser & press F12
+ For ${t('plugins.slack-now-playing.menu.token')}: Network tab > Make a request > Find "token" parameter
+ For ${t('plugins.slack-now-playing.menu.cookie-token')}: Application tab > Cookies > "d" cookie value

+

${t('plugins.slack-now-playing.menu.emoji-name')}: Used to display album art in your status

+
`, + type: 'multiInput', + useHtmlLabel: true, + multiInputOptions: [ + { + label: t('plugins.slack-now-playing.menu.token'), + value: options.token, + inputAttrs: { + type: 'text', + placeholder: 'xoxc-...', + }, + }, + { + label: t('plugins.slack-now-playing.menu.cookie-token'), + value: options.cookieToken, + inputAttrs: { + type: 'text', + placeholder: 'xoxd-...', + }, + }, + { + label: t('plugins.slack-now-playing.menu.emoji-name'), + value: options.emojiName, + inputAttrs: { + type: 'text', + placeholder: 'my-album-art', + }, + }, + ], + resizable: true, + width: 620, + height: 520, + ...promptOptions(), + }, + window, + ); + + if (output) { + try { + // Create a deep copy of the options to ensure we don't modify the original + const updatedOptions = { ...options } as SlackNowPlayingConfig; + + // Update only the fields that were provided + if (output[0] !== undefined) { + updatedOptions.token = output[0]; + } + if (output[1] !== undefined) { + updatedOptions.cookieToken = output[1]; + } + if (output[2] !== undefined) { + updatedOptions.emojiName = output[2]; + } + + // Validate the updated options + const validationResult = validateConfig(updatedOptions); + + if (!validationResult.valid) { + // Show validation errors to the user + await dialog.showMessageBox(window, { + type: 'warning', + title: 'Slack Now Playing Configuration Issues', + message: 'There are issues with your Slack configuration:', + detail: validationResult.errors.join('\n'), + buttons: ['OK'], + }); + } + + // Save the config even if it has validation errors + // This allows users to save partial configurations + setConfig(updatedOptions); + + // Config has been saved successfully + } catch (error) { + console.error('Error saving Slack Now Playing configuration:', error); + await dialog.showMessageBox(window, { + type: 'error', + title: 'Configuration Error', + message: 'Failed to save Slack Now Playing configuration', + detail: error instanceof Error ? error.message : String(error), + buttons: ['OK'], + }); + } + } +} + +export const onMenu = ({ + window, + getConfig, + setConfig, +}: MenuContext): MenuTemplate => { + return [ + { + label: t('plugins.slack-now-playing.menu.settings'), + async click() { + // Get the latest config before showing the prompt + const latestConfig = await getConfig(); + await promptSlackNowPlayingOptions(latestConfig, setConfig, window); + }, + }, + ]; +}; diff --git a/src/plugins/slack-now-playing/slack-api-client.ts b/src/plugins/slack-now-playing/slack-api-client.ts new file mode 100644 index 0000000000..ff98a37371 --- /dev/null +++ b/src/plugins/slack-now-playing/slack-api-client.ts @@ -0,0 +1,549 @@ +import { session } from 'electron'; + +/** + * Standard response format from Slack API endpoints + * + * This interface represents the standard response structure returned by all Slack API endpoints. + * It includes common fields like success status, error information, and the actual response data. + * + * @template TData Type of the response data when the call is successful + * + * @example + * ```typescript + * // Successful response example + * const successResponse: SlackApiResponse = { + * ok: true, + * profile: { display_name: 'John Doe', email: 'john@example.com' } + * }; + * + * // Error response example + * const errorResponse: SlackApiResponse = { + * ok: false, + * error: 'invalid_auth', + * error_description: 'Invalid authentication token' + * }; + * ``` + */ +export interface SlackApiResponse { + /** Whether the API call was successful */ + ok: boolean; + + /** Error code if the call failed (only present when ok is false) */ + error?: string; + + /** Human-readable error description if available (only present when ok is false) */ + error_description?: string; + + /** Warning messages from the API that don't prevent successful execution */ + warning?: string; + + /** + * Typed response data (available when ok is true) + * This property is not actually in the Slack API response, but is used + * to provide type safety for the response data + */ + data?: TData; + + /** + * Additional response data properties that vary by endpoint + * The actual structure depends on the specific API endpoint called + */ + [key: string]: unknown; +} + +/** + * Parameters for Slack API requests + * + * This type represents the parameters that can be passed to Slack API endpoints. + * It enforces type safety for common parameter types while allowing for flexibility. + * + * @example + * ```typescript + * const params: SlackApiParams = { + * channel: 'C1234567890', + * count: 10, + * inclusive: true + * }; + * ``` + */ +export type SlackApiParams = { + [key: string]: + | string + | number + | boolean + | null + | undefined + | string[] + | number[]; +}; + +/** + * Error thrown by the Slack API client + */ +export class SlackError extends Error { + /** The original error that caused this error */ + readonly originalError: Error; + /** The endpoint that was called */ + readonly endpoint: string; + /** The HTTP status code if available */ + readonly statusCode?: number; + /** The response data if available */ + readonly responseData?: SlackApiResponse; + + /** + * Create a new Slack API error + * @param message Error message + * @param endpoint The API endpoint that was called + * @param originalError The original error that caused this error + * @param statusCode The HTTP status code if available + * @param responseData The response data if available + */ + constructor( + message: string, + endpoint: string, + originalError: Error, + statusCode?: number, + responseData?: SlackApiResponse, + ) { + super(message); + this.name = 'SlackError'; + this.originalError = originalError; + this.endpoint = endpoint; + this.statusCode = statusCode; + this.responseData = responseData; + } +} + +/** + * Cache entry for Slack API responses + */ +interface CacheEntry { + /** The cached response data */ + data: T; + /** When the cache entry was created (timestamp) */ + timestamp: number; + /** Cache expiration time in milliseconds */ + expiryMs: number; +} + +/** + * Rate limit tracking for Slack API endpoints + */ +interface RateLimitInfo { + /** Number of requests made to this endpoint */ + requestCount: number; + /** Timestamp when the rate limit window started */ + windowStart: number; + /** Duration of the rate limit window in milliseconds */ + windowMs: number; + /** Maximum number of requests allowed in the window */ + maxRequests: number; +} + +/** + * Centralized Slack API client for making requests to the Slack API + * + * This client handles authentication, error handling, request formatting, + * caching, and rate limiting for all Slack API calls in the application. + * + * Features: + * - Automatic request authentication + * - Response caching for GET requests + * - Rate limiting protection + * - Comprehensive error handling + * - Type-safe request and response handling + */ +export class SlackApiClient { + /** The Slack API token (xoxc-) */ + readonly token: string; + /** The Slack cookie token (xoxd-) */ + readonly cookie: string; + /** Base URL for all Slack API requests */ + private readonly baseUrl = 'https://slack.com/api'; + /** Cache for GET requests to reduce API calls */ + private readonly cache: Map> = new Map(); + /** Default cache expiration time (5 minutes) */ + private readonly defaultCacheExpiryMs = 5 * 60 * 1000; + /** Rate limit tracking for each endpoint */ + private readonly rateLimits: Map = new Map(); + /** Default rate limit (20 requests per minute for most endpoints) */ + private readonly defaultRateLimit = { maxRequests: 20, windowMs: 60 * 1000 }; + /** Dedicated session for Slack API requests */ + private slackSession: Electron.Session | null = null; + + /** + * Create a new Slack API client + * @param token The Slack API token (xoxc-) + * @param cookie The Slack cookie token (xoxd-) + */ + constructor(token: string, cookie: string) { + this.token = token; + this.cookie = cookie; + } + + /** + * Get or create a dedicated session for Slack API requests + * Sets the authentication cookie with proper URL encoding + */ + private async getSlackSession(): Promise { + if (this.slackSession) return this.slackSession; + + this.slackSession = session.fromPartition('persist:slack-api'); + + // Set the URL-encoded cookie in the session + await this.slackSession.cookies.set({ + url: 'https://slack.com', + name: 'd', + value: encodeURIComponent(this.cookie), + domain: '.slack.com', + path: '/', + secure: true, + httpOnly: true, + }); + + return this.slackSession; + } + + /** + * Clear the response cache and reset rate limits + * + * This can be useful in scenarios where you want to force fresh data + * or when testing the API client. + * + * @param endpoint Optional specific endpoint to clear. If not provided, clears all endpoints. + */ + clearCache(endpoint?: string): void { + if (endpoint) { + // Clear cache for a specific endpoint + for (const key of this.cache.keys()) { + if (key.startsWith(`${endpoint}:`)) { + this.cache.delete(key); + } + } + + // Reset rate limit for the endpoint + this.rateLimits.delete(endpoint); + } else { + // Clear all cache and rate limits + this.cache.clear(); + this.rateLimits.clear(); + } + } + + /** + * Get the base headers required for all Slack API requests + * @returns Headers object (cookie is handled via session) + */ + private getBaseHeaders(): Record { + // Cookie is set via session.cookies.set() - credentials: 'include' sends it + // Token is sent in request body, not Authorization header + return {}; + } + + /** + * Make a POST request to a Slack API endpoint with rate limiting protection + * + * This method includes several performance optimizations: + * 1. Rate limiting protection to prevent hitting Slack API limits + * 2. Automatic conversion between data formats + * 3. Comprehensive error handling with detailed error information + * + * @param endpoint The API endpoint to call (without the base URL) + * @param data The data to send in the request body + * @param formData Whether the data is form data (multipart/form-data) + * @returns The response from the API + * @throws {SlackError} If the request fails or would exceed rate limits + */ + async post( + endpoint: string, + data: Record | FormData, + formData = false, + ): Promise> { + // Check rate limits before making the request + if (!this.checkRateLimit(endpoint)) { + throw new SlackError( + `Rate limit exceeded for Slack API endpoint: ${endpoint}`, + endpoint, + new Error('Too many requests in a short period'), + 429, // HTTP 429 Too Many Requests + { ok: false, error: 'rate_limited' }, + ); + } + + const url = `${this.baseUrl}/${endpoint}`; + const headers = this.getBaseHeaders(); + let payload: BodyInit; + + if (formData && data instanceof FormData) { + // For FormData, add token to the form data + data.append('token', this.token); + payload = data; + } else { + // For regular POST requests, use URL-encoded format + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + + // Filter out undefined and null values and add token + const cleanData: Record = { + token: this.token, // Token goes in body, not Authorization header + }; + for (const [key, value] of Object.entries(data)) { + if (value !== undefined && value !== null) { + cleanData[key] = String(value); + } + } + + payload = new URLSearchParams(cleanData).toString(); + } + + try { + // Get the Slack session with cookie already set + const slackSession = await this.getSlackSession(); + + // Update rate limit tracking + this.updateRateLimit(endpoint); + + // Use session.fetch with credentials: 'include' to send session cookies + const res = await slackSession.fetch(url, { + method: 'POST', + headers, + body: payload, + credentials: 'include', + }); + const json = (await res.json()) as SlackApiResponse; + + if (!json.ok) { + if (json.error === 'rate_limited') { + const rateLimit = this.rateLimits.get(endpoint) || { + requestCount: 0, + windowStart: Date.now(), + ...this.defaultRateLimit, + }; + rateLimit.requestCount = rateLimit.maxRequests; + this.rateLimits.set(endpoint, rateLimit); + } + throw new SlackError( + `Slack API error: ${json.error || 'Unknown error'}`, + endpoint, + new Error(json.error_description || json.error || 'Unknown error'), + res.status, + json, + ); + } + return json; + } catch (error: unknown) { + // Re-throw SlackErrors directly to preserve statusCode and responseData + if (error instanceof SlackError) { + throw error; + } + throw new SlackError( + `Error in Slack API POST to ${endpoint}: ${error instanceof Error ? error.message : String(error)}`, + endpoint, + error instanceof Error ? error : new Error(String(error)), + ); + } + } + + /** + * Check if we're about to exceed rate limits for an endpoint + * @param endpoint The API endpoint to check + * @returns True if the request should be allowed, false if it would exceed rate limits + */ + private checkRateLimit(endpoint: string): boolean { + const now = Date.now(); + let rateLimit = this.rateLimits.get(endpoint); + + // If no rate limit info exists for this endpoint, create it + if (!rateLimit) { + rateLimit = { + requestCount: 0, + windowStart: now, + ...this.defaultRateLimit, + }; + this.rateLimits.set(endpoint, rateLimit); + } + + // Check if we need to reset the window + if (now - rateLimit.windowStart > rateLimit.windowMs) { + rateLimit.requestCount = 0; + rateLimit.windowStart = now; + } + + // Check if we would exceed the rate limit + return rateLimit.requestCount < rateLimit.maxRequests; + } + + /** + * Update rate limit tracking after a request + * @param endpoint The API endpoint that was called + */ + private updateRateLimit(endpoint: string): void { + const rateLimit = this.rateLimits.get(endpoint); + if (rateLimit) { + rateLimit.requestCount++; + } + } + + /** + * Generate a cache key for a request + * @param endpoint The API endpoint + * @param params The request parameters + * @returns A unique cache key + */ + private getCacheKey(endpoint: string, params: SlackApiParams): string { + return `${endpoint}:${JSON.stringify(params)}`; + } + + /** + * Check if a cached response is available and valid + * @param cacheKey The cache key to check + * @returns The cached response or undefined if not available + */ + private getCachedResponse(cacheKey: string): T | undefined { + const cached = this.cache.get(cacheKey); + if (!cached) return undefined; + + const now = Date.now(); + const age = now - cached.timestamp; + + // Return the cached data if it's still valid + if (age < cached.expiryMs) { + return cached.data as T; + } + + // Remove expired cache entry + this.cache.delete(cacheKey); + return undefined; + } + + /** + * Store a response in the cache + * @param cacheKey The cache key + * @param data The data to cache + * @param expiryMs Optional custom expiry time in milliseconds + */ + private cacheResponse(cacheKey: string, data: T, expiryMs?: number): void { + this.cache.set(cacheKey, { + data, + timestamp: Date.now(), + expiryMs: expiryMs ?? this.defaultCacheExpiryMs, + }); + } + + /** + * Make a GET request to a Slack API endpoint with caching and rate limiting + * + * This method includes several performance optimizations: + * 1. Response caching to reduce API calls for identical requests + * 2. Rate limiting protection to prevent hitting Slack API limits + * 3. Automatic retry for rate-limited requests + * + * @param endpoint The API endpoint to call (without the base URL) + * @param params The query parameters to include in the request + * @param options Optional request options + * @param options.skipCache Set to true to bypass the cache and force a fresh request + * @param options.cacheExpiryMs Custom cache expiration time in milliseconds + * @returns The response from the API + * @throws {SlackError} If the request fails or would exceed rate limits + */ + async get( + endpoint: string, + params: SlackApiParams = {}, + options: { skipCache?: boolean; cacheExpiryMs?: number } = {}, + ): Promise> { + const url = `${this.baseUrl}/${endpoint}`; + const headers = this.getBaseHeaders(); + + // Remove undefined and null values from params and handle array values + const cleanParams: Record< + string, + string | number | boolean | string[] | number[] + > = {}; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + // Handle array values properly + cleanParams[key] = value; + } + } + + // Check cache first (unless skipCache is true) + if (!options.skipCache) { + const cacheKey = this.getCacheKey(endpoint, cleanParams); + const cachedResponse = + this.getCachedResponse>(cacheKey); + + if (cachedResponse) { + // Return the cached response + return cachedResponse; + } + } + + // Check rate limits before making the request + if (!this.checkRateLimit(endpoint)) { + throw new SlackError( + `Rate limit exceeded for Slack API endpoint: ${endpoint}`, + endpoint, + new Error('Too many requests in a short period'), + 429, // HTTP 429 Too Many Requests + { ok: false, error: 'rate_limited' }, + ); + } + + try { + // Build query string with token + const searchParams = new URLSearchParams(); + searchParams.append('token', this.token); // Token goes in query params + for (const [key, value] of Object.entries(cleanParams)) { + if (Array.isArray(value)) { + for (const v of value) { + searchParams.append(key, String(v)); + } + } else { + searchParams.append(key, String(value)); + } + } + const fetchUrl = `${url}?${searchParams.toString()}`; + + // Get the Slack session with cookie already set + const slackSession = await this.getSlackSession(); + + // Update rate limit tracking + this.updateRateLimit(endpoint); + + // Use session.fetch with credentials: 'include' to send session cookies + const res = await slackSession.fetch(fetchUrl, { + method: 'GET', + headers, + credentials: 'include', + }); + const json = (await res.json()) as SlackApiResponse; + + if (!json.ok) { + throw new SlackError( + `Slack API error: ${json.error || 'Unknown error'}`, + endpoint, + new Error(json.error_description || json.error || 'Unknown error'), + res.status, + json, + ); + } + + // Cache successful responses + if (!options.skipCache) { + const cacheKey = this.getCacheKey(endpoint, cleanParams); + this.cacheResponse(cacheKey, json, options.cacheExpiryMs); + } + + return json; + } catch (error: unknown) { + // Re-throw SlackErrors directly to preserve statusCode and responseData + if (error instanceof SlackError) { + throw error; + } + throw new SlackError( + `Error in Slack API GET to ${endpoint}: ${error instanceof Error ? error.message : String(error)}`, + endpoint, + error instanceof Error ? error : new Error(String(error)), + ); + } + } +}