diff options
| author | alex <[email protected]> | 2026-07-21 21:03:51 +0200 |
|---|---|---|
| committer | alex <[email protected]> | 2026-07-21 21:03:51 +0200 |
| commit | ba2df9450b9409634e3e271a606b39a73392dc54 (patch) | |
| tree | 3f216f41f933b157ff2ab1b093b9fb3ba7008733 /bot.py | |
| download | spotify-discord-widget-ba2df9450b9409634e3e271a606b39a73392dc54.tar.xz spotify-discord-widget-ba2df9450b9409634e3e271a606b39a73392dc54.zip | |
init, todo: fix spotify profile picture fetching
Diffstat (limited to 'bot.py')
| -rw-r--r-- | bot.py | 436 |
1 files changed, 436 insertions, 0 deletions
@@ -0,0 +1,436 @@ +from collections import Counter +import os +import sqlite3 +import sys + +import aiohttp +import discord +from discord.ext import commands +from dotenv import load_dotenv +import spotipy +from spotipy.oauth2 import SpotifyOAuth +from urllib.parse import parse_qs, urlparse + +load_dotenv() + +REQUIRED_ENV = [ + "SPOTIFY_CLIENT_ID", + "SPOTIFY_CLIENT_SECRET", + "SPOTIFY_REDIRECT_URI", + "DISCORD_BOT_TOKEN", + "DISCORD_APP_ID", +] + +for var in REQUIRED_ENV: + if not os.getenv(var): + print(f"❌ Error: Missing required environment variable '{var}' in .env file.") + sys.exit(1) + +SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") +SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") +SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI", "http://localhost:8888/callback") +DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") +DISCORD_APP_ID = os.getenv("DISCORD_APP_ID") +DISCORD_IDENTITY_ID = os.getenv("DISCORD_IDENTITY_ID", "0") + +SCOPE = "user-read-private user-top-read user-read-currently-playing user-read-playback-state user-read-recently-played" +# ============================================================================== +# 2. DATABASE SETUP +# ============================================================================== +conn = sqlite3.connect("tokens.db", check_same_thread=False) +cursor = conn.cursor() +cursor.execute( + """ + CREATE TABLE IF NOT EXISTS user_tokens ( + discord_id TEXT PRIMARY KEY, + refresh_token TEXT NOT NULL + ) +""" +) +conn.commit() + +# ============================================================================== +# 3. SPOTIFY API & AUTHENTICATION HELPERS +# ============================================================================== +def get_oauth() -> SpotifyOAuth: + return SpotifyOAuth( + client_id=SPOTIFY_CLIENT_ID, + client_secret=SPOTIFY_CLIENT_SECRET, + redirect_uri=SPOTIFY_REDIRECT_URI, + scope=SCOPE, + ) + + +def get_spotify_client(discord_id: str) -> spotipy.Spotify | None: + """Retrieves user's refresh token from SQLite and returns an authenticated Spotify client.""" + cursor.execute( + "SELECT refresh_token FROM user_tokens WHERE discord_id = ?", + (str(discord_id),), + ) + row = cursor.fetchone() + + if not row: + return None + + try: + sp_oauth = get_oauth() + token_info = sp_oauth.refresh_access_token(row[0]) + return spotipy.Spotify(auth=token_info["access_token"]) + except Exception as e: + print(f"Error refreshing token for user {discord_id}: {e}") + return None + + +class SpotifyAPI: + + @staticmethod + def get_profile_picture(sp: spotipy.Spotify) -> str | None: + user = sp.current_user() + images = user.get("images", []) + return images[0]["url"] if images else None + + @staticmethod + def get_top_track(sp: spotipy.Spotify, time_range="short_term") -> dict | None: + res = sp.current_user_top_tracks(limit=1, time_range=time_range) + items = res.get("items", []) + if not items: + return None + + track = items[0] + return { + "title": track["name"], + "artist": track["artists"][0]["name"], + "album_art": track["album"]["images"][0]["url"] + if track["album"]["images"] + else None, + "url": track["external_urls"]["spotify"], + } + + @staticmethod + def get_top_artist(sp: spotipy.Spotify, time_range="short_term") -> dict | None: + res = sp.current_user_top_artists(limit=1, time_range=time_range) + items = res.get("items", []) + if not items: + return None + + artist = items[0] + return { + "name": artist["name"], + "image_url": artist["images"][0]["url"] if artist["images"] else None, + "url": artist["external_urls"]["spotify"], + } + + @staticmethod + def get_top_album(sp: spotipy.Spotify, time_range="short_term") -> dict | None: + res = sp.current_user_top_tracks(limit=50, time_range=time_range) + tracks = res.get("items", []) + if not tracks: + return None + + album_counts = Counter(t["album"]["id"] for t in tracks) + top_album_id = album_counts.most_common(1)[0][0] + + for t in tracks: + if t["album"]["id"] == top_album_id: + album = t["album"] + return { + "title": album["name"], + "artist": album["artists"][0]["name"], + "cover_art": album["images"][0]["url"] + if album["images"] + else None, + "url": album["external_urls"]["spotify"], + } + return None + + @staticmethod + def get_currently_playing(sp: spotipy.Spotify) -> dict: + playback = sp.current_playback() + if playback and playback.get("is_playing") and playback.get("item"): + item = playback["item"] + return { + "status": "playing", + "is_playing": True, + "title": item["name"], + "artist": item["artists"][0]["name"], + "album_art": item["album"]["images"][0]["url"] + if item["album"]["images"] + else None, + "url": item["external_urls"]["spotify"], + } + + recently_played = sp.current_user_recently_played(limit=1) + items = recently_played.get("items", []) + + if items: + track = items[0]["track"] + return { + "status": "recently_played", + "is_playing": False, + "title": track["name"], + "artist": track["artists"][0]["name"], + "album_art": track["album"]["images"][0]["url"] + if track["album"]["images"] + else None, + "url": track["external_urls"]["spotify"], + } + + return { + "status": "offline", + "is_playing": False, + "title": "No listening history", + "artist": "N/A", + "album_art": None, + "url": None, + } + + +# ============================================================================== +# 4. DISCORD PAYLOAD BUILDER & DISCORD IDENTITY API UPDATER +# ============================================================================== +def build_discord_profile_payload( + spotify_username: str, + spotify_profile_pic: str | None, + top_song: dict | None, + top_album: dict | None, + top_artist: dict | None, + current_track: dict, + time_range_label: str = "4 weeks", +) -> dict: + DEFAULT_IMAGE = "https://cdn.discordapp.com/embed/avatars/0.png" + status_label = ( + "Currently Playing" if current_track.get("is_playing") else "Last Played" + ) + + return { + "data": { + "dynamic": [ + { + "type": 3, + "name": "spotify_profile_picture", + "value": {"url": spotify_profile_pic or DEFAULT_IMAGE}, + }, + { + "type": 1, + "name": "spotfy_user_name", + "value": spotify_username or "Unknown User", + }, + { + "type": 3, + "name": "most_listened_song_picture", + "value": { + "url": top_song.get("album_art") if top_song else DEFAULT_IMAGE + }, + }, + { + "type": 1, + "name": "most_listened_song_name", + "value": top_song.get("title") if top_song else "None", + }, + { + "type": 3, + "name": "most_listened_album_picture", + "value": { + "url": top_album.get("cover_art") if top_album else DEFAULT_IMAGE + }, + }, + { + "type": 1, + "name": "most_listened_album_name", + "value": top_album.get("title") if top_album else "None", + }, + { + "type": 3, + "name": "most_listened_artist_picture", + "value": { + "url": top_artist.get("image_url") + if top_artist + else DEFAULT_IMAGE + }, + }, + { + "type": 1, + "name": "most_listened_artist_name", + "value": top_artist.get("name") if top_artist else "None", + }, + { + "type": 3, + "name": "current_song_picture", + "value": { + "url": current_track.get("album_art") or DEFAULT_IMAGE + }, + }, + { + "type": 1, + "name": "current_song_name", + "value": current_track.get("title", "None"), + }, + {"type": 1, "name": "status", "value": status_label}, + {"type": 1, "name": "range", "value": time_range_label}, + ] + } + } + + +async def update_discord_widget(discord_user_id: str, payload: dict) -> bool: + url = f"https://discord.com/api/v9/applications/{DISCORD_APP_ID}/users/{discord_user_id}/identities/0/profile" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bot {DISCORD_BOT_TOKEN}", + "User-Agent": "DiscordBot (https://github.com/discord/discord-api-docs, 1.0.0)", + } + + async with aiohttp.ClientSession() as session: + async with session.patch(url, headers=headers, json=payload) as resp: + if resp.status == 200: + print(f"Successfully updated widget for user: {discord_user_id}") + return True + else: + response_text = await resp.text() + print(f"Failed to update ({resp.status}): {response_text}") + return False + + +async def process_user_widget_update(discord_user_id: str) -> bool: + """Helper function to orchestrate fetching Spotify stats and pushing to Discord.""" + sp = get_spotify_client(discord_user_id) + if not sp: + return False + + user_info = sp.current_user() + spotify_username = user_info.get("display_name") or user_info.get("id") + profile_pic = SpotifyAPI.get_profile_picture(sp) + top_song = SpotifyAPI.get_top_track(sp, time_range="short_term") + top_album = SpotifyAPI.get_top_album(sp, time_range="short_term") + top_artist = SpotifyAPI.get_top_artist(sp, time_range="short_term") + current_track = SpotifyAPI.get_currently_playing(sp) + + payload = build_discord_profile_payload( + spotify_username=spotify_username, + spotify_profile_pic=profile_pic, + top_song=top_song, + top_album=top_album, + top_artist=top_artist, + current_track=current_track, + time_range_label="4 weeks", + ) + + return await update_discord_widget(discord_user_id, payload) + + +# ============================================================================== +# 5. DISCORD BOT COMMANDS SETUP +# ============================================================================== +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) + + +async def on_ready(): + await bot.tree.sync() + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + + + name="spotify_login", + description="Get link to connect your Spotify account", +) +async def spotify_login(interaction: discord.Interaction): + sp_oauth = get_oauth() + auth_url = sp_oauth.get_authorize_url() + + msg = ( + f"1️⃣ [Click here to log into Spotify]({auth_url})\n" + f"2️⃣ After logging in, you'll reach a page that fails to load (e.g. `localhost`).\n" + f"3️⃣ Copy the long **`code`** parameter from the URL bar (e.g. `?code=ABC123...`).\n" + f"4️⃣ Run `/submit_code code:<YOUR_CODE>` to link your account!" + ) + await interaction.response.send_message(msg, ephemeral=True) + + +def extract_code(input_text: str) -> str: + """Extracts the 'code' parameter from a full Spotify callback URL, query string, or raw input.""" + input_text = input_text.strip() + + if "code=" in input_text: + parsed_url = urlparse(input_text) + query_params = parse_qs(parsed_url.query or parsed_url.path) + + if "code" in query_params: + return query_params["code"][0] + + try: + return input_text.split("code=")[1].split("&")[0] + except IndexingError: + pass + + return input_text + + + name="submit_code", + description="Submit your Spotify auth code or full callback URL", +) +async def submit_code(interaction: discord.Interaction, link_or_code: str): + await interaction.response.defer(ephemeral=True) + + # Automatically extract the actual code string + clean_code = extract_code(link_or_code) + + sp_oauth = get_oauth() + try: + token_info = sp_oauth.get_access_token(clean_code, check_cache=False) + refresh_token = token_info["refresh_token"] + + discord_id = str(interaction.user.id) + cursor.execute( + """ + INSERT INTO user_tokens (discord_id, refresh_token) VALUES (?, ?) + ON CONFLICT(discord_id) DO UPDATE SET refresh_token=excluded.refresh_token + """, + (discord_id, refresh_token), + ) + conn.commit() + + # Update widget immediately upon linking + success = await process_user_widget_update(discord_id) + + if success: + await interaction.followup.send( + "✅ Spotify account linked and widget updated successfully!", + ephemeral=True, + ) + else: + await interaction.followup.send( + "✅ Account linked, but failed to push initial widget update to Discord.", + ephemeral=True, + ) + + except Exception as e: + await interaction.followup.send( + f"❌ Invalid link/code or error linking account: {e}", ephemeral=True + ) + + [email protected](name="update_widget", description="Force update your Spotify widget payload") +async def update_widget(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + discord_id = str(interaction.user.id) + + sp = get_spotify_client(discord_id) + if not sp: + await interaction.followup.send( + "❌ You haven't linked your Spotify account yet! Run `/spotify_login` first.", + ephemeral=True, + ) + return + + success = await process_user_widget_update(discord_id) + if success: + await interaction.followup.send("✅ Widget successfully updated!", ephemeral=True) + else: + await interaction.followup.send( + "❌ Failed to update widget identity via Discord API.", ephemeral=True + ) + +if __name__ == "__main__": + bot.run(DISCORD_BOT_TOKEN)
\ No newline at end of file |
