├── Procfile ├── .gitignore ├── src ├── api │ └── index.js ├── config │ ├── config.js │ └── defaultImages.js ├── utils │ ├── responseHandler.js │ └── jsonProcessor.js ├── routes │ ├── quickRoutes │ │ └── activityRoutes.js │ └── userRoutes.js ├── app.js └── services │ ├── discordClient.js │ └── websocketServer.js ├── package.json ├── LICENSE └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | web: node src/api/index.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .DS_Store 4 | dist 5 | test.js -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | const app = require("../app"); 2 | 3 | module.exports = app; 4 | -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | require("dotenv").config({ path: path.resolve(__dirname, "../../.env") }); 3 | 4 | module.exports = { 5 | PORT: process.env.PORT || 3000, 6 | DISCORD_TOKEN: process.env.DISCORD_TOKEN, 7 | MAIN_GUILD: process.env.MAIN_GUILD, 8 | DEFAULT_ACTIVITY_IMAGE: "https://i.ibb.co/1GjmGmdr/a.png", 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "audibert", 3 | "version": "1.0.0", 4 | "description": "INSCREVA-SE NO CANAL DO AUDIBERT", 5 | "main": "src/api/index.js", 6 | "scripts": { 7 | "start": "node src/api/index.js" 8 | }, 9 | "dependencies": { 10 | "apicache": "^1.6.3", 11 | "cors": "^2.8.5", 12 | "discord.js": "^14.0.0", 13 | "dotenv": "^16.0.0", 14 | "express": "^4.17.1", 15 | "memory-cache": "^0.2.0", 16 | "mongodb": "^6.17.0", 17 | "node-fetch": "^2.6.1", 18 | "whatwg-url": "^14.1.0" 19 | }, 20 | "author": "audibert", 21 | "license": "MIT" 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/responseHandler.js: -------------------------------------------------------------------------------- 1 | const handleSuccess = (res, data, statusCode = 200) => { 2 | res.status(statusCode).json({ 3 | data, 4 | success: true, 5 | }); 6 | }; 7 | 8 | const handleError = ( 9 | res, 10 | statusCode, 11 | errorCode, 12 | message, 13 | additionalDetails = null 14 | ) => { 15 | const errorResponse = { 16 | error: { 17 | code: errorCode, 18 | message: message, 19 | }, 20 | success: false, 21 | }; 22 | 23 | if (additionalDetails) { 24 | errorResponse.error = { ...errorResponse.error, ...additionalDetails }; 25 | } 26 | 27 | res.status(statusCode).json(errorResponse); 28 | }; 29 | 30 | module.exports = { 31 | handleSuccess, 32 | handleError, 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matheus Audibert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/config/defaultImages.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | VALORANT: { 3 | largeImage: 4 | "https://cdn.discordapp.com/app-icons/700136079562375258/e55fc8259df1548328f977d302779ab7.png", 5 | }, 6 | Roblox: { 7 | largeImage: 8 | "https://cdn.discordapp.com/app-icons/363445589247131668/f2b60e350a2097289b3b0b877495e55f.png", 9 | }, 10 | Fortnite: { 11 | largeImage: 12 | "https://cdn.discordapp.com/app-icons/432980957394370572/c1864b38910c209afd5bf6423b672022.png", 13 | }, 14 | "Counter-Strike 2": { 15 | largeImage: 16 | "https://cdn.discordapp.com/app-icons/1158877933042143272/558f5a26ecb3b17c3dea3d15c1df537a.png", 17 | }, 18 | Minecraft: { 19 | largeImage: 20 | "https://cdn.discordapp.com/app-icons/356875570916753438/166fbad351ecdd02d11a3b464748f66b.png", 21 | }, 22 | TLauncher: { 23 | largeImage: 24 | "https://cdn.discordapp.com/app-assets/1140558605771350016/1142030771398385665.png", 25 | }, 26 | "Rocket League": { 27 | largeImage: 28 | "https://cdn.discordapp.com/app-icons/356877880938070016/a74899a5190c48a3e6ce9f8d2eaff348.png", 29 | }, 30 | "Grand Theft Auto V": { 31 | largeImage: 32 | "https://cdn.discordapp.com/app-icons/356876176465199104/069d9f4871b5ebd2f62bd342ce6ba77f.webp?size=160&keep_aspect_ratio=false", 33 | }, 34 | "Overwatch 2": { 35 | largeImage: 36 | "https://cdn.discordapp.com/app-icons/356875570916753438/166fbad351ecdd02d11a3b464748f66b.png", 37 | }, 38 | "World of Tanks Blitz": { 39 | largeImage: 40 | "https://cdn.discordapp.com/app-icons/419272031960432651/3d22f4d22095e18963ff8bb7b67b8946.png", 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/routes/quickRoutes/activityRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const client = require("../../services/discordClient"); 4 | const { 5 | checkUserInGuilds, 6 | processSpotifyActivity, 7 | processGeneralActivities, 8 | } = require("../../utils/jsonProcessor"); 9 | 10 | router.get("/:id", async (req, res) => { 11 | const USER_ID = req.params.id; 12 | 13 | try { 14 | const { member } = await checkUserInGuilds(client, USER_ID); 15 | 16 | if (!member) { 17 | return res.status(404).json({ 18 | error: { 19 | code: "user_not_monitored", 20 | message: "User is not being monitored by Grux", 21 | discord_invite: "https://discord.gg/gu7sKjwEz5", 22 | }, 23 | success: false, 24 | }); 25 | } 26 | 27 | let userStatus = member.presence?.status || "invisible"; 28 | if (userStatus === "offline") { 29 | userStatus = "invisible"; 30 | } 31 | 32 | const activities = member.presence?.activities || []; 33 | 34 | const spotifyActivity = processSpotifyActivity(activities); 35 | const generalActivity = processGeneralActivities(activities); 36 | 37 | return res.json({ 38 | data: { 39 | status: userStatus, 40 | spotify: spotifyActivity, 41 | activity: generalActivity, 42 | }, 43 | success: true, 44 | }); 45 | } catch (error) { 46 | console.error("Error processing request:", error); 47 | return res.status(500).json({ 48 | error: { 49 | code: "internal_server_error", 50 | message: "An error occurred while processing the request", 51 | }, 52 | success: false, 53 | }); 54 | } 55 | }); 56 | 57 | module.exports = router; 58 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const cors = require("cors"); 3 | const http = require("http"); 4 | const config = require("./config/config"); 5 | const client = require("./services/discordClient"); 6 | const websocketServer = require("./services/websocketServer"); 7 | const userRoutes = require("./routes/userRoutes"); 8 | const activityRoutes = require("./routes/quickRoutes/activityRoutes"); 9 | 10 | const app = express(); 11 | const server = http.createServer(app); 12 | const PORT = config.PORT; 13 | 14 | app.use(cors()); 15 | app.use(express.json()); 16 | 17 | websocketServer.initialize(server); 18 | 19 | app.get("/", async (req, res) => { 20 | try { 21 | const mainGuild = await client.guilds.fetch(config.MAIN_GUILD); 22 | await mainGuild.members.fetch(); 23 | const humanMemberCount = mainGuild.members.cache.filter( 24 | (member) => !member.user.bot 25 | ).size; 26 | 27 | const wsStats = websocketServer.getStats(); 28 | 29 | res.json({ 30 | data: { 31 | info: "Grux provides Discord presences as an API. Find out more here: https://github.com/matheusaudibert/grux", 32 | discord_invite: "https://discord.gg/gu7sKjwEz5", 33 | monitored_user_count: humanMemberCount, 34 | }, 35 | success: true, 36 | }); 37 | } catch (error) { 38 | console.error("Error fetching guild data:", error); 39 | res.status(500).json({ 40 | error: { 41 | code: "internal_server_error", 42 | message: "An error occurred while processing the request", 43 | }, 44 | success: false, 45 | }); 46 | } 47 | }); 48 | 49 | app.use("/user", userRoutes); 50 | app.use("/activity", activityRoutes); 51 | 52 | server.listen(PORT, () => { 53 | console.log( 54 | `API running on http://localhost:${PORT}/user/1274150219482660897` 55 | ); 56 | console.log(`WebSocket server running on ws://localhost:${PORT}`); 57 | }); 58 | 59 | app.use("*", (req, res) => { 60 | res.status(404).json({ 61 | error: { 62 | code: "not_found", 63 | message: "Route does not exist", 64 | }, 65 | success: false, 66 | }); 67 | }); 68 | 69 | module.exports = app; 70 | -------------------------------------------------------------------------------- /src/services/discordClient.js: -------------------------------------------------------------------------------- 1 | const { Client, GatewayIntentBits, ActivityType } = require("discord.js"); 2 | const config = require("../config/config"); 3 | const websocketServer = require("./websocketServer"); 4 | const { 5 | processSpotifyActivity, 6 | processGeneralActivities, 7 | } = require("../utils/jsonProcessor"); 8 | 9 | const client = new Client({ 10 | intents: [ 11 | GatewayIntentBits.Guilds, 12 | GatewayIntentBits.GuildPresences, 13 | GatewayIntentBits.GuildMembers, 14 | ], 15 | }); 16 | 17 | const presenceCache = new Map(); 18 | 19 | client.once("ready", () => { 20 | console.log(`${client.user.tag} online!`); 21 | client.user.setPresence({ 22 | activities: [ 23 | { 24 | name: "grux.audibert.dev", 25 | type: ActivityType.Watching, 26 | }, 27 | ], 28 | }); 29 | 30 | setInterval(() => { 31 | checkAllPresences(); 32 | }, 3000); 33 | }); 34 | 35 | function checkAllPresences() { 36 | const mainGuild = client.guilds.cache.get(config.MAIN_GUILD); 37 | if (!mainGuild) return; 38 | 39 | mainGuild.members.cache.forEach((member) => { 40 | if (member.user.bot) return; 41 | 42 | const userId = member.user.id; 43 | const currentPresence = member.presence; 44 | const cachedPresence = presenceCache.get(userId); 45 | 46 | if (hasPresenceChanged(cachedPresence, currentPresence)) { 47 | presenceCache.set(userId, serializePresence(currentPresence)); 48 | broadcastPresenceUpdate(userId, currentPresence); 49 | } 50 | }); 51 | } 52 | 53 | function hasPresenceChanged(oldPresence, newPresence) { 54 | const oldSerialized = serializePresence(oldPresence); 55 | const newSerialized = serializePresence(newPresence); 56 | 57 | return JSON.stringify(oldSerialized) !== JSON.stringify(newSerialized); 58 | } 59 | 60 | function serializePresence(presence) { 61 | if (!presence) return null; 62 | 63 | return { 64 | status: presence.status, 65 | activities: 66 | presence.activities?.map((activity) => ({ 67 | name: activity.name, 68 | type: activity.type, 69 | details: activity.details, 70 | state: activity.state, 71 | timestamps: activity.timestamps, 72 | assets: activity.assets, 73 | syncId: activity.syncId, 74 | createdTimestamp: activity.createdTimestamp, 75 | })) || [], 76 | }; 77 | } 78 | 79 | function broadcastPresenceUpdate(userId, presence) { 80 | const activities = presence?.activities || []; 81 | const spotifyActivity = processSpotifyActivity(activities); 82 | const generalActivity = processGeneralActivities(activities); 83 | 84 | let userStatus = presence?.status || "invisible"; 85 | if (userStatus === "offline") { 86 | userStatus = "invisible"; 87 | } 88 | 89 | websocketServer.broadcastPresenceUpdate( 90 | userId, 91 | userStatus, 92 | spotifyActivity, 93 | generalActivity 94 | ); 95 | } 96 | 97 | client.on("presenceUpdate", (oldPresence, newPresence) => { 98 | const userId = newPresence.userId; 99 | 100 | const mainGuild = client.guilds.cache.get(config.MAIN_GUILD); 101 | if (!mainGuild || !mainGuild.members.cache.has(userId)) { 102 | return; 103 | } 104 | 105 | presenceCache.set(userId, serializePresence(newPresence)); 106 | 107 | broadcastPresenceUpdate(userId, newPresence); 108 | }); 109 | 110 | client.on("error", (error) => { 111 | console.error("Error in client:", error); 112 | }); 113 | 114 | client.on("disconnect", () => { 115 | console.warn("Discord client disconnected, attempting to reconnect..."); 116 | }); 117 | 118 | client.on("reconnecting", () => { 119 | console.log("Discord client reconnecting..."); 120 | }); 121 | 122 | client.login(config.DISCORD_TOKEN).catch((error) => { 123 | console.error("Login failed:", error); 124 | }); 125 | 126 | module.exports = client; 127 | -------------------------------------------------------------------------------- /src/routes/userRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const { MongoClient } = require("mongodb"); 4 | const client = require("../services/discordClient"); 5 | const mcache = require("memory-cache"); 6 | const { 7 | checkUserInGuilds, 8 | processProfileInfo, 9 | processSpotifyActivity, 10 | processGeneralActivities, 11 | } = require("../utils/jsonProcessor"); 12 | const { handleSuccess, handleError } = require("../utils/responseHandler"); 13 | 14 | const mongoUri = process.env.MONGODB_URI; 15 | 16 | const getCachedFields = (userId) => { 17 | const cacheKey = `user_cached_fields_${userId}`; 18 | return mcache.get(cacheKey); 19 | }; 20 | 21 | const setCachedFields = (userId, fields) => { 22 | const cacheKey = `user_cached_fields_${userId}`; 23 | mcache.put(cacheKey, fields, 300 * 1000); // 5 minutos 24 | }; 25 | 26 | router.get("/:id", async (req, res) => { 27 | const USER_ID = req.params.id; 28 | 29 | try { 30 | const { member, isUserFound } = await checkUserInGuilds(client, USER_ID); 31 | 32 | if (!isUserFound || !member) { 33 | return handleError( 34 | res, 35 | 404, 36 | "user_not_monitored", 37 | "User is not being monitored by Grux", 38 | { discord_invite: "https://discord.gg/gu7sKjwEz5" } 39 | ); 40 | } 41 | 42 | let cachedFields = getCachedFields(USER_ID); 43 | 44 | if (!cachedFields) { 45 | let userData = null; 46 | try { 47 | const mongoClient = new MongoClient(mongoUri); 48 | await mongoClient.connect(); 49 | const database = mongoClient.db("test"); 50 | const usersCollection = database.collection("users"); 51 | userData = await usersCollection.findOne({ _id: USER_ID }); 52 | await mongoClient.close(); 53 | } catch (dbError) { 54 | console.error("Error fetching data from MongoDB:", dbError); 55 | return handleError( 56 | res, 57 | 500, 58 | "database_error", 59 | "Could not retrieve user data from database." 60 | ); 61 | } 62 | 63 | if (!userData) { 64 | return handleError( 65 | res, 66 | 404, 67 | "user_not_in_database", 68 | "User is being monitored but it is not in the database." 69 | ); 70 | } 71 | 72 | cachedFields = { 73 | badges: userData.badges || [], 74 | nameplate: userData.nameplate || null, 75 | connected_accounts: userData.connectedAccounts || [], 76 | clan: userData.clan || null, 77 | }; 78 | 79 | setCachedFields(USER_ID, cachedFields); 80 | } 81 | 82 | const userData = { 83 | badges: cachedFields.badges, 84 | nameplate: cachedFields.nameplate, 85 | connectedAccounts: cachedFields.connected_accounts, 86 | clan: cachedFields.clan, 87 | }; 88 | 89 | const profileInfo = processProfileInfo(member, userData); 90 | const activities = member.presence?.activities || []; 91 | const spotifyActivity = processSpotifyActivity(activities); 92 | const generalActivity = processGeneralActivities(activities); 93 | 94 | let userStatus = member.presence?.status || "invisible"; 95 | if (userStatus === "offline") { 96 | userStatus = "invisible"; 97 | } 98 | 99 | const apiData = { 100 | profile: profileInfo, 101 | status: userStatus, 102 | spotify: spotifyActivity, 103 | activity: generalActivity, 104 | }; 105 | 106 | handleSuccess(res, apiData); 107 | } catch (error) { 108 | console.error("Unhandled error in user route:", error.message); 109 | handleError( 110 | res, 111 | 500, 112 | "internal_server_error", 113 | "An error occurred while processing the request" 114 | ); 115 | } 116 | }); 117 | 118 | module.exports = router; 119 | -------------------------------------------------------------------------------- /src/services/websocketServer.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require("ws"); 2 | const {} = require("../utils/jsonProcessor"); 3 | 4 | class WebSocketServer { 5 | constructor() { 6 | this.wss = null; 7 | this.connectedUsers = new Map(); 8 | } 9 | 10 | initialize(server) { 11 | this.wss = new WebSocket.Server({ server }); 12 | 13 | this.wss.on("connection", (ws, req) => { 14 | console.log("New WebSocket connection established"); 15 | 16 | // Extrair user ID da URL 17 | const url = new URL(req.url, `http://${req.headers.host}`); 18 | const userId = url.searchParams.get("user_id"); 19 | 20 | if (!userId) { 21 | ws.send( 22 | JSON.stringify({ 23 | op: "error", 24 | d: { message: "user_id parameter is required" }, 25 | }) 26 | ); 27 | ws.close(); 28 | return; 29 | } 30 | 31 | if (!this.connectedUsers.has(userId)) { 32 | this.connectedUsers.set(userId, new Set()); 33 | } 34 | this.connectedUsers.get(userId).add(ws); 35 | 36 | this.sendInitialData(ws, userId); 37 | 38 | ws.on("close", () => { 39 | this.removeConnection(ws, userId); 40 | console.log("WebSocket connection closed"); 41 | }); 42 | 43 | ws.on("error", (error) => { 44 | console.error("WebSocket error:", error); 45 | }); 46 | }); 47 | 48 | console.log("WebSocket server initialized"); 49 | } 50 | 51 | async sendInitialData(ws, userId) { 52 | try { 53 | const response = await fetch(`https://grux.audibert.dev/user/${userId}`); 54 | const data = await response.json(); 55 | 56 | if (data.success) { 57 | ws.send( 58 | JSON.stringify({ 59 | op: "initial_data", 60 | d: data.data, 61 | }) 62 | ); 63 | } else { 64 | ws.send( 65 | JSON.stringify({ 66 | op: "error", 67 | d: { message: "User not found or not monitored" }, 68 | }) 69 | ); 70 | ws.close(); 71 | } 72 | } catch (error) { 73 | console.error("Error fetching initial data:", error); 74 | ws.send( 75 | JSON.stringify({ 76 | op: "error", 77 | d: { message: "Failed to fetch user data" }, 78 | }) 79 | ); 80 | ws.close(); 81 | } 82 | } 83 | 84 | removeConnection(ws, userId) { 85 | if (this.connectedUsers.has(userId)) { 86 | this.connectedUsers.get(userId).delete(ws); 87 | if (this.connectedUsers.get(userId).size === 0) { 88 | this.connectedUsers.delete(userId); 89 | } 90 | } 91 | } 92 | 93 | broadcastPresenceUpdate(userId, status, spotifyActivity, generalActivity) { 94 | if (!this.connectedUsers.has(userId)) { 95 | return; 96 | } 97 | 98 | const data = { 99 | op: "presence_update", 100 | d: { 101 | status, 102 | spotify: spotifyActivity, 103 | activity: generalActivity, 104 | }, 105 | }; 106 | 107 | const connections = this.connectedUsers.get(userId); 108 | const deadConnections = []; 109 | 110 | connections.forEach((ws) => { 111 | if (ws.readyState === WebSocket.OPEN) { 112 | try { 113 | ws.send(JSON.stringify(data)); 114 | } catch (error) { 115 | console.error("Error sending message:", error); 116 | deadConnections.push(ws); 117 | } 118 | } else { 119 | deadConnections.push(ws); 120 | } 121 | }); 122 | 123 | deadConnections.forEach((ws) => connections.delete(ws)); 124 | 125 | if (connections.size > 0) { 126 | console.log( 127 | `Sent presence update to ${connections.size} client(s) for user: ${userId}` 128 | ); 129 | } 130 | } 131 | 132 | getStats() { 133 | return { 134 | total_connections: this.wss ? this.wss.clients.size : 0, 135 | connected_users: this.connectedUsers.size, 136 | total_user_connections: Array.from(this.connectedUsers.values()).reduce( 137 | (total, connections) => total + connections.size, 138 | 0 139 | ), 140 | }; 141 | } 142 | } 143 | 144 | module.exports = new WebSocketServer(); 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grux API 2 | 3 | Grux is a service that makes it easy to access Discord profile and presence informations through a RESTful API `(grux.audibert.dev/user/:userid)` and WebSocket (see below). Perfect for displaying your Discord profile, badges, status, activities, and server information on your website or application. 4 | 5 | ## Get Started 6 | 7 | 1. Join my [Discord server](https://discord.gg/gu7sKjwEz5). 8 | 9 | [![Discord Server Card](https://cardzera.audibert.dev/api/1383718526694461532?buttonText=Join%20now%20to%20access%20the%20API&t={timestamp})](https://discord.gg/gu7sKjwEz5) 10 | 11 | 2. Your presence will be available at the API endpoint. 12 | 13 | That's all you need to do! 14 | 15 | ## API Docs 16 | 17 | ### Getting a user's full presence data 18 | 19 | `GET grux.audibert.dev/user/:userid` 20 | 21 | > [!NOTE] 22 | > This endpoint has a 5-minute cache for profile fields (nameplate, badges, clan, connected_accounts) to improve performance and avoid limits. 23 | 24 | ```json 25 | { 26 | "data": { 27 | "profile": { 28 | "bot": false, 29 | "device": "desktop", 30 | "id": "1274150219482660897", 31 | "creation_date": "2024-08-16T23:38:04.891Z", 32 | "username": "grwx", 33 | "display_name": "Audibert", 34 | "link": "https://discord.com/users/1274150219482660897", 35 | "avatar": "6c9747063de19030c95daa80c1ca61c7", 36 | "avatar_image": "https://cdn.discordapp.com/avatars/1274150219482660897/6c9747063de19030c95daa80c1ca61c7.png?size=1024", 37 | "avatar_decoration_image": null, 38 | "nameplate_image": "https://cdn.discordapp.com/assets/collectibles/nameplates/nameplates/twilight/static.png", 39 | "badges": [ 40 | { 41 | "id": "active_developer", 42 | "description": "Active Developer", 43 | "asset": "6bdc42827a38498929a4920da12695d9", 44 | "badge_image": "https://cdn.discordapp.com/badge-icons/6bdc42827a38498929a4920da12695d9.png" 45 | }, 46 | { 47 | "id": "quest_completed", 48 | "description": "Completed a Quest", 49 | "asset": "7d9ae358c8c5e118768335dbe68b4fb8", 50 | "badge_image": "https://cdn.discordapp.com/badge-icons/7d9ae358c8c5e118768335dbe68b4fb8.png" 51 | }, 52 | { 53 | "id": "orb_profile_badge", 54 | "description": "Collected the Orb Profile Badge", 55 | "asset": "83d8a1eb09a8d64e59233eec5d4d5c2d", 56 | "badge_image": "https://cdn.discordapp.com/badge-icons/83d8a1eb09a8d64e59233eec5d4d5c2d.png" 57 | } 58 | ], 59 | "clan": { 60 | "tag": "CODE", 61 | "identity_guild_id": "1112920281367973900", 62 | "asset": "b8161e357e4c3de4fb7c649b3523a1ea", 63 | "clan_image": "https://cdn.discordapp.com/clan-badges/1112920281367973900/b8161e357e4c3de4fb7c649b3523a1ea.png" 64 | }, 65 | "connected_accounts": [ 66 | { 67 | "type": "youtube", 68 | "name": "audibert", 69 | "link": "https://youtube.com/channel/UCIO1e3zJ-c2oQCWnmY4nqIQ" 70 | } 71 | ] 72 | }, 73 | "status": "online", 74 | "spotify": { 75 | "type": "Listening to Spotify", 76 | "name": "Spotify", 77 | "song": "90mph", 78 | "artist": "JBEE, Sillage", 79 | "album": "90mph", 80 | "album_image": "https://i.scdn.co/image/ab67616d0000b2737aee7c56bd63016a79ddc9d1", 81 | "link": "https://open.spotify.com/track/6uT2TsDrCrXue7ROEfNeGN", 82 | "timestamps": { 83 | "start": 1750873861045, 84 | "end": 1750874008612 85 | }, 86 | "created_at": 1750873861499 87 | }, 88 | "activity": [ 89 | { 90 | "type": "Playing", 91 | "name": "Visual Studio Code", 92 | "state": "Workspace: audibert", 93 | "details": "Editing README.md", 94 | "largeText": "Editing a MARKDOWN file", 95 | "largeImage": "https://cdn.discordapp.com/app-assets/383226320970055681/1359299128655347824.png", 96 | "smallText": "Visual Studio Code", 97 | "smallImage": "https://cdn.discordapp.com/app-assets/383226320970055681/1359299466493956258.png", 98 | "timestamps": { 99 | "start": 1750865485922 100 | }, 101 | "created_at": 1750873791410 102 | } 103 | ] 104 | }, 105 | "success": true 106 | } 107 | ``` 108 | 109 | ### Getting only activity data (real-time) 110 | 111 | If you only need activity and status information without cached profile data, use: 112 | 113 | `GET grux.audibert.dev/activity/:userid` 114 | 115 | ```json 116 | { 117 | "data": { 118 | "status": "online", 119 | "spotify": /* spotify data */, 120 | "activity": /* activity data */}, 121 | "success": true 122 | } 123 | ``` 124 | 125 | ## WeSocket Docs 126 | 127 | ### Connecting to the WebSocket 128 | 129 | `wss://grux.audibert.dev?user_id=:userid` 130 | 131 | ### Initial data 132 | 133 | Sent once when the connection is established. Contains full profile, status, activity, and Spotify data. 134 | 135 | ```json 136 | { 137 | "op": "initial_data", 138 | "d": { 139 | "profile": { /* profile data */ }, 140 | "status": /* online, dnd, idle or invisible */, 141 | "spotify": { /* spotify data */ }, 142 | "activity": [ /* activity data */ ] 143 | } 144 | } 145 | ``` 146 | 147 | ### Presence Update 148 | 149 | Sent automatically whenever the user's activity or status changes. 150 | 151 | ```json 152 | { 153 | "op": "presence_update", 154 | "d": { 155 | "status": /* online, dnd, idle or invisible */, 156 | "spotify": { /* updated Spotify info */ }, 157 | "activity": [ /* updated activities */ ], 158 | } 159 | } 160 | ``` 161 | 162 | > [!NOTE] 163 | > The connection does not require any manual heartbeat, the server handles all that internally. 164 | 165 | ## Contribuition 166 | 167 | Contributions are welcome! Feel free to open an issue or submit a pull request if you have a way to improve this project. 168 | 169 | Make sure your request is meaningful and you have tested the app locally before submitting a pull request. 170 | 171 | ## Support 172 | 173 | _If you're using this repo, feel free to show support and give this repo a ⭐ star! It means a lot, thank you :)_ 174 | 175 | ## Extra 176 | 177 | The first version of this API was built in a video: 178 | 179 | [![YouTube Card](https://ytcards.audibert.dev/api/3sJCXoxgbHQ?width=250&theme=github&max_title_lines=1&show_duration=false)](https://youtube.com/watch?v=3sJCXoxgbHQ) 180 | -------------------------------------------------------------------------------- /src/utils/jsonProcessor.js: -------------------------------------------------------------------------------- 1 | const config = require("../config/config"); 2 | const defaultImages = require("../config/defaultImages"); 3 | 4 | const checkUserInGuilds = async (client, USER_ID) => { 5 | const MAIN_GUILD = config.MAIN_GUILD; 6 | let member = null; 7 | 8 | try { 9 | const mainGuild = await client.guilds.fetch(MAIN_GUILD); 10 | member = await mainGuild.members.fetch(USER_ID, { force: true }); 11 | 12 | if (member) { 13 | return { isUserFound: true, member }; 14 | } 15 | } catch (error) {} 16 | 17 | return { isUserFound: false, member: null }; 18 | }; 19 | 20 | function getCreation(userId) { 21 | const DISCORD_EPOCH = 1420070400000; 22 | const timestamp = BigInt(userId) >> 22n; 23 | const creationDate = new Date(Number(timestamp) + DISCORD_EPOCH); 24 | 25 | return creationDate.toISOString(); 26 | } 27 | 28 | const processConnectedAccounts = (accounts) => { 29 | return accounts.map((account) => { 30 | let link = null; 31 | switch (account.type) { 32 | case "reddit": 33 | link = `https://reddit.com/user/${account.name}`; 34 | break; 35 | case "tiktok": 36 | link = `https://tiktok.com/@${account.name}`; 37 | break; 38 | case "twitter": 39 | link = `https://twitter.com/${account.name}`; 40 | break; 41 | case "ebay": 42 | link = `https://ebay.com/usr/${account.name}`; 43 | break; 44 | case "github": 45 | link = `https://github.com/${account.name}`; 46 | break; 47 | case "instagram": 48 | link = `https://instagram.com/${account.name}`; 49 | break; 50 | case "twitch": 51 | link = `https://twitch.tv/${account.name}`; 52 | break; 53 | case "domain": 54 | link = `https://${account.id}`; 55 | break; 56 | case "roblox": 57 | link = `https://roblox.com/pt/users/${account.id}`; 58 | break; 59 | case "steam": 60 | link = `https://steamcommunity.com/profiles/${account.id}`; 61 | break; 62 | case "spotify": 63 | link = `https://open.spotify.com/user/${account.id}`; 64 | break; 65 | case "youtube": 66 | link = `https://youtube.com/channel/${account.id}`; 67 | break; 68 | default: 69 | link = null; 70 | } 71 | return { type: account.type, name: account.name, link }; 72 | }); 73 | }; 74 | 75 | const processLargeImage = (image, applicationId, activityName) => { 76 | const defaultGame = defaultImages[activityName]; 77 | 78 | if (defaultGame) { 79 | return defaultGame.largeImage; 80 | } 81 | 82 | if (image && image.startsWith("mp:external/")) { 83 | const urlParts = image.split("/"); 84 | if (urlParts.length >= 4) { 85 | const externalUrl = urlParts.slice(3).join("/"); 86 | return `https://${externalUrl}`; 87 | } 88 | return null; 89 | } else if (image) { 90 | return `https://cdn.discordapp.com/app-assets/${applicationId}/${image}.png`; 91 | } 92 | return config.DEFAULT_ACTIVITY_IMAGE; 93 | }; 94 | 95 | const processSmallImage = (image, applicationId) => { 96 | if (image && image.startsWith("mp:external/")) { 97 | const urlParts = image.split("/"); 98 | if (urlParts.length >= 4) { 99 | const externalUrl = urlParts.slice(3).join("/"); 100 | return `https://${externalUrl}`; 101 | } 102 | return null; 103 | } else if (image) { 104 | return `https://cdn.discordapp.com/app-assets/${applicationId}/${image}.png`; 105 | } 106 | return null; 107 | }; 108 | 109 | const processBadges = (badgesData) => { 110 | return badgesData 111 | ? badgesData.map((badge) => ({ 112 | id: badge.id, 113 | description: badge.description, 114 | asset: badge.icon, 115 | badge_image: `https://cdn.discordapp.com/badge-icons/${badge.icon}.png`, 116 | })) 117 | : []; 118 | }; 119 | 120 | const processClan = (clanData) => { 121 | if ( 122 | !clanData || 123 | !clanData.identity_guild_id || 124 | !clanData.badge || 125 | !clanData.tag 126 | ) { 127 | return null; 128 | } 129 | 130 | return { 131 | tag: clanData.tag, 132 | identity_guild_id: clanData.identity_guild_id, 133 | asset: clanData.badge, 134 | clan_image: `https://cdn.discordapp.com/clan-badges/${clanData.identity_guild_id}/${clanData.badge}.png`, 135 | }; 136 | }; 137 | 138 | const processProfileInfo = (member, userData) => { 139 | const nameplate_image = userData?.nameplate?.asset 140 | ? `https://cdn.discordapp.com/assets/collectibles/${userData.nameplate.asset}static.png` 141 | : null; 142 | 143 | return { 144 | bot: member.user.bot || false, 145 | device: member.presence?.clientStatus?.desktop 146 | ? "desktop" 147 | : member.presence?.clientStatus?.mobile 148 | ? "mobile" 149 | : member.presence?.clientStatus?.web 150 | ? "web" 151 | : null, 152 | id: member.user.id, 153 | creation_date: getCreation(member.user.id), 154 | username: member.user.username, 155 | display_name: member.user.globalName, 156 | link: `https://discord.com/users/${member.user.id}`, 157 | avatar: member.user.avatar, 158 | avatar_image: member.user.displayAvatarURL({ 159 | size: 1024, 160 | extension: "png", 161 | }), 162 | avatar_decoration_image: member?.user?.avatarDecorationData?.asset 163 | ? `https://cdn.discordapp.com/avatar-decoration-presets/${member.user.avatarDecorationData.asset}.png` 164 | : null, 165 | nameplate_image, 166 | public_flags: member.user.flags.bitfield, 167 | badges: processBadges(userData.badges), 168 | clan: processClan(userData.clan), 169 | connected_accounts: processConnectedAccounts( 170 | userData.connectedAccounts || [] 171 | ), 172 | }; 173 | }; 174 | 175 | const processSpotifyActivity = (activities) => { 176 | const spotifyActivity = activities 177 | .filter((activity) => activity.name === "Spotify") 178 | .map((activity) => ({ 179 | type: "Listening to Spotify", 180 | name: activity.name, 181 | song: activity.details || null, 182 | artist: activity.state ? activity.state.replace(/;/g, ",") : null, 183 | album: activity.assets?.largeText || null, 184 | album_image: 185 | activity.assets?.largeImage?.replace( 186 | "spotify:", 187 | "https://i.scdn.co/image/" 188 | ) || null, 189 | link: activity.syncId 190 | ? `https://open.spotify.com/track/${activity.syncId}` 191 | : null, 192 | timestamps: { 193 | start: activity.timestamps?.start 194 | ? new Date(activity.timestamps.start).getTime() 195 | : null, 196 | end: activity.timestamps?.end 197 | ? new Date(activity.timestamps.end).getTime() 198 | : null, 199 | }, 200 | created_at: activity.createdTimestamp 201 | ? new Date(activity.createdTimestamp).getTime() 202 | : null, 203 | })); 204 | return spotifyActivity.length > 0 ? spotifyActivity[0] : null; 205 | }; 206 | 207 | const processGeneralActivities = (activities) => { 208 | const generalActivities = activities 209 | .filter((activity) => activity.type === 0) 210 | .map((activity) => ({ 211 | type: "Playing", 212 | name: activity.name, 213 | state: activity.state || null, 214 | details: activity.details || null, 215 | largeText: activity.assets?.largeText || null, 216 | largeImage: processLargeImage( 217 | activity.assets?.largeImage, 218 | activity.applicationId, 219 | activity.name 220 | ), 221 | smallText: activity.assets?.smallText || null, 222 | smallImage: activity.assets?.smallImage 223 | ? processSmallImage(activity.assets.smallImage, activity.applicationId) 224 | : null, 225 | timestamps: { 226 | start: activity.timestamps?.start 227 | ? new Date(activity.timestamps.start).getTime() 228 | : null, 229 | }, 230 | created_at: activity.createdTimestamp 231 | ? new Date(activity.createdTimestamp).getTime() 232 | : null, 233 | })); 234 | return generalActivities.length > 0 ? generalActivities.reverse() : null; 235 | }; 236 | 237 | module.exports = { 238 | checkUserInGuilds, 239 | getCreation, 240 | processLargeImage, 241 | processSmallImage, 242 | processConnectedAccounts, 243 | processBadges, 244 | processClan, 245 | processProfileInfo, 246 | processSpotifyActivity, 247 | processGeneralActivities, 248 | }; 249 | --------------------------------------------------------------------------------