Files
portfolio/discord-presence-bot/index.js
denshooter 38ae39440e
All checks were successful
CI / CD / test-build (push) Successful in 10m12s
CI / CD / deploy-dev (push) Successful in 1m45s
CI / CD / deploy-production (push) Has been skipped
feat: resolve game covers from Steam API when Discord has no image
2026-04-23 23:10:59 +02:00

182 lines
5.7 KiB
JavaScript

const { Client, GatewayIntentBits, ActivityType } = require("discord.js");
const http = require("https");
const TOKEN = process.env.DISCORD_BOT_TOKEN;
const TARGET_USER_ID = process.env.DISCORD_USER_ID || "172037532370862080";
const PORT = parseInt(process.env.BOT_PORT || "3001", 10);
if (!TOKEN) {
console.error("DISCORD_BOT_TOKEN is required");
process.exit(1);
}
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildPresences,
GatewayIntentBits.GuildMembers,
],
});
const steamCoverCache = new Map();
function fetchJSON(url) {
return new Promise((resolve, reject) => {
const req = http.get(url, { timeout: 5000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return fetchJSON(res.headers.location).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode}`));
}
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
});
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
});
}
async function resolveGameCover(name) {
if (steamCoverCache.has(name)) return steamCoverCache.get(name);
try {
const search = await fetchJSON(`https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(name)}&l=english&cc=us`);
const items = search?.response?.items;
if (!items || items.length === 0) {
steamCoverCache.set(name, null);
return null;
}
const match = items.find((i) => i.name.toLowerCase() === name.toLowerCase()) || items[0];
const appId = match.id;
const details = await fetchJSON(`https://store.steampowered.com/api/appdetails/?appids=${appId}&cc=us`);
const appData = details?.[String(appId)]?.data;
const coverUrl = appData?.header_image || null;
steamCoverCache.set(name, coverUrl);
console.log(`Resolved cover for "${name}": ${coverUrl}`);
return coverUrl;
} catch (e) {
console.error(`Steam lookup failed for "${name}":`, e.message);
steamCoverCache.set(name, null);
return null;
}
}
let cachedData = {
discord_status: "offline",
listening_to_spotify: false,
spotify: null,
activities: [],
};
async function updatePresence(guild) {
const member = guild.members.cache.get(TARGET_USER_ID);
if (!member || !member.presence) return;
const presence = member.presence;
cachedData.discord_status = presence.status || "offline";
const activities = presence.activities
? presence.activities.filter((a) => a.type !== ActivityType.Custom)
: [];
const entries = [];
for (const a of activities) {
const entry = {
name: a.name,
type: a.type,
details: a.details || null,
state: a.state || null,
image: null,
applicationId: a.applicationId || null,
};
if (a.applicationId && a.assets?.largeImage) {
const imgKey = a.assets.largeImage;
entry.image = imgKey.startsWith("mp:external")
? `https://media.discordapp.net/${imgKey.replace("mp:", "")}`
: `https://cdn.discordapp.com/app-assets/${a.applicationId}/${imgKey}.png`;
} else if (a.type === ActivityType.Playing && !entry.image) {
const steamCover = await resolveGameCover(a.name);
if (steamCover) entry.image = steamCover;
}
if (a.assets) {
entry.assets = {
large_image: a.assets.largeImage || null,
large_text: a.assets.largeText || null,
small_image: a.assets.smallImage || null,
small_text: a.assets.smallText || null,
};
}
if (a.timestamps) {
entry.timestamps = {
start: a.timestamps.start?.toISOString() || null,
end: a.timestamps.end?.toISOString() || null,
};
}
entries.push(entry);
}
cachedData.activities = entries;
const spotifyActivity = activities.find((a) => a.type === ActivityType.Listening && a.name === "Spotify");
if (spotifyActivity && spotifyActivity.syncId) {
cachedData.listening_to_spotify = true;
cachedData.spotify = {
song: spotifyActivity.details || "",
artist: spotifyActivity.state ? spotifyActivity.state.replace(/; /g, "; ") : "",
album: spotifyActivity.assets?.largeText || "",
album_art_url: spotifyActivity.assets?.largeImage
? `https://i.scdn.co/image/${spotifyActivity.assets.largeImage.replace("spotify:", "")}`
: null,
track_id: spotifyActivity.syncId || null,
};
} else {
cachedData.listening_to_spotify = false;
cachedData.spotify = null;
}
}
async function updateAll() {
for (const guild of client.guilds.cache.values()) {
await updatePresence(guild);
}
}
client.on("clientReady", () => {
console.log(`Bot online as ${client.user.tag}`);
client.user.setActivity("Watching Presence", { type: ActivityType.Watching });
for (const guild of client.guilds.cache.values()) {
guild.members.fetch(TARGET_USER_ID).then(() => {
console.log(`Found user ${TARGET_USER_ID} in guild ${guild.name}`);
updateAll();
}).catch((err) => {
console.error(`Could not fetch user ${TARGET_USER_ID} in guild ${guild.name}:`, err.message);
});
}
});
client.on("presenceUpdate", () => {
updateAll();
});
const server = require("http").createServer((req, res) => {
if (req.method === "GET" && req.url === "/presence") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: cachedData }));
} else {
res.writeHead(404);
res.end("Not found");
}
});
server.listen(PORT, () => {
console.log(`HTTP endpoint listening on port ${PORT}`);
});
client.login(TOKEN);