├── .gitignore ├── CODPM-API-Discord.js ├── README.md ├── Sample-ANSI.png └── Sample-Bordered.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.code-workspace 2 | *.xcf -------------------------------------------------------------------------------- /CODPM-API-Discord.js: -------------------------------------------------------------------------------- 1 | const DISCORD_WEBHOOK = new URL(''); 2 | const DISCORD_TABLELAYOUT = 'ansi'; // 'ansi' or 'unicode' 3 | const DISCORD_MAXEMBEDS = 5; 4 | const CODPM_GAME = 'cod'; // cod, coduo, cod2, cod4 5 | const CODPM_VERSION = 1.1; 6 | const CONFIG_RENAME_EMPTY = 'Unnamed Server'; 7 | const CONFIG_SHOW_EMPTY = false; // List empty severs 8 | const CONFIG_UNICODE_SV_HOSTNAME = false; 9 | 10 | // /!\ END of configuration /!\ 11 | 12 | const CODPM_API = new URL(`/masterlist/${CODPM_GAME}/${CODPM_VERSION}`, 'https://api.cod.pm'); 13 | 14 | let updated = 0; 15 | 16 | function monotone(text) 17 | { 18 | return text.replace(/\^(?:\^\d)?\d/g, ''); 19 | } 20 | 21 | function truncate(text, maxlength, postfix) 22 | { 23 | if(postfix === undefined) 24 | postfix = '...'; 25 | 26 | if(text.length > maxlength) 27 | return text.substring(0, maxlength - postfix.length) + postfix; 28 | 29 | return text; 30 | } 31 | 32 | function ansicolorize(text, fg = 0, bg = 0, t1 = 0, t2) // syntax: ;[;][;] 33 | { // https://gist.github.com/kkrypt0nn/a02506f3712ff2d1c8ca7c9e0aed7c06 34 | /*Format: 35 | 0: Normal, 1: Bold, 4: Underline 36 | 37 | Foreground: 38 | 30: Gray, 31: Red, 32: Green, 33: Yellow, 34: Blue, 35: Pink, 36: Cyan, 37: White 39 | 40 | Background: 41 | 40: Firefly dark blue, 41: Orange, 42: Marble blue, 43: Greyish turquoise 42 | 44: Gray, 45: Indigo, 46: Light gray, 47: White*/ 43 | if(text !== undefined && (typeof text === 'string' || text instanceof String)) { 44 | const esc = '\u001b'; 45 | let color = `[${t1};${bg}`; // syntax: [;;;m 46 | if(t2 !== undefined) 47 | color += `;${t2}`; 48 | color += `;${fg}m`; 49 | text = `${esc}${color}${text}${esc}[0m`; 50 | } 51 | 52 | return text; 53 | } 54 | 55 | function pad(text, start, end, pad = ' ') 56 | { 57 | if(start !== undefined && end !== undefined) 58 | text = pad.repeat(start) + text + pad.repeat(end); 59 | 60 | return text; 61 | } 62 | 63 | setInterval((async () => { 64 | try { //const ignoresrvs = ['176.31.252.60:*', '157.90.181.156:9120']; 65 | const codpm = await fetch(CODPM_API, {"headers": {"Accept": "application/json"}}); 66 | if(!codpm.ok || codpm.status != 200) { 67 | console.error(`cod.pm server error: ${codpm.status} - ${codpm.statusText}`); 68 | return; 69 | } 70 | 71 | const rawj = await codpm.json(); 72 | if(rawj.masterlist_updated === updated) 73 | return; 74 | 75 | updated = rawj.masterlist_updated; 76 | let [globalplayers, globalservers, totalservers, mhlength, mplength, mglength, mmlength, mclength] = new Array(8).fill(0); 77 | const [hlength, plength, glength, mlength, clength] = [30, 5, 5, 16, 21]; 78 | const parsedsrvs = new Array(); 79 | 80 | for(let i = 0; i < rawj.servers.length; i++) { 81 | const server = rawj.servers[i]; 82 | 83 | if(server.hidden > 0 || server.sv_maxclients > 72) // ignoresrvs.some((element) => element.match(new RegExp(`^${server.ip}:(?:\\*|${server.port})$`))) 84 | continue; 85 | 86 | totalservers++; 87 | const players = server.playerinfo.length - server.bots; 88 | 89 | if(players > 0) { 90 | globalplayers += players; 91 | globalservers++; 92 | } 93 | 94 | if(totalservers <= 10 && (CONFIG_SHOW_EMPTY || players > 0)) { 95 | let sv_hostname = server.sv_hostname.replace( 96 | CONFIG_UNICODE_SV_HOSTNAME 97 | ? /^[\x00-\x20\x7F]+|[\x00-\x20\x7F]+$|([\x00-\x20\x7F]{2,})/g 98 | : /^[^\x21-\x7E]+|[^\x21-\x7E]+$|([^\x21-\x7E]{2,})/g 99 | , (m, c) => (c && c.includes(' ') ? ' ' : '')); 100 | sv_hostname = truncate(monotone(sv_hostname), hlength).replace(/(discord)|`/ig, (m, c) => c ? 'disсord' : '`'); 101 | sv_hostname = !sv_hostname && CONFIG_RENAME_EMPTY ? CONFIG_RENAME_EMPTY : sv_hostname; 102 | const mapname = monotone(server.mapname).toLowerCase(); 103 | 104 | const current = { 105 | "ip": server.ip, 106 | "port": server.port, 107 | "sv_hostname": sv_hostname, 108 | "clients": players, 109 | "sv_maxclients": server.sv_maxclients, 110 | "g_gametype": truncate(monotone(server.g_gametype), glength, '>').toLowerCase(), 111 | "mapname": truncate(mapname.replace(/^[a-z]+_/, ''), mlength, '>'), 112 | "connect": `${server.ip}:${server.port}`, 113 | "country_isocode": server.country_isocode, 114 | "mapimage": `https://cod.pm/mp_maps/${server.mapimage}` 115 | }; 116 | 117 | parsedsrvs.push(current); 118 | if(current.sv_hostname.length > mhlength) 119 | mhlength = current.sv_hostname.length; 120 | 121 | const maxclients = `${server.clients}/${server.sv_maxclients}`; 122 | if(maxclients.length > mplength) 123 | mplength = maxclients.length; 124 | 125 | if(current.g_gametype.length > mglength) 126 | mglength = current.g_gametype.length; 127 | if(current.mapname.length > mmlength) 128 | mmlength = current.mapname.length; 129 | if(current.connect.length > mclength) 130 | mclength = current.connect.length; 131 | } 132 | } 133 | 134 | const payload_json = {"embeds": new Array()}; 135 | let message = ''; 136 | 137 | if(parsedsrvs.length > 0) { 138 | mhlength = Math.abs(hlength - mhlength); 139 | mplength = Math.abs(plength - mplength); 140 | mglength = Math.abs(glength - mglength); 141 | mmlength = Math.abs(mlength - mmlength); 142 | mclength = Math.abs(clength - mclength); 143 | 144 | const offset = DISCORD_TABLELAYOUT === 'unicode' ? 0 : 2; 145 | const hwidth = 37 - offset - mhlength; // (hlength + 4 + 3) - offset - mhlength 146 | const dwidth = 30 - offset - mplength - mglength - mmlength; 147 | const cwidth = 32 - offset - mclength; 148 | 149 | message += '```ansi\n'; 150 | if(DISCORD_TABLELAYOUT === 'unicode') { 151 | message += `╔${'═'.repeat(hwidth)}╦${'═'.repeat(dwidth)}╦${'═'.repeat(cwidth)}╗\n`; 152 | message += `║${pad(ansicolorize('Server Name', 35), Math.floor((hwidth - 11) / 2), Math.ceil((hwidth - 11) / 2))}` 153 | + `║${pad(ansicolorize('Details', 35), Math.floor((dwidth - 7) / 2), Math.ceil((dwidth - 7) / 2))}` 154 | + `║${pad(ansicolorize('Connect', 35), Math.floor((cwidth - 7) / 2), Math.ceil((cwidth - 7) / 2))}║\n`; 155 | message += `╟${'─'.repeat(hwidth)}╫${'─'.repeat(dwidth)}╫${'─'.repeat(cwidth)}╢\n`; 156 | } else { 157 | message += `${ansicolorize(pad('Server Name', Math.floor((hwidth - 11) / 2), Math.ceil((hwidth - 11) / 2)), 37, 42, 1)}` 158 | + ` ${ansicolorize(pad('Details', Math.floor((dwidth - 7) / 2), Math.ceil((dwidth - 7) / 2)), 37, 42, 1)}` 159 | + ` ${ansicolorize(pad('Connect', Math.floor((cwidth - 7) / 2), Math.ceil((cwidth - 7) / 2)), 37, 42, 1)}\n`; 160 | } 161 | 162 | for(let i = 0; i < parsedsrvs.length; i++) { 163 | const server = parsedsrvs[i]; 164 | const maxclients = `${server.clients}/${server.sv_maxclients}`; 165 | 166 | if(DISCORD_TABLELAYOUT === 'unicode') { 167 | message += `║ [${ansicolorize(server.country_isocode, 32)}] ${server.sv_hostname.padEnd(hlength - mhlength)}` 168 | + ` ║ ${maxclients.padStart(plength - mplength)} ${server.g_gametype.padEnd(glength - mglength)} ${server.mapname.padEnd(mlength - mmlength)}` 169 | + ` ║ /connect ${server.connect.padEnd(clength - mclength)} ║\n`; 170 | } else { 171 | message += `[${ansicolorize(server.country_isocode, 32)}] ${server.sv_hostname.padEnd(hlength - mhlength)}` 172 | + ` ${maxclients.padStart(plength - mplength)} ${server.g_gametype.padEnd(glength - mglength)} ${server.mapname.padEnd(mlength - mmlength)}` 173 | + ` /connect ${server.connect.padEnd(clength - mclength)}\n`; 174 | } 175 | 176 | const servernum = i + 1; 177 | if(!DISCORD_MAXEMBEDS || servernum > DISCORD_MAXEMBEDS || server.clients < 1) 178 | continue; // only up to DISCORD_MAXEMBEDS 179 | 180 | payload_json.embeds.push({ 181 | "type": "rich", 182 | "title": `\`#${servernum} ${truncate(server.sv_hostname, 22).padEnd(22)}\``, 183 | "color": 32768, 184 | "fields": [{ 185 | "name": "Players", 186 | "value": maxclients, 187 | "inline": true 188 | }, { 189 | "name": "Map (Gametype)", 190 | "value": `${truncate(server.mapname, 15 - server.g_gametype.length - 3, '>')} (${server.g_gametype})`, 191 | "inline": true 192 | }, { 193 | "name": "Details", 194 | "value": `:flag_${server.country_isocode}: [:link:](https://cod.pm/server/${server.ip}/${server.port})`, 195 | "inline": true 196 | }], 197 | "thumbnail": { 198 | "url": server.mapimage 199 | } 200 | }); 201 | } 202 | 203 | if(DISCORD_TABLELAYOUT === 'unicode') 204 | message += `╚${'═'.repeat(hwidth)}╩${'═'.repeat(dwidth)}╩${'═'.repeat(cwidth)}╝\n`; 205 | message += '```\n'; 206 | } 207 | 208 | const timenow = Date.now() / 1000; // Milliseconds to seconds 209 | const timestamp = Math.floor(timenow - (timenow - rawj.masterlist_updated)); 210 | 211 | message += `> **There ${globalplayers != 1 ? 'are' : 'is'} currently ${globalplayers} player${globalplayers != 1 ? 's' : ''}` 212 | + ` on ${globalservers} of ${totalservers} server${totalservers != 1 ? 's' : ''}` 213 | + ` on [${CODPM_GAME === 'cod' ? 'COD1' : CODPM_GAME.toUpperCase()} v${CODPM_VERSION}](https://cod.pm/${CODPM_GAME}/${CODPM_VERSION})`; 214 | if(DISCORD_MAXEMBEDS && parsedsrvs.length > 0) 215 | message += `\n> Below ${globalservers === 1 ? 'is the' : 'are the top'} ${globalservers < DISCORD_MAXEMBEDS ? (globalservers === 1 ? 'only' : globalservers) : DISCORD_MAXEMBEDS} currently active server${totalservers != 1 ? 's' : ''}.`; 216 | message += `\n> Updated: **`; 217 | payload_json.content = message; 218 | 219 | try { 220 | const form = new FormData(); 221 | form.append('payload_json', JSON.stringify(payload_json)); 222 | 223 | const discord = await fetch(DISCORD_WEBHOOK, { 224 | "method": /\/messages\/\d+$/.test(DISCORD_WEBHOOK) ? "PATCH" : "POST", 225 | "body": form 226 | }); 227 | 228 | if(!discord.ok) { 229 | console.error(`Server error: ${discord.status} - ${discord.statusText}`); 230 | console.error(await discord.json()); 231 | updated = 0; 232 | } 233 | } catch(err) { 234 | console.error(`Unable to fetch ${DISCORD_WEBHOOK}:`, err); 235 | } 236 | } catch(err) { 237 | console.error(`Unable to fetch ${CODPM_API}:`, err); 238 | } 239 | }), 20 * 1000); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CODPM-API-Discord 2 | 3 | A simple JavaScript script to parse the [cod.pm](https://api.cod.pm) API and display formatted text in a Discord channel via the Discord [webhook](https://discord.com/developers/docs/resources/webhook) API. 4 | 5 | # Screenshots 6 | 7 | ![ANSI sample](https://raw.githubusercontent.com/cato-a/CODPM-API-Discord/main/Sample-ANSI.png "ANSI colored masterlist") 8 | 9 |
10 | 11 | ![Bordered sample](https://raw.githubusercontent.com/cato-a/CODPM-API-Discord/main/Sample-Bordered.png "Unicode bordered masterlist") 12 | 13 | # Configuration 14 | 15 | The configuration variables are at the top of the JavaScript file. 16 | 17 | The `DISCORD_WEBHOOK` variable should be either of the following formats. 18 | 19 | ```plaintext 20 | https://discord.com/api/webhooks/{webhook.id}/{webhook.token} 21 | https://discord.com/api/webhooks/{webhook.id}/{webhook.token}/messages/{message.id} 22 | ``` 23 | 24 | # Running the script 25 | 26 | **Option 1)** Via NodeJS directly. 27 | 28 | ```plaintext 29 | node CODPM-API-Discord.js 30 | ``` 31 | 32 | **Option 2)** Via a NodeJS Docker container. 33 | 34 | ```plaintext 35 | docker run --detach --name CODPM-API-Discord -e 'NODE_ENV=production' -v "$PWD/CODPM-API-Discord.js":/usr/src/app/run.js -w /usr/src/app node:current-alpine node run.js 36 | ``` -------------------------------------------------------------------------------- /Sample-ANSI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cato-a/CODPM-API-Discord/e586c7ca228804d3d55b1f00fda144ef6d68147d/Sample-ANSI.png -------------------------------------------------------------------------------- /Sample-Bordered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cato-a/CODPM-API-Discord/e586c7ca228804d3d55b1f00fda144ef6d68147d/Sample-Bordered.png --------------------------------------------------------------------------------