From 0d05dd088a0d77256ced32485592aeab54af4a63 Mon Sep 17 00:00:00 2001 From: Milo Date: Tue, 29 Jul 2025 15:18:43 +0200 Subject: [PATCH] refactoring first steps --- commands.js | 123 +- flopobot.db-shm | Bin 0 -> 32768 bytes flopobot.db-wal | Bin 0 -> 65952 bytes index.js | 5188 +------------------------ old_index.js | 5167 ++++++++++++++++++++++++ src/api/discord.js | 60 + src/api/valorant.js | 11 + src/bot/client.js | 28 + src/bot/commands/floposite.js | 55 + src/bot/commands/info.js | 71 + src/bot/commands/inventory.js | 131 + src/bot/commands/search.js | 122 + src/bot/commands/skins.js | 68 + src/bot/commands/timeout.js | 212 + src/bot/commands/valorant.js | 188 + src/bot/components/inventoryNav.js | 151 + src/bot/components/pollVote.js | 176 + src/bot/components/searchNav.js | 121 + src/bot/components/upgradeSkin.js | 196 + src/bot/events.js | 56 + src/bot/handlers/interactionCreate.js | 89 + src/bot/handlers/messageCreate.js | 191 + src/config/commands.js | 113 + src/database/index.js | 185 + src/game/elo.js | 144 + src/game/points.js | 191 + src/game/poker.js | 139 + src/game/solitaire.js | 246 ++ src/game/state.js | 68 + src/game/various.js | 109 + src/server/app.js | 51 + src/server/routes/api.js | 250 ++ src/server/routes/poker.js | 316 ++ src/server/routes/solitaire.js | 221 ++ src/server/socket.js | 258 ++ src/utils/ai.js | 82 + src/utils/index.js | 254 ++ 37 files changed, 9759 insertions(+), 5272 deletions(-) create mode 100644 flopobot.db-shm create mode 100644 flopobot.db-wal create mode 100644 old_index.js create mode 100644 src/api/discord.js create mode 100644 src/api/valorant.js create mode 100644 src/bot/client.js create mode 100644 src/bot/commands/floposite.js create mode 100644 src/bot/commands/info.js create mode 100644 src/bot/commands/inventory.js create mode 100644 src/bot/commands/search.js create mode 100644 src/bot/commands/skins.js create mode 100644 src/bot/commands/timeout.js create mode 100644 src/bot/commands/valorant.js create mode 100644 src/bot/components/inventoryNav.js create mode 100644 src/bot/components/pollVote.js create mode 100644 src/bot/components/searchNav.js create mode 100644 src/bot/components/upgradeSkin.js create mode 100644 src/bot/events.js create mode 100644 src/bot/handlers/interactionCreate.js create mode 100644 src/bot/handlers/messageCreate.js create mode 100644 src/config/commands.js create mode 100644 src/database/index.js create mode 100644 src/game/elo.js create mode 100644 src/game/points.js create mode 100644 src/game/poker.js create mode 100644 src/game/solitaire.js create mode 100644 src/game/state.js create mode 100644 src/game/various.js create mode 100644 src/server/app.js create mode 100644 src/server/routes/api.js create mode 100644 src/server/routes/poker.js create mode 100644 src/server/routes/solitaire.js create mode 100644 src/server/socket.js create mode 100644 src/utils/ai.js create mode 100644 src/utils/index.js diff --git a/commands.js b/commands.js index 32bdb7b..869e87d 100644 --- a/commands.js +++ b/commands.js @@ -1,120 +1,5 @@ -import 'dotenv/config'; -import { getTimesChoices } from './game.js'; -import { capitalize, InstallGlobalCommands } from './utils.js'; +import { registerCommands } from './src/config/commands.js'; -function createTimesChoices() { - const choices = getTimesChoices(); - const commandChoices = []; - - for (let choice of choices) { - commandChoices.push({ - name: capitalize(choice.name), - value: choice.value?.toString(), - }); - } - - return commandChoices; -} - -// Simple test command -const TEST_COMMAND = { - name: 'test', - description: 'Basic command', - type: 1, - integration_types: [0, 1], - contexts: [0, 1, 2], -}; - -// Timeout vote command -const TIMEOUT_COMMAND = { - name: 'timeout', - description: 'Vote démocratique pour timeout un boug', - options: [ - { - type: 6, - name: 'akhy', - description: 'Qui ?', - required: true, - }, - { - type: 3, - name: 'temps', - description: 'Combien de temps ?', - required: true, - choices: createTimesChoices(), - } - ], - type: 1, - integration_types: [0, 1], - contexts: [0, 2], -} - -// Valorant -const VALORANT_COMMAND = { - name: 'valorant', - description: `Ouvrir une caisse valorant (15€)`, - type: 1, - integration_types: [0, 1], - contexts: [0, 2], -} - -// Own inventory command -const INVENTORY_COMMAND = { - name: 'inventory', - description: 'Voir inventaire', - options: [ - { - type: 6, - name: 'akhy', - description: 'Qui ?', - required: false, - }, - ], - type: 1, - integration_types: [0, 1], - contexts: [0, 2], -} - -const INFO_COMMAND = { - name: 'info', - description: 'Qui est time out ?', - type: 1, - integration_types: [0, 1], - contexts: [0, 2], -} - -const SKINS_COMMAND = { - name: 'skins', - description: 'Le top 10 des skins les plus chers.', - type: 1, - integration_types: [0, 1], - contexts: [0, 2], -} - -const SITE_COMMAND = { - name: 'floposite', - description: 'Lien vers FlopoSite', - type: 1, - integration_types: [0, 1], - contexts: [0, 2], -} - -const SEARCH_SKIN_COMMAND = { - name: 'search', - description: 'Chercher un skin', - options: [ - { - type: 3, - name: 'recherche', - description: 'Tu cherches quoi ?', - required: true, - }, - ], - type: 1, - integration_types: [0, 1], - contexts: [0, 2], -} - -const ALL_COMMANDS = [TIMEOUT_COMMAND, INVENTORY_COMMAND, VALORANT_COMMAND, INFO_COMMAND, SKINS_COMMAND, SEARCH_SKIN_COMMAND, SITE_COMMAND]; - -InstallGlobalCommands(process.env.APP_ID, ALL_COMMANDS); +console.log('Registering global commands...'); +registerCommands(); +console.log('Commands registered.'); \ No newline at end of file diff --git a/flopobot.db-shm b/flopobot.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..db6d44fd15c9fe84d9c33fdb9799fd450b836867 GIT binary patch literal 32768 zcmeI*KS~2p5C`CiMx(|*DN_iZz)I|ew5dFSVq*~t!86#XrAP1*fwblT-oOh4ynxON zTUdx}x-9$sV0g>!@R;`<;O+49G)lIzIuV0Gt`AxB%a^D3`Pga!cWWfD{sQF=m3ryLUcE+L1PBlyK!5-N0t5&&R$#NS zlN+1>0RjXF5FoIsK)J742oNCfpFp{{UI?^P;IN&?c4`6y2(((DoBs^jiGJ+2TEXE7 e5FkK+009C72oNAZfB*pk1PBlyK!CtH3j6>Ip)KqH literal 0 HcmV?d00001 diff --git a/flopobot.db-wal b/flopobot.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..9cf605d26d6767c264d93145c2e42a0dc2f63142 GIT binary patch literal 65952 zcmeHQ3v^snx!yCAdB0j|O)U+b(v~*b!g)WiNk38K4YM1}FoR0m=Yn zfHFWCK%&df*Vg#p6?jpR1%_8xjuBP9bD*bZxP5OjlSvPBrwZSaD^RH8w_VBg z+P_@n2(UF6%i;7(BE$2F%5fNuWhFuB$fFd#Yc|v^Md(7O59&DJB}<~nFcK#)3@0cY zt0+C`p7hAB^q#_Z{%~%?-vzrs-=^JftnwWDRv_|Sw61CiitdSgHgYVoI}(q6FZ#LY zP0@zvg2?|xV&Ol8UkyJQJ`&y;zBF7D`d#STp~pjqLY<*YLUV(^3BD41EO;Q846X=P z2i^(16nHc+9=Ix?24?x+_J7^~u>V^B7Qf`5>HC@Q1>Xa{{k}~;-WT!y)cd^mUhjyv z&C7U$o;N*T_MGwzdRjfpJwEq;xu134>CU>F-5++ltA1GZ#j4w@`l{AI6X}aGKp8kY z3@mc3b(Ahm%UN0EWmRB#UJ&@rBq5e(1eRmjO(Y>kmQ`5tn2iJ>NmV6Q;#776S%`;a zQ5D7>X4jF01X)%Xp@k^KGmPf~3_JLafZH!WCp8UX>L|Y$OZ8nNF4Y z%gI8lBFKVp8Bqx6mQ;~xAPdP73r9F^A;e0eEU&T>;-GgcErb|_6~#-fgm{=z^<*JQ z%3cjtj^_qN=D0zt~Dhlo)xDg%Hod zXjy0>1Pln=`D7tplo>%;U?Bukh2vrtLYyjz(tIl+MwGepEQDB@VdNSsA(rLUd1N72 zR$&!8mmtJTip)!}rI||>lHdwcl|Mul5+yiv${ex~96FAbsx5>VhL?p8l7(PZWd&un zg^sGKuB7+m7Rwoic#{v12mu2kFG1V!V*!2eo_Jp1@ZmF0@7#37G5o_^-`7y|pQ8QIk3@bPxjV8U z5)J=rI2XP+^y|>4L#fbt!PkPvgN;FF;ERE5V7dQI|GoZAaIOC}s7hay0m=YnfHFWC zpbSt3&K?5|o4sq<1<2tv-waNgFH3)~Z%Z_{uWW8?S|8X@7vBVWYLIhUJp~JAsh;)C ziB;_z8=G7|*&xL?f|~h+<`it8rD||<^wzXC#W#@EOjt>YYF5SDEY;|CRHB-T;|WVO zx}}wSi`HJUY_sAgZh-i8`%?3 zb_cvC>;m`w=c$b2;)b>1=+odwaLN&VGx}2W)Y&_&Q)J2jWq>k38K4YM1}FoR0m=Yn zfHFWCpbWfU8K`!-SZAp_0J{jO=&W{trcwg|`uqT>DzXc_ck38K4YM1}FoR0m{JN3Ip%GUEn!v7wE?QEyNdi zC0_T=C(g)^1;Pom4!wlJ=Z1b1`iIb7*Y&Q|u9?o)oOe4roVAX(9Zx$(9rX?`dMWzV z=#gk^bWUh>=z@?V_#eT~2X7Ac1TPOR4E!nZ-N0u9#{#@n@FBy)O>KbrLhQAB{)XGZ&G%PoR%9gfDJ)fLEi_08vVyL+)Fntfy3Se`FQPGP zT`-Pw(|bVWThFW9HCU+BXliIYxSV~`T9cscv)055qt==jcGyxArzk^~nmCEM+FFw! z4OnYpm5j9}M(nrL#LDtsOHHiAr>!;dVvn^Zj_J14#3=G^3r(U3F@U-(G+{SKey61- zQIL07YhuMtOHJU1-C?Oo5X7XlCQjaFsfkyVt1LCa^Ws~rHL>y*OHG_AUumg{6ZuWn zns`|YDMFa$BKWE5fk22tjLK-?J4iwhX@J#&ZcHx(e*F@|YN0Zw7XrV2R*|#-7t;%Y zr?#rfT3Cwdgfzc?kyi=9B(&uaD3E1Bz?6azBNM`$~gLOA|` z%n|~DU?I#CBN4)PU?Cj;KxGL*Hz0177XLuu386Bu5DrKnv4kiXAf&|_;1xoE3rz?j zb}=#`b_EuKGqJ)-BwuAL1hEF-bWEBpAOx`nAQ}o;hzD2%fN7wj(im>|43Ax^Rq)|v_$3uognhrj zUQm8Te85y@ab*=nc@aX6=gqcT)Atd!yMiDstN>Bu;TWA?K}7*}hT?(>Dne)inT=IY z5ki0n()zWF7;&&uuBoVEd0TRPaV3fzobS<{<<;ciT1SQJB$YuY%C8BL zksy){y1m?*5JyJlS%pQn!KHH#yeIq!R>YqF#>45Qzr^tc7NOTr^x^25$lH-yBVzam z;e+AY(2Jp~Lmv!26Wkv31)d13_y69160YL!^)2u|=)J&m+H;}%DY$C?LeX23X$jb2^-kg>L(^bn*z7)-ka!pwD-r=QrT*X6ZjFM=VrB$v%Pj;fyE1R2| z;wuw^B(34jgu>01X8P0uhlJ;`3^+9eA z`{_>ryaBuSr?KJO>`Ji<)5IS3Q`^wfpP0lBkMI8DaBf!R*t%`X9`|sB?3q93g!0wyTJe-?RIks-2vxj}U2R**afSrD}bvPHT7&~v}vxogiKRVrG z!0!3|(cxUAa%|mBXb<~=LG(z!0eklyL&Ldn<=DEV(H{0a`_Kb}2JBtTypdcekL@gX zO_f00a7t>&_j` z1u93^O{VtHZ$^jV3CRNn^zB!$qd9-Y=y|iNJ@ljKCIl@opeJ_C9nJYFM4vF-+Cx8x zP9SK(1p0~Jzx?NYTXNpeS{C_`)9XYF-P4&B3g+L^-_fbMWQ)EZyS=4(b-cB`wYjZn zmE**ZzjbtMW6l$5=CO7s)^0K$45n9XA}HODpu5ox=)_db16StUxZQr_(Ar(ffKf3H zFgi8T_E25NalP3Grz+pkmaEd0>&-6hv{AXlm;MesA4jLA>fN|8=PH8^uymNv@gTY$ z-91%vqocb9JU9C0730af|H|L>XLhPmXA)= z{*kRYgxT=thn`7ijg4l^Hmm5iiNW{aRQ)xHYx0e^rUY&clv0Mq(5?HYYMrs=leQ^= zdkH3#+*!Bn6`hh>rYd)A-Jc&H+Mp&g!b_&pg&L1wu!gFmeMrg6^EE_SLN;WpKfoj*k25w$0GFI5}1Q zbDM|tPJBR2&f+c^&_Bu1d!SB{p3e*L+MT1={DKeSAzNxnA|+adrFO*%KZq+yCB5RiYubT z@hZBj{F;9segxm2_rK44`QPq~VLyV!XwzTLk6@F2y}mCuH~$D;1rPG;2d8vunG7Cy ziXVNmv!$(Z{rdW*mGKxnOuu2>S1zJ{1eHA7U2t`=$2|2T(0n(j9|83vD0viix=jGY zG~uWp0T0en9Qe~Rkc-TS0()TA;kq6|<5CdO{5P*-nfYADm!4f~Mx~?nY-75z;ZZDz=mrOEcKAZ3evzSTA zj%-)DSK}?;rey!lRJPqlRc$P^CwtNZz1d=_w91&6;vaF}!VYvUUem@GPpluFut!^8 za}D}3GX~?Zu-uibS$Hl^b{nRD=^CQdP7btm!IEj{*bZoCXA<{z>MxmWx?>M6G)NC7 zGuhOXvaZakbZ@F;{_RM1XHv^znG}qCd>m?F0xb!7sy{lLxVUJ`N;>i~jHGuA^!Gyp*IeCQ7*7-H z*yPff7h1WiKi!kGmeVov=Lr*aJ;|ZzMevBNt@2cVta{o*iH{MSBJh_xnnd^@CjHv< z&FMoN7L5#kcN85+TrxS14GUe&d~Gu8L!q8SE7JqJw{w|NSqsk#|PUdl%SRqz-%7kA%^qfg?9 z-x-07o`r;>Wn)9CYThI-i&?*KTZHa_-QVQ=cK6;mqGj~dlbKe44M~pe_50?f=(a`0 z*gHlH89mF!=6FVw>|q~UhHhS3ggx_#5kp4LO0XgOxvjBwgh9ua6=RQ%7&3a6i;WXB zLAFC%*oOpkgfU_}27WPO$mm%vHY_agpFQjY3OXbhus`+3`|cdkGI~}v)&QF|OHO4r z&c|1v14uGkQ88)=&BUhOEr?=Jz%AXnaL6^x~m>LQe<0Du9-4Qa;*nO??sa zJ@rHRbe%MvHpJ8cZEOK&zJ z-}}}%LvDR@Q~}$fr~Dns)|Z)&Ao9>z&PYJ>5e%3-6=EIh z3>_~o%qB!h%V#`C$I7p%=PO4ymS0oPHja)~P*cklj*gUHQ&04b4wqk(<|Cl_2o#Q0 zj7b@2J_4GLKosGMx?_=Rt)q1NKM}SIgak2(g-Vkp8-+M|o24dRQLeJo1bdgQ)|yy( zi=`$`m9MnaMDr2Qd;~Nf0nJD7KFdeYVz%THZeW6SN{|*Rt^w0?1)-wID?EFC1r-Gm zZfY#3prUrKHC91I?MCVR3M!VjCC6C>REA|_1)VIv=KC-o0rm;V7M>S4e6;n2cOHJ> zt2iHlBbr0-KYdXKC { - res.header('Access-Control-Allow-Origin', FLAPI_URL); - res.header('Access-Control-Allow-Headers', 'Content-type, X-API-Key, ngrok-skip-browser-warning'); - next(); -}); -// To keep track of our active games -const activeGames = {}; -const activeSolitaireGames = {}; -const activePolls = {}; -const activeInventories = {}; -const activeSearchs = {}; -const activeSlowmodes = {}; -const activePredis = {}; -let todaysSOTD = {}; -const SPAM_INTERVAL = process.env.SPAM_INTERVAL - -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, // For guild events - GatewayIntentBits.GuildMessages, // For messages in guilds - GatewayIntentBits.MessageContent, // For reading message content (privileged intent) - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildPresences, - ] -}); - -const requestTimestamps = new Map(); // userId => [timestamp1, timestamp2, ...] -const MAX_REQUESTS_PER_INTERVAL = parseInt(process.env.MAX_REQUESTS || "5"); - -const akhysData= new Map() -export const skins = [] - -async function getAkhys() { - try { - stmtUsers.run(); - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const members = await guild.members.fetch(); // Fetch all members - - const akhys = members.filter(m => !m.user.bot && m.roles.cache.has(process.env.AKHY_ROLE_ID)); - - akhys.forEach(akhy => { - akhysData.set(akhy.user.id, { - id: akhy.user.id, - username: akhy.user.username, - globalName: akhy.user.globalName, - warned: false, - warns: 0, - allTimeWarns: 0, - totalRequests: 0, - }); - insertManyUsers([ - { - id: akhy.user.id, - username: akhy.user.username, - globalName: akhy.user.globalName, - warned: 0, - warns: 0, - allTimeWarns: 0, - totalRequests: 0 - }, - ]); - }); - } catch (err) { - console.error('Error while counting akhys:', err); - } - try { - stmtSkins.run(); - - const fetchedSkins = await getValorantSkins() - const fetchedTiers = await getSkinTiers() - - fetchedSkins.forEach((skin) => { - const chromas = [] - const levels = [] - skin.chromas.forEach((chroma) => { - chromas.push({ - uuid: chroma.uuid, - displayName: chroma.displayName, - displayIcon: chroma.displayIcon, - fullRender: chroma.fullRender, - swatch: chroma.swatch, - streamedVideo: chroma.streamedVideo, - }) - }) - skin.levels.forEach((level) => { - levels.push({ - uuid: level.uuid, - displayName: level.displayName, - displayIcon: level.displayIcon, - streamedVideo: level.streamedVideo, - }) - }) - skins.push({ - uuid: skin.uuid, - displayName: skin.displayName, - contentTierUuid: skin.contentTierUuid, - displayIcon: skin.displayIcon, - chromas: chromas, - levels: levels, - }) - }) - - let newSkinCount = 0; - let newSkinText = ''; - for (const skin of skins) { - try { - if (skin.contentTierUuid !== null) { - const tierRank = () => { - const tier = fetchedTiers.filter((tier) => { return tier.uuid === skin.contentTierUuid})[0] - const rank = tier ? tier['rank'] : null; - return rank ? rank + 1 : 0; - } - const tierColor = () => { - const tier = fetchedTiers.filter((tier) => { return tier.uuid === skin.contentTierUuid})[0] - return tier ? tier['highlightColor']?.slice(0, 6) : 'F2F3F3' - } - const tierText = () => { - const tier = fetchedTiers.filter((tier) => { return tier.uuid === skin.contentTierUuid})[0] - const rank = tier ? tier['rank'] : null; - let res; - if (rank === null) return 'Pas de tier'; - switch(rank) { - case 0: - res = '**<:select:1362964319498670222> Select**' - break - case 1: - res = '**<:deluxe:1362964308094488797> Deluxe**' - break - case 2: - res = '**<:premium:1362964330349330703> Premium**' - break - case 3: - res = '**<:exclusive:1362964427556651098> Exclusive**' - break - case 4: - res = '**<:ultra:1362964339685986314> Ultra**' - break - default: - return 'Pas de tier' - } - res += skin.displayName.includes('VCT') ? ' | Esports Edition' : '' - res += skin.displayName.toLowerCase().includes('champions') ? ' | Champions' : '' - res += skin.displayName.toLowerCase().includes('arcane') ? ' | Arcane' : '' - return res - } - const basePrice = () => { - let res; - if (skin.displayName.toLowerCase().includes('classic')){ - res = 150; - } else if (skin.displayName.toLowerCase().includes('shorty')) { - res = 300; - } else if (skin.displayName.toLowerCase().includes('frenzy')) { - res = 450; - } else if (skin.displayName.toLowerCase().includes('ghost')) { - res = 500; - } else if (skin.displayName.toLowerCase().includes('sheriff')) { - res = 800; - } else if (skin.displayName.toLowerCase().includes('stinger')) { - res = 1100; - } else if (skin.displayName.toLowerCase().includes('spectre')) { - res = 1600; - } else if (skin.displayName.toLowerCase().includes('bucky')) { - res = 850; - } else if (skin.displayName.toLowerCase().includes('judge')) { - res = 1850; - } else if (skin.displayName.toLowerCase().includes('bulldog')) { - res = 2050; - } else if (skin.displayName.toLowerCase().includes('guardian')) { - res = 2250; - } else if (skin.displayName.toLowerCase().includes('phantom')) { - res = 2900; - } else if (skin.displayName.toLowerCase().includes('vandal')) { - res = 2900; - } else if (skin.displayName.toLowerCase().includes('marshal')) { - res = 950; - } else if (skin.displayName.toLowerCase().includes('outlaw')) { - res = 2400; - } else if (skin.displayName.toLowerCase().includes('operator')) { - res = 4700; - } else if (skin.displayName.toLowerCase().includes('ares')) { - res = 1600; - } else if (skin.displayName.toLowerCase().includes('odin')) { - res = 3200; - } else { - res = 6000; - } - - res *= (1 + (tierRank())) - res *= skin.displayName.includes('VCT') ? 1.25 : 1; - res *= skin.displayName.toLowerCase().includes('champions') ? 2 : 1; - res *= skin.displayName.toLowerCase().includes('arcane') ? 1.5 : 1; - res *= 1+(Math.random()/100) // [1 to 1.01] - - return (res/1111).toFixed(2); - } - - const skinBasePrice = basePrice(); - - const maxPrice = (price) => { - let res = price - - res *= (1 + (skin.levels.length / Math.max(skin.levels.length, 2))) - res *= (1 + (skin.chromas.length / 4)) - - return res.toFixed(2); - } - - await insertSkin.run( - { - uuid: skin.uuid, - displayName: skin.displayName, - contentTierUuid: skin.contentTierUuid, - displayIcon: skin.displayIcon, - user_id: null, - tierRank: tierRank(), - tierColor: tierColor(), - tierText: tierText(), - basePrice: skinBasePrice, - currentLvl: null, - currentChroma: null, - currentPrice: null, - maxPrice: maxPrice(skinBasePrice), - }); - newSkinCount++; - newSkinText += skin.displayName + ' | '; - } - } catch (e) { - // - } - } - console.log(`New skins : ${newSkinCount}`); - if (newSkinCount <= 30 && newSkinCount > 0) console.log(newSkinText); - } catch (e) { - console.error('Error while fetching skins:', e); - } - try { - stmtLogs.run() - } catch (e) { - console.log('Logs table init error') - } -} - -async function getOnlineUsersWithRole(guild_id=process.env.GUILD_ID, role_id=process.env.VOTING_ROLE_ID) { - try { - const guild = await client.guilds.fetch(guild_id); - const members = await guild.members.fetch(); // Fetch all members - - const online = members.filter(m => !m.user.bot && m.presence?.status && m.roles.cache.has(role_id)); - return online - } catch (err) { - console.error('Error while counting online members:', err); - } -} - -// Login to Discord using bot token (optional) -client.login(process.env.BOT_TOKEN).then(r => console.log('')); - -// Listen for message events -client.on('messageCreate', async (message) => { - // Ignore messages from bots to avoid feedback loops - if (message.author.bot) return; - - // hihihiha - if (message.author.id === process.env.PATA_ID) { - if (message.content.startsWith('feur') - || message.content.startsWith('rati')) { - await sleep(1000) - await message.delete() - } - } - - // coins mechanic and slowmodes check - if (message.guildId === process.env.GUILD_ID) { - channelPointsHandler(message) - io.emit('data-updated', { table: 'users', action: 'update' }); - const deletedSlowmode = await slowmodesHandler(message, activeSlowmodes) - if (deletedSlowmode) io.emit('new-slowmode', { action: 'deleted slowmode' }); - } - - if (message.content.toLowerCase().startsWith(`<@${process.env.APP_ID}>`) || message.mentions.repliedUser?.id === process.env.APP_ID) { - let startTime = Date.now() - let akhyAuthor = await getUser.get(message.author.id) - - const now = Date.now(); - const timestamps = requestTimestamps.get(message.author.id) || []; - - // Remove timestamps older than SPAM_INTERVAL seconds - const updatedTimestamps = timestamps.filter(ts => now - ts < SPAM_INTERVAL); - - if (updatedTimestamps.length >= MAX_REQUESTS_PER_INTERVAL) { - console.log(akhyAuthor.warned ? `${message.author.username} is restricted : ${updatedTimestamps}` : `Rate limit exceeded for ${message.author.username}`); - if (!akhyAuthor.warned) { - await message.reply(`T'abuses fréro, attends un peu ⏳`) - } else if (akhyAuthor.warns === Math.max(1, process.env.MAX_WARNS - 3)) { - await message.author.send("Attention si tu continues de spam tu vas te faire timeout 🤯") - } - await updateManyUsers([ - { - id: akhyAuthor.id, - username: akhyAuthor.username, - globalName: akhyAuthor.globalName, - warned: 1, // true - warns: akhyAuthor.warns + 1, - allTimeWarns: akhyAuthor.allTimeWarns + 1, - totalRequests: akhyAuthor.totalRequests - }, - ]) - akhyAuthor = await getUser.get(akhyAuthor.id) - if (akhyAuthor.warns > process.env.MAX_WARNS ?? 10) { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const time = parseInt(process.env.SPAM_TIMEOUT_TIME) - try { - await guild.members.edit(akhyAuthor.id, { - communication_disabled_until: new Date(Date.now() + time).toISOString(), - reason: 'Dose le spam fdp', - }); - } catch (e) { - console.log('Tried timeout for AI spam : ', e) - message.channel.send(`<@${akhyAuthor.id}> tu me fais chier !! T'as de la chance que je puisse pas te timeout 🔪`) - .catch(console.error); - return - } - message.channel.send(`Ce bouffon de <@${akhyAuthor.id}> a été timeout pendant ${formatTime(time/1000)}, il me cassait les couilles 🤫`) - .catch(console.error); - return - } - return; - } - - - // Track this new usage - updatedTimestamps.push(now); - requestTimestamps.set(akhyAuthor.id, updatedTimestamps); - await updateManyUsers([ - { - id: akhyAuthor.id, - username: akhyAuthor.username, - globalName: akhyAuthor.globalName, - warned: 0, // false - warns: 0, // reset - allTimeWarns: akhyAuthor.allTimeWarns, - totalRequests: akhyAuthor.totalRequests + 1 - }, - ]) - akhyAuthor = await getUser.get(akhyAuthor.id) - - try { - // Fetch last messages from the channel - const fetched = await message.channel.messages.fetch({ limit: 100 }); - const messagesArray = Array.from(fetched.values()).reverse(); // oldest to newest - - const requestMessage = message.content.replace(`<@${process.env.APP_ID}>`, '') - - // Map to OpenAI/Gemini format - console.log('AI fetch', process.env.MODEL) - const allAkhys = await getAllUsers.all() - let allAkhysText = '' - allAkhys.forEach(akhy => { - allAkhysText += `<@${akhy.id}> alias ${akhy.globalName}, ` - }) - let convo = 'Voici les derniers messages de la conversation pour contexte (du plus vieux au plus récent) :\n' - messagesArray.forEach(msg => { - convo += `<@${msg.author.id}> a dit : ${msg.content}.\n` - }) - let formatted = []; - if (process.env.MODEL === 'OpenAI' || process.env.MODEL === 'Gemini') { - formatted.push({ - role: 'developer', - content: `${convo}`, - }); - formatted.push({ - role: 'developer', - content: `Voici la liste des différents utilisateurs présents : ${allAkhysText}`, - }) - formatted.push({ - role: 'developer', - content: `Voici une liste de quelques emojis que tu peux utiliser sur le serveur: <:CAUGHT:1323810730155446322> quand tu te fais prendre la main dans le sac ou que tu a un avis divergent ou risqué, <:hinhinhin:1072510144933531758> pour le rire ou quand tu es moqueur, <:o7:1290773422451986533> pour payer respect ou remercier ou dire au revoir, <:zhok:1115221772623683686> pour quand quelquechose manque de sens, <:nice:1154049521110765759> pour quelquechose de bien, <:nerd:1087658195603951666> pour une explication technique ou une attitude nerd, <:peepSelfie:1072508131839594597> pour à peu près n\'importe quelle situation quand tu es blazé` - }) - - formatted.push( - { - role: "developer", - content: "Adopte une attitude détendue et répond comme si tu participais à la conversation, pas trop long, pas de retour à la ligne, simple et utilise les emojis du serveur. N'hésites pas à utiliser des abréviations mais sans en abuser." - }, - { - role: 'developer', - content: message.mentions.repliedUser?.id ? `La phrase de l'utilisateur répond à un message de ${message.mentions.repliedUser?.id === process.env.APP_ID ? 'toi-même' : message.mentions.repliedUser?.id}` : '', - }, - { - role: "developer", - content: `Ton id est : <@${process.env.APP_ID}>, évite de l'utiliser. Ton username et global_name sont : ${process.env.APP_NAME}` - }, - { - role: "developer", - content: `L'utilisateur qui s'adresse a toi est : <@${akhyAuthor.id}>` - }, - { - role: "user", - content: requestMessage.length > 1 ? requestMessage : 'Salut', - }); - } - else if (process.env.MODEL === 'Mistral') { - // Map to Mistral format - formatted.push({ - role: 'system', - content: `${convo}`, - }); - - formatted.push({ - role: 'system', - content: `Voici la liste des différents utilisateurs présents : ${allAkhysText}`, - }); - - formatted.push( - { - role: "system", - content: "Adopte une attitude détendue et répond comme si tu participais à la conversation, pas trop long, pas de retour à la ligne, simple. N'hésites pas à utiliser des abréviations mais sans en abuser." - }, - { - role: 'system', - content: message.mentions.repliedUser?.id ? `La phrase de l'utilisateur répond à un message de ${message.mentions.repliedUser?.id === process.env.APP_ID ? 'toi-même' : message.mentions.repliedUser?.id}` : '', - }, - - { - role: "system", - content: `Ton id est : <@${process.env.APP_ID}>, évite de l'utiliser. Ton username et global_name sont : ${process.env.APP_NAME}` - }, - { - role: "system", - content: `L'utilisateur qui s'adresse a toi est : <@${akhyAuthor.id}>` - }, - { - role: "user", - content: requestMessage.length > 1 ? requestMessage : 'Salut', - }); - } - - // await gork(formatted); IA en marche - const reply = await gork(formatted); - - // Send response to the channel - await message.reply(reply); - } catch (err) { - console.error("Error fetching or sending messages:", err); - await message.reply("Oups, y'a eu un problème!"); - } - } - else if (message.content.toLowerCase().includes("quoi")) { - let prob = Math.random() - console.log(`feur ${prob}`) - if (prob < process.env.FEUR_PROB) { - // Send a message "feur" to the same channel - message.channel.send(`feur`) - .catch(console.error); - } - } - else if (message.guildId === process.env.DEV_GUILD_ID) { - // ADMIN COMMANDS - if (message.content.toLowerCase().startsWith('?u')) { - console.log(await getAPOUsers()) - } - else if (message.content.toLowerCase().startsWith('?b')) { - const amount = message.content.replace('?b ', '') - console.log(amount) - console.log(await postAPOBuy('650338922874011648', amount)) - } - else if (message.content.toLowerCase().startsWith('?v')) { - console.log('active polls :') - console.log(activePolls) - } - else if (message.content.toLowerCase().startsWith('?sv')) { - const amount = parseInt(message.content.replace('?sv ', '')) - let sum = 0 - let start_at = Date.now() - for (let i = 0; i < amount; i++) { - sum += parseFloat(randomSkinPrice(i+1)) - if (i%10 === 0 || i === amount-1) console.log(`Avg Skin Cost : ~${(sum/i+1).toFixed(2)}€ (~${sum.toFixed(2)}/${i+1}) - ${(Date.now() - start_at)}ms elapsed`) - } - console.log(`Result for ${amount} skins`) - } - else if (message.author.id === process.env.DEV_ID) { - const prefix = process.env.DEV_SITE === 'true' ? 'dev' : 'flopo' - if (message.content === prefix + ':add-coins-to-users') { - console.log(message.author.id) - try { - const stmtUpdateUsers = flopoDB.prepare(` - ALTER TABLE users - ADD coins INTEGER DEFAULT 0 - `); - stmtUpdateUsers.run() - } catch (e) { - console.log(e) - } - } - else if (message.content === prefix + ':sotd') { - initTodaysSOTD() - } - else if (message.content === prefix + ':users') { - const allAkhys = getAllUsers.all() - console.log(allAkhys) - } - else if (message.content === prefix + ':cancel') { - await message.delete() - } - else if (message.content.startsWith(prefix + ':reset-user-coins')) { - const userId = message.content.replace(prefix + ':reset-user-coins ', '') - const authorDB = getUser.get(userId) - if (authorDB) { - updateUserCoins.run({ - id: userId, - coins: 0, - }) - console.log(`${authorDB.username}'s coins were reset to 0`) - } else { - console.log('invalid user') - } - } - else if (message.content.startsWith(prefix + ':send-message')) { - const msg = message.content.replace(prefix + ':send-message ', '') - await fetch(process.env.BASE_URL + '/send-message', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - channelId: '1368908514545631262', - message: msg, - }) - }); - } - else if (message.content.startsWith(prefix + ':sql')) { - let sqlCommand = message.content.replace(prefix + ':sql ', '') - console.log(sqlCommand) - try { - if (sqlCommand.startsWith('SELECT')) { - const stmt = flopoDB.prepare(`${sqlCommand}`).all(); - console.log(stmt) - } else { - const stmt = flopoDB.prepare(`${sqlCommand}`).run(); - console.log(stmt) - } - } catch (e) { - console.log(e) - } - } - else if (message.content.startsWith(prefix + ':poker')) { - console.log('poker') - } - else if (message.content.startsWith(prefix + ':elo-test')) { - const numbers = message.content.match(/\d+/g); - - const score1 = parseInt(numbers[0]); - const score2 = parseInt(numbers[1]); - - const prob1 = 1 / (1 + Math.pow(10, (score2 - score1)/400)) - const prob2 = 1 / (1 + Math.pow(10, (score1 - score2)/400)) - - const res1 = Math.floor(score1 + 10 * (1 - prob1)) - const res2 = Math.floor(score2 + 10 * (0 - prob2)) - - console.log(res1, res2) - } - } - } -}); - -// Once bot is ready -client.once('ready', async () => { - console.log(`Logged in as ${client.user.tag}`); - console.log(`[Connected with ${FLAPI_URL}]`) - await getAkhys(); - console.log('FlopoBOT marked as ready') - - // every 10 minutes - cron.schedule('*/10 * * * *', async () => { - const FIVE_MINUTES = 5 * 60 * 1000; - - // clean 5 minutes old inventories - for (const id in activeInventories) { - const inventory = activeInventories[id]; - if (Date.now() >= inventory.timestamp + FIVE_MINUTES) { - console.log(`Removing expired inventory : ${id}`); - delete activeInventories[id]; - } - } - for (const id in activeSearchs) { - const search = activeSearchs[id]; - if (Date.now() >= search.timestamp + FIVE_MINUTES) { - console.log(`Removing expired search : ${id}`); - delete activeSearchs[id]; - } - } - for (const id in activePredis) { - const predi = activePredis[id]; - if (predi.closed) { - if (predi.paidTime && Date.now() >= predi.paidTime + (24 * 60 * 60 * 1000)) { - console.log(`Removing expired paid predi : ${id}`); - delete activePredis[id]; - } else if (Date.now() >= predi.cancelledTime + (24 * 60 * 60 * 1000)) { - console.log(`Removing expired cancelled predi : ${id}`); - delete activePredis[id]; - } - } - } - for (const roomId in Object.keys(pokerRooms)) { - const room = pokerRooms[roomId]; - if (Object.keys(room.players)?.length === 0) { - delete pokerRooms[roomId]; - console.log(`Removing empty poker room : ${roomId}`); - io.emit('new-poker-room') - } - } - }); - - // at midnight - cron.schedule(process.env.CRON_EXPR, async () => { - try { - const akhys = getAllUsers.all() - akhys.forEach((akhy) => { - resetDailyReward.run(akhy.id); - }) - } catch (e) { - console.log(e) - } - - initTodaysSOTD() - }); - - // users/skins dayly fetch at 7am - cron.schedule('0 7 * * *', async() => { - // fetch eventual new users/skins - await getAkhys(); - console.log('Users and skins fetched') - }) -}); - -/** - * Interactions endpoint URL where Discord will send HTTP requests - * Parse request body and verifies incoming requests using discord-interactions package - */ -app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), async function (req, res) { - // Interaction id, type and data - const { id, type, data } = req.body; - - /** - * Handle verification requests - */ - if (type === InteractionType.PING) { - return res.send({ type: InteractionResponseType.PONG }); - } - - /** - * Handle slash command requests - * See https://discord.com/developers/docs/interactions/application-commands#slash-commands - */ - if (type === InteractionType.APPLICATION_COMMAND) { - const { name } = data; - - // 'timeout' command - if (name === 'timeout') { - // Interaction context - const context = req.body.context; - // User ID is in user field for (G)DMs, and member for servers - const userId = context === 0 ? req.body.member.user.id : req.body.user.id; - // User's choices - const akhy = req.body.data.options[0].value; - const time = req.body.data.options[1].value; - - const guild = await client.guilds.fetch(req.body.guild_id); - const fromMember = await guild.members.fetch(userId); - const toMember = await guild.members.fetch(akhy); - - const already = Object.values(activePolls).find(poll => poll.toUsername === toMember.user); - - if (already) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Impossible de timeout **${toMember.user}** car un vote est déjà en cours`, - flags: InteractionResponseFlags.EPHEMERAL, - } - }); - } - - if (toMember.communicationDisabledUntilTimestamp > Date.now()) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `**${toMember.user}** est déjà timeout`, - flags: InteractionResponseFlags.EPHEMERAL, - } - }); - } - - // Save the poll information along with channel ID so we can notify later - activePolls[id] = { - id: userId, - username: fromMember.user, - toUserId: akhy, - toUsername: toMember.user, - time: time, - time_display: formatTime(time), - for: 0, - against: 0, - voters: [], - channelId: req.body.channel_id, // Capture channel for follow-up notification - endpoint: `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, - }; - - const guildId = req.body.guild_id; - const roleId = process.env.VOTING_ROLE_ID; // Set this in your .env file - const onlineEligibleUsers = await getOnlineUsersWithRole(guildId, roleId); - const requiredMajority = Math.max(parseInt(process.env.MIN_VOTES), Math.floor(onlineEligibleUsers.size / (time >= 21600 ? 2 : 3)) + 1); - const votesNeeded = Math.max(0, requiredMajority - activePolls[id].for); - - activePolls[id].endTime = Date.now() + process.env.POLL_TIME * 1000; - activePolls[id].requiredMajority = requiredMajority; - -// Set an interval to update the countdown every 10 seconds (or more often if you want) - const countdownInterval = setInterval(async () => { - const poll = activePolls[id]; - - if (!poll) { - clearInterval(countdownInterval); - io.emit('new-poll', { action: 'timeout cleared' }); - return; - } - - const remaining = Math.max(0, Math.floor((poll?.endTime - Date.now()) / 1000)); - const minutes = Math.floor(remaining / 60); - const seconds = remaining % 60; - const countdownText = `**${minutes}m ${seconds}s** restantes`; - const votesNeeded = Math.max(0, activePolls[id].requiredMajority - activePolls[id].for); - - if (!poll || remaining === 0) { - try { - let forText = '' - poll.voters.forEach((voter) => { - const user = getUser.get(voter); - forText += `- ${user.globalName}\n` - }) - await DiscordRequest( - poll.endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Le vote pour timeout ${poll.toUsername.username} pendant ${poll.time_display} a échoué 😔`, - description: `Il manquait **${votesNeeded}** vote(s)`, - fields: [ - { - name: 'Pour', - value: '✅ ' + poll.for + '\n' + forText, - inline: true, - }, - { - name: 'Temps restant', - value: '⏳ ' + countdownText, - inline: false, - }, - ], - color: 0xF2F3F3, // You can set the color of the embed - }, - ], - components: [], - }, - } - ); - } catch (err) { - console.error('Error sending message', err); - } - console.log('clear poll') - clearInterval(countdownInterval); - delete activePolls[id]; - io.emit('new-poll', { action: 'timeout cleared' }); - return; - } - - try { - let forText = '' - poll.voters.forEach((voter) => { - const user = getUser.get(voter); - forText += `- ${user.globalName}\n` - }) - await DiscordRequest( - poll.endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Timeout`, - description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}\nIl manque **${votesNeeded}** vote(s)`, - fields: [ - { - name: 'Pour', - value: '✅ ' + poll.for + '\n' + forText, - inline: true, - }, - { - name: 'Temps restant', - value: '⏳ ' + countdownText, - inline: false, - }, - ], - color: 0xF2F3F3, // You can set the color of the embed - }, - ], - components: [ - { - type: MessageComponentTypes.ACTION_ROW, - components: [ - { - type: MessageComponentTypes.BUTTON, - custom_id: `vote_for_${req.body.id}`, - label: 'Oui ✅', - style: ButtonStyleTypes.SECONDARY, - }, - { - type: MessageComponentTypes.BUTTON, - custom_id: `vote_against_${req.body.id}`, - label: 'Non ❌', - style: ButtonStyleTypes.SECONDARY, - }, - ], - }, - ], - }, - } - ); - } catch (err) { - console.error('Error updating countdown:', err); - } - }, 1000); // every second - - const remaining = Math.max(0, Math.floor((activePolls[id].endTime - Date.now()) / 1000)); - const minutes = Math.floor(remaining / 60); - const seconds = remaining % 60; - const countdownText = `**${minutes}m ${seconds}s** restantes`; - - // web site update - io.emit('new-poll', { action: 'timeout command' }); - - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - title: `Timeout`, - description: `**${activePolls[id].username}** propose de timeout **${activePolls[id].toUsername}** pendant ${activePolls[id].time_display}\nIl manque **${votesNeeded}** vote(s)`, - fields: [ - { - name: 'Pour', - value: '✅ ' + activePolls[id].for, - inline: true, - }, - { - name: 'Temps restant', - value: '⏳ ' + countdownText, - inline: false, - }, - ], - color: 0xF2F3F3, // You can set the color of the embed - }, - ], - components: [ - { - type: MessageComponentTypes.ACTION_ROW, - components: [ - { - type: MessageComponentTypes.BUTTON, - custom_id: `vote_for_${req.body.id}`, - label: 'Oui ✅', - style: ButtonStyleTypes.SECONDARY, - }, - { - type: MessageComponentTypes.BUTTON, - custom_id: `vote_against_${req.body.id}`, - label: 'Non ❌', - style: ButtonStyleTypes.SECONDARY, - }, - ], - }, - ], - }, - }); - } - - if (name === 'inventory') { - // Interaction context - const context = req.body.context; - // User ID is in user field for (G)DMs, and member for servers - const userId = context === 0 ? req.body.member.user.id : req.body.user.id; - // User's choices - const akhy = req.body.data.options ? req.body.data.options[0].value : userId; - - const guild = await client.guilds.fetch(req.body.guild_id); - const completeAkhy = await guild.members.fetch(akhy); - - const invSkins = getUserInventory.all({user_id: akhy}); - - const chromaText = (skin) => { - let result = "" - for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { - result += skin.currentChroma === i ? '💠 ' : '◾ ' - } - return result - } - const chromaName = (skin) => { - if (skin.currentChroma >= 2) { - const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') - const match = name.match(/variante\s+[1-4]\s+([^)]+)/) - const result = match ? match[2] : null; - if (match) { - return match[1].trim() - } else { - return name - } - } - if (skin.currentChroma === 1) { - return 'Base' - } - return '' - }; - let content = ''; - let totalPrice = 0; - let fields = []; - invSkins.forEach(skin => { - content += `- ${skin.displayName} | ${skin.currentPrice.toFixed()}€ \n`; - totalPrice += skin.currentPrice; - fields.push({ - name: `${skin.displayName} | ${skin.currentPrice.toFixed(2)}€`, - value: `${skin.tierText}\nChroma : ${chromaText(skin)} | ${chromaName(skin)}\nLvl : **${skin.currentLvl}**/${skins.find((s) => s.uuid === skin.uuid).levels.length}\n`, - inline: false, - }) - }) - - activeInventories[id] = { - akhyId: akhy, - userId: userId, - page: 0, - amount: invSkins.length, - reqBodyId: req.body.id, - endpoint: `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, - timestamp: Date.now(), - }; - - if (invSkins.length === 0) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - title: `Inventaire de ${completeAkhy.user.username}`, - description: "Aucun skin dans l'inventaire", - color: 0xF2F3F3, - footer: {text: `Total : ${totalPrice.toFixed(2)}€`}, - }, - ], - }, - }); - } - const trueSkin = skins.find((s) => s.uuid === invSkins[0].uuid); - - const imageUrl = () => { - let result; - if (invSkins[0].currentLvl === trueSkin.levels.length) { - if (invSkins[0].currentChroma === 1) { - result = trueSkin.chromas[0].displayIcon - - } else { - result = trueSkin.chromas[invSkins[0].currentChroma-1].fullRender ?? trueSkin.chromas[invSkins[0].currentChroma-1].displayIcon - } - } else if (invSkins[0].currentLvl === 1) { - result = trueSkin.levels[0].displayIcon ?? trueSkin.chromas[0].fullRender - } else if (invSkins[0].currentLvl === 2 || invSkins[0].currentLvl === 3) { - result = trueSkin.displayIcon - } - if (result) return result; - return trueSkin.displayIcon - }; - - let components = [ - { - type: MessageComponentTypes.BUTTON, - custom_id: `prev_page_${req.body.id}`, - label: '⏮️ Préc.', - style: ButtonStyleTypes.SECONDARY, - }, - { - type: MessageComponentTypes.BUTTON, - custom_id: `next_page_${req.body.id}`, - label: 'Suiv. ⏭️', - style: ButtonStyleTypes.SECONDARY, - }, - ] - - if ((invSkins[0].currentLvl < trueSkin.levels.length || invSkins[0].currentChroma < trueSkin.chromas.length) && akhy === userId) { - components.push({ - type: MessageComponentTypes.BUTTON, - custom_id: `upgrade_${req.body.id}`, - label: `Upgrade ⏫`, - style: ButtonStyleTypes.PRIMARY, - }) - } - - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - title: `Inventaire de ${completeAkhy.user.username}`, - description: `${invSkins?.length > 0 ? '' : "Aucun skin dans l'inventaire"}`, - color: 0xF2F3F3, - footer: {text: `${activeInventories[id].page+1}/${invSkins?.length} | Total : ${totalPrice.toFixed(2)}€`}, - fields: [fields[activeInventories[id].page]], - image: { - url: invSkins?.length > 0 ? imageUrl() : '', - } - }, - ], - components: [ - { - type: MessageComponentTypes.ACTION_ROW, - components: components, - }, - ], - }, - }); - } - - if (name === 'valorant') { - const buyResponse = await postAPOBuy(req.body.member.user.id, process.env.VALO_PRICE ?? 150) - - if (buyResponse.status === 500 || buyResponse.ok === false) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Tu n'as pas assez d'argent...`, - flags: InteractionResponseFlags.EPHEMERAL, - } - }); - } - - // First, send the initial response immediately - const initialEmbed = new EmbedBuilder() - .setTitle(`\t`) - .setImage('https://media.tenor.com/gIWab6ojBnYAAAAd/weapon-line-up-valorant.gif') - .setColor(`#F2F3F3`); - - // Send the initial response and store the reply object - await res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { embeds: [initialEmbed] } - }); - - // Get a random skin - const dbSkins = getAllAvailableSkins.all(); - const randomIndex = Math.floor(Math.random() * dbSkins.length); - let randomSkin; - - try { - randomSkin = skins.find((skin) => skin.uuid === dbSkins[randomIndex].uuid); - if (!randomSkin) throw new Error("Skin not found"); - } catch (e) { - // Edit the original message if there's an error - await DiscordRequest( - `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, - { - method: 'PATCH', - body: { - content: "Oups, ya eu un ptit problème", - embeds: [] - } - } - ); - return; - } - - // Generate random level and chroma - const randomLevel = Math.floor(Math.random() * randomSkin.levels.length + 1); - let randomChroma = randomLevel === randomSkin.levels.length - ? Math.floor(Math.random() * randomSkin.chromas.length + 1) - : 1; - if (randomChroma === randomSkin.chromas.length && randomSkin.chromas.length >= 2) randomChroma-- - const selectedLevel = randomSkin.levels[randomLevel - 1] - const selectedChroma = randomSkin.chromas[randomChroma - 1] - - // Set timeout for the reveal - setTimeout(async () => { - // Prepare the final embed - const selectedLevel = randomSkin.levels[randomLevel - 1]; - const selectedChroma = randomSkin.chromas[randomChroma - 1]; - - // Helper functions (unchanged from your original code) - const videoUrl = () => { - let result; - if (randomLevel === randomSkin.levels.length) { - if (randomChroma === 1) { - result = randomSkin.levels[randomSkin.levels.length - 1].streamedVideo ?? randomSkin.chromas[0].streamedVideo - } else { - result = randomSkin.chromas[randomChroma-1].streamedVideo - } - } else { - result = randomSkin.levels[randomLevel-1].streamedVideo - } - return result; - }; - const imageUrl = () => { - let result; - if (randomLevel === randomSkin.levels.length) { - if (randomChroma === 1) { - result = randomSkin.chromas[0].displayIcon - - } else { - result = randomSkin.chromas[randomChroma-1].fullRender ?? randomSkin.chromas[randomChroma-1].displayIcon - } - } else if (randomLevel === 1) { - result = randomSkin.levels[0].displayIcon ?? randomSkin.chromas[0].fullRender - } else if (randomLevel === 2 || randomLevel === 3) { - result = randomSkin.displayIcon - } - if (result) return result; - return randomSkin.displayIcon - }; - const chromaName = () => { - if (randomChroma >= 2) { - const name = selectedChroma.displayName.replace(/[\r\n]+/g, '').replace(randomSkin.displayName, '') - const match = name.match(/variante\s+[1-4]\s+([^)]+)/) - const result = match ? match[2] : null; - if (match) { - return match[1].trim() - } else { - return name - } - } - if (randomChroma === 1) { - return 'Base' - } - return '' - }; - const lvlText = () => { - let result = "" - if (randomLevel >= 1) { - result += '1️⃣ ' - } - if (randomLevel >= 2) { - result += '2️⃣ ' - } - if (randomLevel >= 3) { - result += '3️⃣ ' - } - if (randomLevel >= 4) { - result += '4️⃣ ' - } - if (randomLevel >= 5) { - result += '5️⃣ ' - } - for (let i = 0; i < randomSkin.levels.length - randomLevel; i++) { - result += '◾ ' - } - return result - } - const chromaText = () => { - let result = "" - for (let i = 1; i <= randomSkin.chromas.length; i++) { - result += randomChroma === i ? '💠 ' : '◾ ' - } - return result - } - const price = () => { - let result = dbSkins[randomIndex].basePrice; - - result *= (1 + (randomLevel / Math.max(randomSkin.levels.length, 2))) - result *= (1 + (randomChroma / 4)) - - return result.toFixed(2); - } - - // Update the database - try { - await updateSkin.run({ - uuid: randomSkin.uuid, - user_id: req.body.member.user.id, - currentLvl: randomLevel, - currentChroma: randomChroma, - currentPrice: price() - }); - } catch (e) { - console.log('Database error', e); - } - - // Build the final embed - const finalEmbed = new EmbedBuilder() - .setTitle(`${randomSkin.displayName} | ${chromaName()}`) - .setFields([ - { name: '', value: `**Lvl** | ${lvlText()}`, inline: true }, - { name: '', value: `**Chroma** | ${chromaText()}`, inline: true }, - { name: '', value: `**Prix** | ${price()} <:vp:1362964205808128122>`, inline: true }, - ]) - .setDescription(dbSkins[randomIndex].tierText) - .setImage(imageUrl()) - .setFooter({ text: 'Ajouté à ton inventaire' }) - .setColor(`#${dbSkins[randomIndex].tierColor}`); - - // Prepare components if video exists - const video = videoUrl(); - const components = []; - - if (video) { - components.push( - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setLabel('🎬 Aperçu vidéo') - .setStyle(ButtonStyle.Link) - .setURL(video) - ) - ); - } - - // Edit the original message - try { - await DiscordRequest( - `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, - { - method: 'PATCH', - body: { - embeds: [finalEmbed], - components: components - } - } - ); - } catch (err) { - console.error('Error editing message:', err); - } - }, 5000); - - return; - } - - if (name === 'info') { - const guild = await client.guilds.fetch(req.body.guild_id); - - await guild.members.fetch() - - const timedOutMembers = guild.members.cache.filter( - (member) => - member.communicationDisabledUntil && - member.communicationDisabledUntil > new Date() - ); - - if (timedOutMembers.size === 0) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - title: `Membres timeout`, - description: "Aucun membre n'est actuellement timeout.", - color: 0xF2F3F3, - }, - ], - }, - }); - } - - const list = timedOutMembers.map( - (member) => - `**${member.user.tag}** (jusqu'à ${member.communicationDisabledUntil.toLocaleString()})` - ).join("\n"); - - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - title: `Membres timeout`, - description: `${list}`, - color: 0xF2F3F3, - }, - ], - }, - }); - } - - if (name === 'skins') { - const topSkins = getTopSkins.all() - const guild = await client.guilds.fetch(req.body.guild_id) - - let fields = [] - - for (const skin of topSkins) { - const index = topSkins.indexOf(skin); - const owner = skin.user_id ? await guild.members.fetch(skin.user_id) : null; - fields.push({ - name: `#${index+1} - **${skin.displayName}**`, - value: `${skin.maxPrice}€ ${skin.user_id ? '| **@'+ owner.user.username+'** ✅' : ''}\n`, - inline: false - }); - } - - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - fields: fields, - color: 0xF2F3F3, - }, - ], - }, - }); - } - - if (name === 'floposite') { - const originalComponents = [ - { - type: MessageComponentTypes.BUTTON, - label: 'Aller sur FlopoSite', - style: ButtonStyleTypes.LINK, - url: 'https://floposite.com', - }, - ]; - - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - title: 'FlopoSite', - description: 'L\'officiel et très goatesque site de FlopoBot.', - color: 0x6571F3, - thumbnail: { - url: process.env.API_URL + '/public/images/flopo.png' - } - } - ], - components: [ - { - type: MessageComponentTypes.ACTION_ROW, - components: originalComponents, - }, - ], - } - }) - } - - if (name === 'search') { - const context = req.body.context; - // User ID is in user field for (G)DMs, and member for servers - const userId = context === 0 ? req.body.member.user.id : req.body.user.id; - const searchValue = req.body.data.options[0].value.toLowerCase(); - - const guild = await client.guilds.fetch(req.body.guild_id); - - let dbSkins = getAllSkins.all() - - let resultSkins = dbSkins.filter((skin) => { - return skin.displayName.toLowerCase().includes(searchValue) || skin.tierText.toLowerCase().includes(searchValue); - }) - - if (resultSkins.length === 0) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: 'Aucun résultat ne correspond à ta recherche', - flags: InteractionResponseFlags.EPHEMERAL, - } - }) - } - - const owner = await guild.members.fetch(resultSkins[0].user_id) - let fields = [ - { - name: `**${resultSkins[0].displayName}** | ${resultSkins[0].tierText}`, - value: `${resultSkins[0].maxPrice}€ ${resultSkins[0].user_id ? '| **@'+ owner.user.username +'** ✅' : ''}`, - inline: false, - } - ] - - activeSearchs[id] = { - userId: userId, - page: 0, - amount: resultSkins.length, - resultSkins: resultSkins, - endpoint: `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, - timestamp: Date.now(), - searchValue: searchValue, - }; - - const trueSkin = skins.find((s) => s.uuid === resultSkins[0].uuid); - const imageUrl = () => { - let res; - if (trueSkin.chromas[trueSkin.chromas.length-1].displayIcon) { - res = trueSkin.chromas[trueSkin.chromas.length-1].displayIcon - } else if (trueSkin.levels[trueSkin.levels.length-1].displayIcon) { - res = trueSkin.levels[trueSkin.levels.length-1].displayIcon - } else { - res = trueSkin.displayIcon - } - return res - }; - - const videoUrl = () => { - let res; - if (trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo) { - res = trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo - } else if (trueSkin.levels[trueSkin.levels.length-1].streamedVideo) { - res = trueSkin.levels[trueSkin.levels.length-1].streamedVideo - } else { - res = null - } - return res - }; - - const originalComponents = [ - { - type: MessageComponentTypes.BUTTON, - custom_id: `prev_search_page_${req.body.id}`, - label: '⏮️ Préc.', - style: ButtonStyleTypes.SECONDARY, - }, - { - type: MessageComponentTypes.BUTTON, - custom_id: `next_search_page_${req.body.id}`, - label: 'Suiv. ⏭️', - style: ButtonStyleTypes.SECONDARY, - }, - ]; - - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - title: `Résultat de recherche`, - description: `🔎 ${searchValue}`, - fields: fields, - color: parseInt(resultSkins[0].tierColor, 16), - image: { url: imageUrl() }, - footer: { text: `1/${resultSkins.length} résultat(s)` }, - }, - ], - components: [ - { - type: MessageComponentTypes.ACTION_ROW, - components: originalComponents, - }, - ], - }, - }); - } - - console.error(`unknown command: ${name}`); - return res.status(400).json({ error: 'unknown command' }); - } - - if (type === InteractionType.MESSAGE_COMPONENT) { -// custom_id set in payload when sending message component - const componentId = data.custom_id; - - if (componentId.startsWith('accept_button_')) { - // get the associated game ID - const gameId = componentId.replace('accept_button_', ''); - // Delete message with token in request body - const endpoint = `webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`; - try { - await res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: 'What is your object of choice?', - // Indicates it'll be an ephemeral message - flags: InteractionResponseFlags.EPHEMERAL, - components: [ - { - type: MessageComponentTypes.ACTION_ROW, - components: [ - { - type: MessageComponentTypes.STRING_SELECT, - // Append game ID - custom_id: `select_choice_${gameId}`, - options: getShuffledOptions(), - }, - ], - }, - ], - }, - }); - // Delete previous message - await DiscordRequest(endpoint, { method: 'DELETE' }); - } catch (err) { - console.error('Error sending message:', err); - } - } - else if (componentId.startsWith('vote_')) { - let gameId, isVotingFor; - - if (componentId.startsWith('vote_for_')) { - gameId = componentId.replace('vote_for_', ''); - isVotingFor = true; - } else { - gameId = componentId.replace('vote_against_', ''); - isVotingFor = false; - } - - if (activePolls[gameId]) { - const poll = activePolls[gameId]; - poll.voters = poll.voters || []; - const voterId = req.body.member.user.id; - - // Check if the voter has the required voting role - const voterRoles = req.body.member.roles || []; - if (!voterRoles.includes(process.env.VOTING_ROLE_ID)) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: "Tu n'as pas le rôle requis pour voter.", - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - // Enforce one vote per eligible user - if (poll.voters.find(u => u === voterId)) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: "Tu as déjà voté oui!", - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - // Record the vote - if (isVotingFor) { - poll.voters.push(voterId); - poll.for++; - } else { - poll.against++; - } - - io.emit('new-poll', { action: 'new vote' }); - - // Retrieve online eligible users (ensure your bot has the necessary intents) - const guildId = req.body.guild_id; - const roleId = process.env.VOTING_ROLE_ID; // Set this in your .env file - const onlineEligibleUsers = await getOnlineUsersWithRole(guildId, roleId); - const votesNeeded = Math.max(0, poll.requiredMajority - poll.for); - - // Check if the majority is reached - if (poll.for >= poll.requiredMajority) { - try { - // Build the updated poll message content - let forText = '' - poll.voters.forEach((voter) => { - const user = getUser.get(voter); - forText += `- ${user.globalName}\n` - }) - await DiscordRequest( - poll.endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Timeout`, - description: `Proposition de timeout **${poll.toUsername}** pendant ${poll.time_display}`, - fields: [ - { - name: 'Votes totaux', - value: '✅ ' + poll.for + '\n' + forText, - inline: true, - }, - ], - color: 0xF2F3F3, // You can set the color of the embed - }, - ], - components: [], // remove buttons - }, - } - ); - } catch (err) { - console.error('Error updating poll message:', err); - } - // Clear the poll so the setTimeout callback doesn't fire later - delete activePolls[gameId]; - - // **Actual Timeout Action** - try { - // Calculate the ISO8601 timestamp to disable communications until now + poll.time seconds - const timeoutUntil = new Date(Date.now() + poll.time * 1000).toISOString(); - const endpointTimeout = `guilds/${req.body.guild_id}/members/${poll.toUserId}`; - await DiscordRequest(endpointTimeout, { - method: 'PATCH', - body: { communication_disabled_until: timeoutUntil }, - }); - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `<@${poll.toUserId}> a été timeout pendant ${poll.time_display} par décision démocratique 👊`, - }, - }); - } catch (err) { - console.error('Error timing out user:', err); - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Impossible de timeout <@${poll.toUserId}>, désolé... 😔`, - }, - }); - } - } - - // If the vote is "for", update the original poll message to reflect the new vote count. - if (isVotingFor) { - const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000)); - const minutes = Math.floor(remaining / 60); - const seconds = remaining % 60; - const countdownText = `**${minutes}m ${seconds}s** restantes`; - try { - // Build the updated poll message content - let forText = '' - poll.voters.forEach((voter) => { - const user = getUser.get(voter); - forText += `- ${user.globalName}\n` - }) - await DiscordRequest( - poll.endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Timeout`, - description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}\nIl manque **${votesNeeded}** vote(s)`, - fields: [ - { - name: 'Pour', - value: '✅ ' + poll.for + '\n' + forText, - inline: true, - }, - { - name: 'Temps restant', - value: '⏳ ' + countdownText, - inline: false, - }, - ], - color: 0xF2F3F3, // You can set the color of the embed - }, - ], - components: req.body.message.components, // preserve the buttons - }, - } - ); - } catch (err) { - console.error('Error updating poll message:', err); - } - } - - // Send an ephemeral acknowledgement to the voter - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Vote enregistré ! ✅`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - } - else if (componentId.startsWith('prev_page')) { - let invId = componentId.replace('prev_page_', ''); - const context = req.body.context; - // User ID is in user field for (G)DMs, and member for servers - const userId = context === 0 ? req.body.member.user.id : req.body.user.id; - - const guild = await client.guilds.fetch(req.body.guild_id); - if (!activeInventories[invId]) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Oups, cet affichage n'est plus actif.\nRelance la commande pour avoir un nouvel élément intéractif`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const completeAkhy = await guild.members.fetch(activeInventories[invId].akhyId); - - const invSkins = getUserInventory.all({user_id: activeInventories[invId].akhyId}); - - const chromaText = (skin) => { - let res = "" - for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { - res += skin.currentChroma === i ? '💠 ' : '◾ ' - } - return res - } - const chromaName = (skin) => { - if (skin.currentChroma >= 2) { - const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') - const match = name.match(/variante\s+[1-4]\s+([^)]+)/) - const result = match ? match[2] : null; - if (match) { - return match[1].trim() - } else { - return name - } - } - if (skin.currentChroma === 1) { - return 'Base' - } - return '' - }; - let content = ''; - let totalPrice = 0; - let fields = []; - invSkins.forEach(skin => { - content += `- ${skin.displayName} | ${skin.currentPrice.toFixed()}€ \n`; - totalPrice += skin.currentPrice; - fields.push({ - name: `${skin.displayName} | ${skin.currentPrice.toFixed(2)}€`, - value: `${skin.tierText}\nChroma : ${chromaText(skin)} | ${chromaName(skin)}\nLvl : **${skin.currentLvl}**/${skins.find((s) => s.uuid === skin.uuid).levels.length}\n`, - inline: false, - }) - }) - - if (activeInventories[invId] && activeInventories[invId].userId === req.body.member.user.id) { - if (activeInventories[invId].page === 0) { - activeInventories[invId].page = activeInventories[invId].amount-1 - } else { - activeInventories[invId].page-- - } - } else { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Tu n'est pas à l'origine de cette commande /inventory`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const trueSkin = skins.find((s) => s.uuid === invSkins[activeInventories[invId].page].uuid); - const imageUrl = () => { - let res; - if (invSkins[activeInventories[invId].page].currentLvl === trueSkin.levels.length) { - if (invSkins[activeInventories[invId].page].currentChroma === 1) { - res = trueSkin.chromas[0].displayIcon - - } else { - res = trueSkin.chromas[invSkins[activeInventories[invId].page].currentChroma-1].fullRender ?? trueSkin.chromas[invSkins[activeInventories[invId].page].currentChroma-1].displayIcon - } - } else if (invSkins[activeInventories[invId].page].currentLvl === 1) { - res = trueSkin.levels[0].displayIcon ?? trueSkin.chromas[0].fullRender - } else if (invSkins[activeInventories[invId].page].currentLvl === 2 || invSkins[activeInventories[invId].page].currentLvl === 3) { - res = trueSkin.displayIcon - } - if (res) return res; - return trueSkin.displayIcon - }; - - let components = req.body.message.components; - - if ((invSkins[activeInventories[invId].page].currentLvl < trueSkin.levels.length || invSkins[activeInventories[invId].page].currentChroma < trueSkin.chromas.length) && activeInventories[invId].akhyId === activeInventories[invId].userId) { - if (components[0].components.length === 2) { - components[0].components.push({ - type: MessageComponentTypes.BUTTON, - custom_id: `upgrade_${activeInventories[invId].reqBodyId}`, - label: `Upgrade ⏫`, - style: ButtonStyleTypes.PRIMARY, - }) - } - } else { - if (components[0].components.length === 3) { - components[0].components.pop() - } - } - - try { - await DiscordRequest( - activeInventories[invId].endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Inventaire de ${completeAkhy.user.username}`, - description: `${invSkins?.length > 0 ? '' : "Aucun skin dans l'inventaire"}`, - color: 0xF2F3F3, - footer: {text: `${activeInventories[invId].page+1}/${invSkins?.length} | Total : ${totalPrice.toFixed(2)}€`}, - fields: [fields[activeInventories[invId].page]], - image: { - url: invSkins?.length > 0 ? imageUrl() : '', - } - }, - ], - components: components, - }, - } - ); - } catch (err) { - console.log('Pas trouvé : ', err) - } - return res.send({ - type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, - }); - } - else if (componentId.startsWith('next_page')) { - let invId = componentId.replace('next_page_', ''); - const context = req.body.context; - // User ID is in user field for (G)DMs, and member for servers - const userId = context === 0 ? req.body.member.user.id : req.body.user.id; - - const guild = await client.guilds.fetch(req.body.guild_id); - if (!activeInventories[invId]) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Oups, cet inventaire n'est plus actif.\nRelance la commande pour avoir un nouvel inventaire interactif`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - const completeAkhy = await guild.members.fetch(activeInventories[invId].akhyId); - - const invSkins = getUserInventory.all({user_id: activeInventories[invId].akhyId}); - - const chromaText = (skin) => { - let res = "" - for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { - res += skin.currentChroma === i ? '💠 ' : '◾ ' - } - return res - } - const chromaName = (skin) => { - if (skin.currentChroma >= 2) { - const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') - const match = name.match(/variante\s+[1-4]\s+([^)]+)/) - const result = match ? match[2] : null; - if (match) { - return match[1].trim() - } else { - return name - } - } - if (skin.currentChroma === 1) { - return 'Base' - } - return '' - }; - let content = ''; - let totalPrice = 0; - let fields = []; - invSkins.forEach(skin => { - content += `- ${skin.displayName} | ${skin.currentPrice.toFixed()}€ \n`; - totalPrice += skin.currentPrice; - fields.push({ - name: `${skin.displayName} | ${skin.currentPrice.toFixed(2)}€`, - value: `${skin.tierText}\nChroma : ${chromaText(skin)} | ${chromaName(skin)}\nLvl : **${skin.currentLvl}**/${skins.find((s) => s.uuid === skin.uuid).levels.length}\n`, - inline: false, - }) - }) - - if (activeInventories[invId] && activeInventories[invId].userId === req.body.member.user.id) { - if (activeInventories[invId].page === activeInventories[invId].amount-1) { - activeInventories[invId].page = 0 - } else { - activeInventories[invId].page++ - } - } else { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Tu n'est pas à l'origine de cette commande /inventory`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const trueSkin = skins.find((s) => s.uuid === invSkins[activeInventories[invId].page].uuid); - const imageUrl = () => { - let res; - if (invSkins[activeInventories[invId].page].currentLvl === trueSkin.levels.length) { - if (invSkins[activeInventories[invId].page].currentChroma === 1) { - res = trueSkin.chromas[0].displayIcon - - } else { - res = trueSkin.chromas[invSkins[activeInventories[invId].page].currentChroma-1].fullRender ?? trueSkin.chromas[invSkins[activeInventories[invId].page].currentChroma-1].displayIcon - } - } else if (invSkins[activeInventories[invId].page].currentLvl === 1) { - res = trueSkin.levels[0].displayIcon ?? trueSkin.chromas[0].fullRender - } else if (invSkins[activeInventories[invId].page].currentLvl === 2 || invSkins[activeInventories[invId].page].currentLvl === 3) { - res = trueSkin.displayIcon - } - if (res) return res; - return trueSkin.displayIcon - }; - - let components = req.body.message.components; - - if ((invSkins[activeInventories[invId].page].currentLvl < trueSkin.levels.length || invSkins[activeInventories[invId].page].currentChroma < trueSkin.chromas.length) && activeInventories[invId].akhyId === activeInventories[invId].userId) { - if (components[0].components.length === 2) { - components[0].components.push({ - type: MessageComponentTypes.BUTTON, - custom_id: `upgrade_${activeInventories[invId].reqBodyId}`, - label: `Upgrade ⏫`, - style: ButtonStyleTypes.PRIMARY, - }) - } - } else { - if (components[0].components.length === 3) { - components[0].components.pop() - } - } - - try { - await DiscordRequest( - activeInventories[invId].endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Inventaire de ${completeAkhy.user.username}`, - description: `${invSkins?.length > 0 ? '' : "Aucun skin dans l'inventaire"}`, - color: 0xF2F3F3, - footer: {text: `${activeInventories[invId].page+1}/${invSkins?.length} | Total : ${totalPrice.toFixed(2)}€`}, - fields: [fields[activeInventories[invId].page]], - image: { - url: invSkins?.length > 0 ? imageUrl() : '', - } - }, - ], - components: components, - }, - } - ); - } catch (err) { - console.log('Pas trouvé : ', err) - } - return res.send({ - type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, - }); - } - else if (componentId.startsWith('upgrade_')) { - let invId = componentId.replace('upgrade_', '') - const context = req.body.context - const userId = context === 0 ? req.body.member.user.id : req.body.user.id - - const guild = await client.guilds.fetch(req.body.guild.id) - if (!activeInventories[invId]) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Oups, cet inventaire n'est plus actif.\nRelance la commande pour avoir un nouvel inventaire interactif`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - const completeAkhy = await guild.members.fetch(activeInventories[invId].akhyId) - - const invSkins = getUserInventory.all({user_id: activeInventories[invId].akhyId}) - - if (!activeInventories[invId] || activeInventories[invId].userId !== req.body.member.user.id) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Tu n'est pas à l'origine de cette commande /inventory`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const upgradePrice = process.env.VALO_UPGRADE_PRICE ?? invSkins[activeInventories[invId].page].maxPrice/10 - const buyResponse = await postAPOBuy(req.body.member.user.id, upgradePrice) - - if (buyResponse.status === 500 || buyResponse.ok === false) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Tu n'as pas assez d'argent, cette amélioration coûte ${upgradePrice}€`, - flags: InteractionResponseFlags.EPHEMERAL, - } - }); - } - - const skin = invSkins[activeInventories[invId].page]; - const trueSkin = skins.find((s) => s.uuid === invSkins[activeInventories[invId].page].uuid); - - const lvlNb = trueSkin.levels.length - const chromaNb = trueSkin.chromas.length - const tierRank = trueSkin.tierRank - const currentLvl = skin.currentLvl - const currentChroma = skin.currentChroma - - let succeeded = false - - if (currentLvl < lvlNb) { - let prob = (currentLvl/lvlNb) - if (tierRank) prob *= ((tierRank+1)/4)+.1 - let rand = Math.random() - console.log(`lvl upgrade prob : ${prob} | ${rand}`) - succeeded = rand >= prob - //amélioration du lvl - if (succeeded) { - let newLvl = skin.currentLvl + 1 - const price = () => { - let res = skin.basePrice; - - res *= (1 + (newLvl / Math.max(trueSkin.levels.length, 2))) - res *= (1 + (skin.currentChroma / 4)) - - return res.toFixed(2); - } - try { - await updateSkin.run({ - uuid: skin.uuid, - user_id: skin.user_id, - currentLvl: newLvl, - currentChroma: skin.currentChroma, - currentPrice: price() - }); - } catch (e) { - console.log('Database error', e); - } - } - } - else if (currentChroma < chromaNb) { - let prob = (currentChroma/chromaNb) - if (tierRank) prob *= ((tierRank+1)/4)+.1 - let rand = Math.random() - console.log(`lvl upgrade prob : ${prob} | ${rand}`) - succeeded = rand >= prob - //amélioration du chroma - if (succeeded) { - let newChroma = skin.currentChroma + 1 - const price = () => { - let res = skin.basePrice; - - res *= (1 + (skin.currentLvl / Math.max(trueSkin.levels.length, 2))) - res *= (1 + (newChroma / 4)) - - return res.toFixed(2); - } - try { - await updateSkin.run({ - uuid: skin.uuid, - user_id: skin.user_id, - currentLvl: skin.currentLvl, - currentChroma: newChroma, - currentPrice: price() - }); - } catch (e) { - console.log('Database error', e); - } - } - } else { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Ce skin n'est pas améliorable`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - // gif - const initialEmbed = new EmbedBuilder() - .setTitle(`Amélioration en cours...`) - .setImage('https://media.tenor.com/HD8nVN2QP9MAAAAC/thoughts-think.gif') - .setColor(0xF2F3F3); - - // Send the initial response and store the reply object - await res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { embeds: [initialEmbed] } - }); - - // then result - setTimeout(async () => { - // Prepare the final embed - let updatedSkin = await getSkin.get(trueSkin.uuid) - const randomLevel = updatedSkin.currentLvl - const randomChroma = updatedSkin.currentChroma - const selectedChroma = trueSkin.chromas[randomChroma-1] - - // Helper functions (unchanged from your original code) - const videoUrl = () => { - let res; - if (randomLevel === trueSkin.levels.length) { - if (randomChroma === 1) { - res = trueSkin.levels[trueSkin.levels.length - 1].streamedVideo ?? trueSkin.chromas[0].streamedVideo - } else { - res = trueSkin.chromas[randomChroma-1].streamedVideo - } - } else { - res = trueSkin.levels[randomLevel-1].streamedVideo - } - return res; - }; - const imageUrl = () => { - let res; - if (randomLevel === trueSkin.levels.length) { - if (randomChroma === 1) { - res = trueSkin.chromas[0].displayIcon - - } else { - res = trueSkin.chromas[randomChroma-1].fullRender ?? trueSkin.chromas[randomChroma-1].displayIcon - } - } else if (randomLevel === 1) { - res = trueSkin.levels[0].displayIcon ?? trueSkin.chromas[0].fullRender - } else if (randomLevel === 2 || randomLevel === 3) { - res = trueSkin.displayIcon - } - if (res) return res; - return trueSkin.displayIcon - }; - const chromaName = () => { - if (randomChroma >= 2) { - const name = selectedChroma.displayName.replace(/[\r\n]+/g, '').replace(trueSkin.displayName, '') - const match = name.match(/variante\s+[1-4]\s+([^)]+)/) - const result = match ? match[2] : null; - if (match) { - return match[1].trim() - } else { - return name - } - } - if (randomChroma === 1) { - return 'Base' - } - return '' - }; - const lvlText = () => { - let res = "" - if (randomLevel >= 1) { - res += '1️⃣ ' - } - if (randomLevel >= 2) { - res += '2️⃣ ' - } - if (randomLevel >= 3) { - res += '3️⃣ ' - } - if (randomLevel >= 4) { - res += '4️⃣ ' - } - if (randomLevel >= 5) { - res += '5️⃣ ' - } - for (let i = 0; i < trueSkin.levels.length - randomLevel; i++) { - res += '◾ ' - } - return res - } - const chromaText = () => { - let res = "" - for (let i = 1; i <= trueSkin.chromas.length; i++) { - res += randomChroma === i ? '💠 ' : '◾ ' - } - return res - } - - // Build the final embed - let finalEmbed; - if (succeeded) { - finalEmbed = new EmbedBuilder() - .setTitle(`L'amélioration a réussi ! 🎉`) - .setFields([ - { name: '', value: `${updatedSkin.displayName} | ${chromaName()}`, inline: false }, - { name: '', value: `**Lvl** | ${lvlText()}`, inline: true }, - { name: '', value: `**Chroma** | ${chromaText()}`, inline: true }, - { name: '', value: `**Prix** | ${updatedSkin.currentPrice} <:vp:1362964205808128122>`, inline: true }, - ]) - .setDescription(updatedSkin.tierText) - .setImage(imageUrl()) - .setColor(0x00FF00); - } - else { - finalEmbed = new EmbedBuilder() - .setTitle(`L'amélioration a râté... ❌`) - .setFields([ - { name: '', value: `${updatedSkin.displayName} | ${chromaName()}`, inline: false }, - ]) - .setDescription(updatedSkin.tierText) - .setImage(imageUrl()) - .setColor(0xFF0000); - } - - - // Prepare components if video exists - const video = videoUrl(); - const components = []; - - if (!succeeded) { - components.push(new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setLabel('Réessayer 🔄️') - .setStyle(ButtonStyle.Primary) - .setCustomId(`upgrade_${activeInventories[invId].reqBodyId}`) - )) - } else if (video) { - components.push( - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setLabel('🎬 Aperçu vidéo') - .setStyle(ButtonStyle.Link) - .setURL(video) - ) - ); - } - - // Edit the original message - try { - await DiscordRequest( - `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, - { - method: 'PATCH', - body: { - embeds: [finalEmbed], - components: components - } - } - ); - } catch (err) { - console.error('Error editing message:', err); - } - }, 500); - } - else if (componentId.startsWith('prev_search_page')) { - let searchId = componentId.replace('prev_search_page_', ''); - const context = req.body.context; - // User ID is in user field for (G)DMs, and member for servers - const userId = context === 0 ? req.body.member.user.id : req.body.user.id; - - const guild = await client.guilds.fetch(req.body.guild_id); - if (!activeSearchs[searchId]) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Oups, cet affichage n'est plus actif.\nRelance la commande pour avoir un nouvel élément intéractif`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const chromaText = (skin) => { - let res = "" - for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { - res += skin.currentChroma === i ? '💠 ' : '◾ ' - } - return res - } - const chromaName = (skin) => { - if (skin.currentChroma >= 2) { - const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') - const match = name.match(/variante\s+[1-4]\s+([^)]+)/) - const result = match ? match[2] : null; - if (match) { - return match[1].trim() - } else { - return name - } - } - if (skin.currentChroma === 1) { - return 'Base' - } - return '' - }; - - if (activeSearchs[searchId] && activeSearchs[searchId].userId === req.body.member.user.id) { - if (activeSearchs[searchId].page === 0) { - activeSearchs[searchId].page = activeSearchs[searchId].amount-1 - } else { - activeSearchs[searchId].page-- - } - } else { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Tu n'est pas à l'origine de cette commande /search`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const trueSkin = skins.find((s) => s.uuid === activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].uuid); - const imageUrl = () => { - let res; - if (trueSkin.chromas[trueSkin.chromas.length-1].displayIcon) { - res = trueSkin.chromas[trueSkin.chromas.length-1].displayIcon - } else if (trueSkin.levels[trueSkin.levels.length-1].displayIcon) { - res = trueSkin.levels[trueSkin.levels.length-1].displayIcon - } else { - res = trueSkin.displayIcon - } - return res - }; - - const videoUrl = () => { - let res; - if (trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo) { - res = trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo - } else if (trueSkin.levels[trueSkin.levels.length-1].streamedVideo) { - res = trueSkin.levels[trueSkin.levels.length-1].streamedVideo - } else { - res = null - } - return res - }; - - const owner = await guild.members.fetch(activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].user_id) - let fields = [ - { - name: `**${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].displayName}** | ${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].tierText}`, - value: `${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].maxPrice}€ ${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].user_id ? '| **@'+ owner.user.username +'** ✅' : ''}`, - inline: false, - } - ] - - try { - const originalComponents = req.body.message.components || []; - - await DiscordRequest( - activeSearchs[searchId].endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Résultat de recherche`, - description: `🔎 ${activeSearchs[searchId].searchValue}`, - fields: fields, - color: parseInt(activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].tierColor, 16), - image: { url: imageUrl() }, - footer: { text: `${activeSearchs[searchId].page+1}/${activeSearchs[searchId].resultSkins.length} résultat(s)` }, - }, - ], - components: originalComponents, - }, - } - ); - } catch (err) { - console.log('Pas trouvé : ', err) - } - return res.send({ - type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, - }); - } - else if (componentId.startsWith('next_search_page')) { - let searchId = componentId.replace('next_search_page_', ''); - const context = req.body.context; - // User ID is in user field for (G)DMs, and member for servers - const userId = context === 0 ? req.body.member.user.id : req.body.user.id; - - const guild = await client.guilds.fetch(req.body.guild_id); - if (!activeSearchs[searchId]) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Oups, cet affichage n'est plus actif.\nRelance la commande pour avoir un nouvel élément intéractif`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const chromaText = (skin) => { - let res = "" - for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { - res += skin.currentChroma === i ? '💠 ' : '◾ ' - } - return res - } - const chromaName = (skin) => { - if (skin.currentChroma >= 2) { - const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') - const match = name.match(/variante\s+[1-4]\s+([^)]+)/) - const result = match ? match[2] : null; - if (match) { - return match[1].trim() - } else { - return name - } - } - if (skin.currentChroma === 1) { - return 'Base' - } - return '' - }; - - if (activeSearchs[searchId] && activeSearchs[searchId].userId === req.body.member.user.id) { - if (activeSearchs[searchId].page === activeSearchs[searchId].amount-1) { - activeSearchs[searchId].page = 0 - } else { - activeSearchs[searchId].page++ - } - } else { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Tu n'est pas à l'origine de cette commande /search`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const trueSkin = skins.find((s) => s.uuid === activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].uuid); - const imageUrl = () => { - let res; - if (trueSkin.chromas[trueSkin.chromas.length-1].displayIcon) { - res = trueSkin.chromas[trueSkin.chromas.length-1].displayIcon - } else if (trueSkin.levels[trueSkin.levels.length-1].displayIcon) { - res = trueSkin.levels[trueSkin.levels.length-1].displayIcon - } else { - res = trueSkin.displayIcon - } - return res - }; - - const videoUrl = () => { - let res; - if (trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo) { - res = trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo - } else if (trueSkin.levels[trueSkin.levels.length-1].streamedVideo) { - res = trueSkin.levels[trueSkin.levels.length-1].streamedVideo - } else { - res = null - } - return res - }; - - const owner = await guild.members.fetch(activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].user_id) - let fields = [ - { - name: `**${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].displayName}** | ${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].tierText}`, - value: `${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].maxPrice}€ ${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].user_id ? '| **@'+ owner.user.username +'** ✅' : ''}`, - inline: false, - } - ] - - try { - const originalComponents = req.body.message.components || []; - - await DiscordRequest( - activeSearchs[searchId].endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Résultat de recherche`, - description: `🔎 ${activeSearchs[searchId].searchValue}`, - fields: fields, - color: parseInt(activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].tierColor, 16), - image: { url: imageUrl() }, - footer: { text: `${activeSearchs[searchId].page+1}/${activeSearchs[searchId].resultSkins.length} résultat(s)` }, - }, - ], - components: originalComponents, - }, - } - ); - } catch (err) { - console.log('Pas trouvé : ', err) - } - return res.send({ - type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, - }); - } - else if (componentId.startsWith('option_')) { - const optionId = parseInt(componentId.replace('option_', '')[0]); - const prediId = componentId.replace(`option_${optionId}_`, ''); - let intAmount = 10; - - const commandUserId = req.body.member.user.id - const commandUser = getUser.get(commandUserId); - if (!commandUser) return res.status(403).send({ message: 'Oups, je ne te connais pas'}) - if (commandUser.coins < intAmount) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: 'Tu n\'as pas assez de FlopoCoins', - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const prediObject = activePredis[prediId] - if (!prediObject) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: 'Prédiction introuvable', - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - if (prediObject.endTime < Date.now()) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: 'Les votes de cette prédiction sont clos', - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - const otherOption = optionId === 0 ? 1 : 0; - if (prediObject.options[otherOption].votes.find(v => v.id === commandUserId) && commandUserId !== process.env.DEV_ID) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: 'Tu ne peux pas voter pour les 2 deux options', - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - let stopMaxCoins = false - if (prediObject.options[optionId].votes.find(v => v.id === commandUserId)) { - activePredis[prediId].options[optionId].votes.forEach(v => { - if (v.id === commandUserId) { - if (v.amount >= 250000) { - stopMaxCoins = true - return - } - if (v.amount + intAmount > 250000) { - intAmount = 250000-v.amount - } - v.amount += intAmount - } - }) - } else { - activePredis[prediId].options[optionId].votes.push({ - id: commandUserId, - amount: intAmount, - }) - } - - if (stopMaxCoins) { - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: 'Tu as déjà parié le max (250K)', - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } - - activePredis[prediId].options[optionId].total += intAmount - - activePredis[prediId].options[optionId].percent = (activePredis[prediId].options[optionId].total / (activePredis[prediId].options[otherOption].total + activePredis[prediId].options[optionId].total)) * 100 - activePredis[prediId].options[otherOption].percent = 100 - activePredis[prediId].options[optionId].percent - - io.emit('new-predi', { action: 'new vote' }); - - updateUserCoins.run({ - id: commandUserId, - coins: commandUser.coins - intAmount, - }) - insertLog.run({ - id: commandUserId + '-' + Date.now(), - user_id: commandUserId, - action: 'PREDI_VOTE', - target_user_id: null, - coins_amount: -intAmount, - user_new_amount: commandUser.coins - intAmount, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - - try { - const totalAmount = activePredis[prediId].options[optionId].votes.find(v => v.id === commandUserId)?.amount; - const optionLabel = activePredis[prediId].options[optionId].label; - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Vote enregistré, **${intAmount}** Flopocoins sur **"${optionLabel}"** (**${totalAmount}** au total)`, - flags: InteractionResponseFlags.EPHEMERAL, - }, - }); - } catch (err) { - console.log('Pas trouvé : ', err) - return res.send({ - type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, - }); - } - } - return; - } - - console.error('unknown interaction type', type); - return res.status(400).json({ error: 'unknown interaction type' }); -}); - -app.use(express.json()); -app.use('/public', express.static('public')); - -// Check flAPI -app.get('/check', (req, res) => { - res.status(200).json({ check: true, status: 'OK' }); -}); - -// Get all users ordered by coins -app.get('/users', (req, res) => { - const users = getAllUsers.all(); - res.json(users); -}); - -app.get('/users/by-elo', (req, res) => { - const users = getUsersByElo.all() - res.json(users); -}) - -app.get('/logs', async (req, res) => { - // purge old logs - await pruneOldLogs() - - return res.status(200).json(getLogs.all()) -}) - -app.post('/timedout', async (req, res) => { - const { userId } = req.body - const guild = await client.guilds.fetch(process.env.GUILD_ID); - - let member; - try { - member = await guild.members.fetch(userId); - } catch (e) { - return res.status(404).send({ message: 'Unknown member' }) - } - - return res.status(200).json({ isTimedOut: member?.communicationDisabledUntilTimestamp > Date.now()}) -}) - -// Get user's avatar -app.get('/user/:id/avatar', async (req, res) => { - try { - const userId = req.params.id; // Get the ID from route parameters - const user = await client.users.fetch(userId); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - const avatarUrl = user.displayAvatarURL({ format: 'png', size: 256 }); - res.json({ avatarUrl }); - - } catch (error) { - console.error('Error fetching user avatar'); - res.status(500).json({ error: 'Failed to fetch avatar' }); - } -}) - -app.get('/user/:id/username', async (req, res) => { - try { - const userId = req.params.id; // Get the ID from route parameters - const user = await client.users.fetch(userId); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - res.json({ user }); - } catch (error) { - console.error('Error fetching user'); - res.status(500).json({ error: 'Failed to fetch user' }); - } -}) - -app.get('/user/:id/sparkline', async (req, res) => { - try { - const userId = req.params.id - - const user = getUser.get(userId) - - if (!user) return res.status(404).send({ message: 'Utilisateur introuvable'}) - - return res.status(200).json({ sparkline: getUserLogs.all({user_id: userId}) }) - } catch (e) { - return res.status(500).send({ message: 'erreur'}) - } -}) - -app.get('/user/:id/elo', async (req, res) => { - try { - const userId = req.params.id - - const user = getUser.get(userId) - - if (!user) return res.status(404).send({ message: 'Utilisateur introuvable'}) - - const userElo = getUserElo.get({ id: userId }) - - if (!userElo) return res.status(200).json({ elo: null }) - - return res.status(200).json({ elo: userElo.elo }) - } catch (e) { - return res.status(500).send({ message: 'erreur'}) - } -}) - -app.get('/user/:id/elo-graph', async (req, res) => { - try { - const userId = req.params.id - - const user = getUser.get(userId) - - if (!user) return res.status(404).send({ message: 'Utilisateur introuvable'}) - - - const games = getUserGames.all({ user_id: userId }); - - if (!games) return res.status(404).send({ message: 'Aucune partie'}) - - let array = [] - games.forEach((game, index) => { - if (game.p1 === userId) { - array.push(game.p1_elo) - if (index === games.length - 1) array.push(game.p1_new_elo) - } else if (game.p2 === userId) { - array.push(game.p2_elo) - if (index === games.length - 1) array.push(game.p2_new_elo) - } - }) - - return res.status(200).json({ elo_graph: array }) - } catch (e) { - return res.status(500).send({ message: 'erreur'}) - } -}) - -// Get user's inventory -app.get('/user/:id/inventory', async (req, res) => { - try { - const userId = req.params.id; // Get the ID from route parameters - const user = await client.users.fetch(userId); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - const inventory = getUserInventory.all({user_id: userId}); - res.json({ inventory }); - - } catch (error) { - console.error('Error fetching user avatar'); - res.status(500).json({ error: 'Failed to fetch inventory' }); - } -}) - -app.get('/user/:id/daily', async (req, res) => { - const userId = req.params.id - - const akhy = getUser.get(userId) - - if (!akhy) return res.status(404).send({ message: 'Utilisateur introuvable'}) - - if (akhy.dailyQueried) return res.status(403).send({ message: 'Récompense déjà récupérée'}) - - const amount = 200 - const coins = akhy.coins - - queryDailyReward.run(userId) - updateUserCoins.run({ - id: userId, - coins: coins + amount, - }) - insertLog.run({ - id: userId + '-' + Date.now(), - user_id: userId, - action: 'DAILY_REWARD', - target_user_id: null, - coins_amount: amount, - user_new_amount: coins + amount, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - - return res.status(200).send({ message: 'Récompense récupérée !' }) -}) - -// Get active polls -app.get('/polls', async (req, res) => { - try { - res.json({ activePolls }); - - } catch (error) { - console.error('Error fetching active polls'); - res.status(500).json({ error: 'Failed to fetch active polls' }); - } -}) - -// Send a custom message in the admin command channel -app.post('/send-message', (req, res) => { - const { userId, channelId, message } = req.body; - const channel = client.channels.cache.get(channelId); - - const user = getUser.get(userId); - - if (!user) return res.status(404).json({ error: 'User not found' }); - - if (!channel) return res.status(404).json({ error: 'Channel not found' }); - - if (user.coins < 10) return res.status(403).json({ error: 'Pas assez de coins' }); - - updateUserCoins.run({ - id: userId, - coins: user.coins - 10, - }) - insertLog.run({ - id: userId + '-' + Date.now(), - user_id: userId, - action: 'SEND_MESSAGE', - target_user_id: null, - coins_amount: -10, - user_new_amount: user.coins - 10, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - - channel.send(message) - .then(() => res.json({ success: true })) - .catch(err => res.status(500).json({ error: err.message })); -}); - -// Change user's server specific username -app.post('/change-nickname', async (req, res) => { - const { userId, nickname, commandUserId } = req.body; - - const commandUser = getUser.get(commandUserId); - - if (!commandUser) return res.status(404).json({ message: 'Oups petit soucis' }); - - if (commandUser.coins < 1000) return res.status(403).json({ message: 'Pas assez de coins' }); - - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const member = await guild.members.fetch(userId); - await member.setNickname(nickname); - let message = nickname ? `Le pseudo de '${member.user.tag}' a été changé en '${nickname}'` : `Le pseudo de '${member.user.tag}' a été remis par défaut` - res.status(200).json({ message : message }); - updateUserCoins.run({ - id: commandUserId, - coins: commandUser.coins - 1000, - }) - insertLog.run({ - id: commandUserId + '-' + Date.now(), - user_id: commandUserId, - action: 'CHANGE_NICKNAME', - target_user_id: userId, - coins_amount: -1000, - user_new_amount: commandUser.coins - 1000, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - } catch (error) { - res.status(500).json({ message : `J'ai pas réussi à changer le pseudo` }); - } -}) - -app.post('/spam-ping', async (req, res) => { - const { userId, commandUserId } = req.body; - - const user = getUser.get(userId); - const commandUser = getUser.get(commandUserId); - - if (!commandUser || !user) return res.status(404).json({ message: 'Oups petit soucis' }); - - if (commandUser.coins < 10000) return res.status(403).json({ message: 'Pas assez de coins' }); - - try { - const discordUser = await client.users.fetch(userId); - - await discordUser.send(`<@${userId}>`) - - res.status(200).json({ message : 'C\'est parti ehehe' }); - - updateUserCoins.run({ - id: commandUserId, - coins: commandUser.coins - 10000, - }) - insertLog.run({ - id: commandUserId + '-' + Date.now(), - user_id: commandUserId, - action: 'SPAM_PING', - target_user_id: userId, - coins_amount: -10000, - user_new_amount: commandUser.coins - 10000, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - - for (let i = 0; i < 29; i++) { - await discordUser.send(`<@${userId}>`) - await sleep(1000); - } - } catch (err) { - console.log(err) - res.status(500).json({ message : "Oups ça n'a pas marché" }); - } -}) - -app.post('/timeout/vote', async (req, res) => { - const { commandUserId, voteKey, voteFor } = req.body; - - const commandUser = getUser.get(commandUserId); - const poll = activePolls[voteKey]; - const isVotingFor = voteFor; - - if (!commandUser) return res.status(404).json({ message: 'Oups petit soucis' }); - if (!poll) return res.status(404).json({ message: 'Vote de timeout introuvable' }); - - if (activePolls[voteKey]) { - const poll = activePolls[voteKey]; - poll.voters = poll.voters || []; - const voterId = commandUserId; - - const guild = await client.guilds.fetch(process.env.GUILD_ID) - const commandMember = await guild.members.fetch(commandUserId); - // Check if the voter has the required voting role - const voterRoles = commandMember.roles.cache.map(role => role.id) || []; - if (!voterRoles.includes(process.env.VOTING_ROLE_ID)) { - return res.status(403).json({ message: 'Tu n\'as pas le rôle requis pour voter'}) - } - - // Enforce one vote per eligible user - if (poll.voters.find(u => u === voterId)) { - return res.status(403).json({ message: 'Tu as déjà voté'}) - } - - // Record the vote - poll.voters.push(voterId); - if (isVotingFor) { - poll.for++; - } else { - poll.against++; - } - - io.emit('new-poll', { action: 'new vote' }); - - // Retrieve online eligible users (ensure your bot has the necessary intents) - const guildId = process.env.GUILD_ID; - const roleId = process.env.VOTING_ROLE_ID; // Set this in your .env file - const onlineEligibleUsers = await getOnlineUsersWithRole(guildId, roleId); - const votesNeeded = Math.max(0, poll.requiredMajority - poll.for); - - // Check if the majority is reached - if (poll.for >= poll.requiredMajority) { - try { - // Build the updated poll message content - let forText = '' - poll.voters.forEach((voter) => { - const user = getUser.get(voter); - forText += `- ${user.globalName}\n` - }) - await DiscordRequest( - poll.endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Timeout`, - description: `Proposition de timeout **${poll.toUsername}** pendant ${poll.time_display}`, - fields: [ - { - name: 'Votes totaux', - value: '✅ ' + poll.for + '\n' + forText, - inline: true, - }, - ], - color: 0xF2F3F3, // You can set the color of the embed - }, - ], - components: [], // remove buttons - }, - } - ); - } catch (err) { - console.error('Error updating poll message:', err); - } - // Clear the poll so the setTimeout callback doesn't fire later - delete activePolls[voteKey]; - - // **Actual Timeout Action** - try { - // Calculate the ISO8601 timestamp to disable communications until now + poll.time seconds - const timeoutUntil = new Date(Date.now() + poll.time * 1000).toISOString(); - const endpointTimeout = `guilds/${process.env.GUILD_ID}/members/${poll.toUserId}`; - await DiscordRequest(endpointTimeout, { - method: 'PATCH', - body: { communication_disabled_until: timeoutUntil }, - }); - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `<@${poll.toUserId}> a été timeout pendant ${poll.time_display} par décision démocratique 👊`, - }, - }); - } catch (err) { - console.error('Error timing out user:', err); - return res.send({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Impossible de timeout <@${poll.toUserId}>, désolé... 😔`, - }, - }); - } - } - - // If the vote is "for", update the original poll message to reflect the new vote count. - if (isVotingFor) { - const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000)); - const minutes = Math.floor(remaining / 60); - const seconds = remaining % 60; - const countdownText = `**${minutes}m ${seconds}s** restantes`; - try { - // Build the updated poll message content - let forText = '' - poll.voters.forEach((voter) => { - const user = getUser.get(voter); - forText += `- ${user.globalName}\n` - }) - await DiscordRequest( - poll.endpoint, - { - method: 'PATCH', - body: { - embeds: [ - { - title: `Timeout`, - description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}\nIl manque **${votesNeeded}** vote(s)`, - fields: [ - { - name: 'Pour', - value: '✅ ' + poll.for + '\n' + forText, - inline: true, - }, - { - name: 'Temps restant', - value: '⏳ ' + countdownText, - inline: false, - }, - ], - color: 0xF2F3F3, // You can set the color of the embed - }, - ], - components: req.body.message.components, // preserve the buttons - }, - } - ); - } catch (err) { - console.error('Error updating poll message:', err); - } - } - - return res.status(200).json({ message: 'Vote enregistré !'}) - } -}) - -app.post('/slowmode', async (req, res) => { - let { userId, commandUserId} = req.body - - const user = getUser.get(userId) - const commandUser = getUser.get(commandUserId); - - if (!commandUser || !user) return res.status(404).json({ message: 'Oups petit soucis' }); - - if (commandUser.coins < 10000) return res.status(403).json({ message: 'Pas assez de coins' }); - - if (!user) return res.status(403).send({ message: 'Oups petit problème'}) - - if (activeSlowmodes[userId]) { - if (userId === commandUserId) { - delete activeSlowmodes[userId]; - return res.status(200).json({ message: 'Slowmode retiré'}) - } else { - let timeLeft = (activeSlowmodes[userId].endAt - Date.now())/1000 - timeLeft = timeLeft > 60 ? (timeLeft/60).toFixed()?.toString() + 'min' : timeLeft.toFixed()?.toString() + 'sec' - return res.status(403).json({ message: `${user.globalName} est déjà en slowmode (${timeLeft})`}) - } - } else if (userId === commandUserId) { - return res.status(403).json({ message: 'Impossible de te mettre toi-même en slowmode'}) - } - - activeSlowmodes[userId] = { - userId: userId, - endAt: Date.now() + 60 * 60 * 1000, // 1 heure - lastMessage: null, - }; - io.emit('new-slowmode', { action: 'new slowmode' }); - - updateUserCoins.run({ - id: commandUserId, - coins: commandUser.coins - 10000, - }) - insertLog.run({ - id: commandUserId + '-' + Date.now(), - user_id: commandUserId, - action: 'SLOWMODE', - target_user_id: userId, - coins_amount: -10000, - user_new_amount: commandUser.coins - 10000, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - - return res.status(200).json({ message: `${user.globalName} est maintenant en slowmode pour 1h`}) -}) - -app.get('/slowmodes', async (req, res) => { - res.status(200).json({ slowmodes: activeSlowmodes }); -}) - -app.post('/start-predi', async (req, res) => { - let { commandUserId, label, options, closingTime, payoutTime } = req.body - - const commandUser = getUser.get(commandUserId) - - if (!commandUser) return res.status(403).send({ message: 'Oups petit problème'}) - if (commandUser.coins < 100) return res.status(403).send({ message: 'Tu n\'as pas assez de FlopoCoins'}) - - if (Object.values(activePredis).find(p => p.creatorId === commandUserId && (p.endTime > Date.now() && !p.closed))) { - return res.status(403).json({ message: `Tu ne peux pas lancer plus d'une prédi à la fois !`}) - } - - const startTime = Date.now() - const newPrediId = commandUserId?.toString() + '-' + startTime?.toString() - - let msgId; - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = guild.channels.cache.find( - ch => ch.name === 'général' || ch.name === 'general' - ); - const embed = new EmbedBuilder() - .setTitle(`Prédiction de ${commandUser.username}`) - .setDescription(`**${label}**`) - .addFields( - { name: `${options[0]}`, value: ``, inline: true }, - { name: ``, value: `ou`, inline: true }, - { name: `${options[1]}`, value: ``, inline: true } - ) - .setFooter({ text: `${formatTime(closingTime).replaceAll('*', '')} pour voter` }) - .setColor('#5865f2') - .setTimestamp(new Date()); - - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId(`option_0_${newPrediId}`) - .setLabel(`+10 sur '${options[0]}'`) - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`option_1_${newPrediId}`) - .setLabel(`+10 sur '${options[1]}'`) - .setStyle(ButtonStyle.Primary) - ); - - const row2 = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel('Voter sur FlopoSite') - .setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/dashboard`) - .setStyle(ButtonStyle.Link) - ) - - const msg = await generalChannel.send({ embeds: [embed], components: [row, row2] }); - msgId = msg.id; - } catch (e) { - return res.status(500).send({ message: 'Erreur lors de l\'envoi du message'}) - } - - const formattedOptions = [ - { label: options[0], votes: [], total: 0, percent: 0, }, - { label: options[1], votes: [], total: 0, percent: 0, }, - ] - activePredis[newPrediId] = { - creatorId: commandUserId, - label: label, - options: formattedOptions, - startTime: startTime, - closingTime: startTime + (closingTime * 1000), - endTime: startTime + (closingTime * 1000) + (payoutTime * 1000), - closed: false, - winning: null, - cancelledTime: null, - paidTime: null, - msgId: msgId, - }; - io.emit('new-predi', { action: 'new predi' }); - - updateUserCoins.run({ - id: commandUserId, - coins: commandUser.coins - 100, - }) - insertLog.run({ - id: commandUserId + '-' + Date.now(), - user_id: commandUserId, - action: 'START_PREDI', - target_user_id: null, - coins_amount: -100, - user_new_amount: commandUser.coins - 100, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - - return res.status(200).json({ message: `Ta prédi '${label}' a commencée !`}) -}) - -app.get('/predis', async (req, res) => { - const reversedPredis = Object.entries(activePredis).reverse(); - - const openEntries = []; - const closedEntries = []; - - for (const [key, value] of reversedPredis) { - if (value.closed === true) { - closedEntries.push([key, value]); - } else { - openEntries.push([key, value]); - } - } - - const reorderedPredis = Object.fromEntries([...openEntries, ...closedEntries]); - - res.status(200).json({ predis: reorderedPredis }); -}); - -app.post('/vote-predi', async (req, res) => { - const { commandUserId, predi, amount, option } = req.body - - let warning = false; - - let intAmount = parseInt(amount) - if (intAmount < 10 || intAmount > 250000) return res.status(403).send({ message: 'Montant invalide'}) - - const commandUser = getUser.get(commandUserId) - if (!commandUser) return res.status(403).send({ message: 'Oups, je ne te connais pas'}) - if (commandUser.coins < intAmount) return res.status(403).send({ message: 'Tu n\'as pas assez de FlopoCoins'}) - - const prediObject = activePredis[predi] - if (!prediObject) return res.status(403).send({ message: 'Prédiction introuvable'}) - - if (prediObject.endTime < Date.now()) return res.status(403).send({ message: 'Les votes de cette prédiction sont clos'}) - - const otherOption = option === 0 ? 1 : 0; - if (prediObject.options[otherOption].votes.find(v => v.id === commandUserId) && commandUserId !== process.env.DEV_ID) return res.status(403).send({ message: 'Tu ne peux pas voter pour les 2 deux options'}) - - if (prediObject.options[option].votes.find(v => v.id === commandUserId)) { - activePredis[predi].options[option].votes.forEach(v => { - if (v.id === commandUserId) { - if (v.amount === 250000) { - return res.status(403).send({ message: 'Tu as déjà parié le max (250K)'}) - } - if (v.amount + intAmount > 250000) { - intAmount = 250000-v.amount - warning = true - } - v.amount += intAmount - } - }) - } else { - activePredis[predi].options[option].votes.push({ - id: commandUserId, - amount: intAmount, - }) - } - activePredis[predi].options[option].total += intAmount - - activePredis[predi].options[option].percent = (activePredis[predi].options[option].total / (activePredis[predi].options[otherOption].total + activePredis[predi].options[option].total)) * 100 - activePredis[predi].options[otherOption].percent = 100 - activePredis[predi].options[option].percent - - io.emit('new-predi', { action: 'new vote' }); - - updateUserCoins.run({ - id: commandUserId, - coins: commandUser.coins - intAmount, - }) - insertLog.run({ - id: commandUserId + '-' + Date.now(), - user_id: commandUserId, - action: 'PREDI_VOTE', - target_user_id: null, - coins_amount: -intAmount, - user_new_amount: commandUser.coins - intAmount, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - - return res.status(200).send({ message : `Vote enregistré!` }); -}) - -app.post('/end-predi', async (req, res) => { - const { commandUserId, predi, confirm, winningOption } = req.body - - const commandUser = getUser.get(commandUserId) - if (!commandUser) return res.status(403).send({ message: 'Oups, je ne te connais pas'}) - if (commandUserId !== process.env.DEV_ID) return res.status(403).send({ message: 'Tu n\'as pas les permissions requises' }) - - const prediObject = activePredis[predi] - if (!prediObject) return res.status(403).send({ message: 'Prédiction introuvable'}) - if (prediObject.closed) return res.status(403).send({ message: 'Prédiction déjà close'}) - - if (!confirm) { - activePredis[predi].cancelledTime = new Date(); - activePredis[predi].options[0].votes.forEach((v) => { - const tempUser = getUser.get(v.id) - try { - updateUserCoins.run({ - id: v.id, - coins: tempUser.coins + v.amount - }) - insertLog.run({ - id: v.id + '-' + Date.now(), - user_id: v.id, - action: 'PREDI_REFUND', - target_user_id: v.id, - coins_amount: v.amount, - user_new_amount: tempUser.coins + v.amount, - }) - } catch (e) { - console.log(`Impossible de rembourser ${v.id} (${v.amount} coins)`) - } - }) - activePredis[predi].options[1].votes.forEach((v) => { - const tempUser = getUser.get(v.id) - try { - updateUserCoins.run({ - id: v.id, - coins: tempUser.coins + v.amount - }) - insertLog.run({ - id: v.id + '-' + Date.now(), - user_id: v.id, - action: 'PREDI_REFUND', - target_user_id: v.id, - coins_amount: v.amount, - user_new_amount: tempUser.coins + v.amount, - }) - } catch (e) { - console.log(`Impossible de rembourser ${v.id} (${v.amount} coins)`) - } - }) - activePredis[predi].closed = true; - } - else { - const losingOption = winningOption === 0 ? 1 : 0; - activePredis[predi].options[winningOption].votes.forEach((v) => { - const tempUser = getUser.get(v.id) - const ratio = activePredis[predi].options[winningOption].total === 0 ? 0 : activePredis[predi].options[losingOption].total / activePredis[predi].options[winningOption].total - try { - updateUserCoins.run({ - id: v.id, - coins: tempUser.coins + (v.amount * (1 + ratio)) - }) - insertLog.run({ - id: v.id + '-' + Date.now(), - user_id: v.id, - action: 'PREDI_RESULT', - target_user_id: v.id, - coins_amount: v.amount * (1 + ratio), - user_new_amount: tempUser.coins + (v.amount * (1 + ratio)), - }) - } catch (e) { - console.log(`Impossible de créditer ${v.id} (${v.amount} coins pariés, *${1 + ratio})`) - } - }) - activePredis[predi].paidTime = new Date(); - activePredis[predi].closed = true; - activePredis[predi].winning = winningOption; - } - - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = guild.channels.cache.find( - ch => ch.name === 'général' || ch.name === 'general' - ); - const message = await generalChannel.messages.fetch(activePredis[predi].msgId) - const updatedEmbed = new EmbedBuilder() - .setTitle(`Prédiction de ${commandUser.username}`) - .setDescription(`**${activePredis[predi].label}**`) - .setFields({ name: `${activePredis[predi].options[0].label}`, value: ``, inline: true }, - { name: ``, value: `ou`, inline: true }, - { name: `${activePredis[predi].options[1].label}`, value: ``, inline: true }, - ) - .setFooter({ text: `${activePredis[predi].cancelledTime !== null ? 'Prédi annulée' : 'Prédi confirmée !' }` }) - .setTimestamp(new Date()); - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel('Voir') - .setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/dashboard`) - .setStyle(ButtonStyle.Link) - ) - await message.edit({ embeds: [updatedEmbed], components: [row] }); - } catch (err) { - console.error('Error updating prédi message:', err); - } - - io.emit('new-predi', { action: 'closed predi' }); - io.emit('data-updated', { table: 'users', action: 'fin predi' }); - - return res.status(200).json({ message: 'Prédi close' }); -}) - -// ADMIN Add coins -app.post('/add-coins', (req, res) => { - const { commandUserId } = req.body; - - const commandUser = getUser.get(commandUserId); - - if (!commandUser) return res.status(404).json({ error: 'User not found' }); - if (commandUserId !== process.env.DEV_ID) return res.status(404).json({ error: 'Not admin' }); - - updateUserCoins.run({ - id: commandUserId, - coins: commandUser.coins + 1000, - }) - insertLog.run({ - id: commandUserId + '-' + Date.now(), - user_id: commandUserId, - action: 'ADD_COINS', - target_user_id: commandUserId, - coins_amount: 1000, - user_new_amount: commandUser.coins + 1000, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - - res.status(200).json({ message : `+1000` }); -}); - -app.post('/buy-coins', (req, res) => { - const { commandUserId, coins } = req.body; - - const commandUser = getUser.get(commandUserId); - - if (!commandUser) return res.status(404).json({ error: 'User not found' }); - - updateUserCoins.run({ - id: commandUserId, - coins: commandUser.coins + coins, - }) - insertLog.run({ - id: commandUserId + '-' + Date.now(), - user_id: commandUserId, - action: 'ADD_COINS', - target_user_id: commandUserId, - coins_amount: coins, - user_new_amount: commandUser.coins + coins, - }) - io.emit('data-updated', { table: 'users', action: 'update' }); - - res.status(200).json({ message : `+${coins}` }); -}); - -const pokerRooms = {} -app.post('/create-poker-room', async (req, res) => { - const { creatorId } = req.body - const id = uuidv4() - const t12names = [ - 'cassoule', - 'passoule', - 'kiwiko', - 'piwiko', - 'wata', - 'pata', - 'apologize', - 'apologay', - 'daspoon', - 'esteban', - 'edorima', - 'momozhok', - 'popozhok', - 'dodozhok', - 'flopozhok', - 'thomas', - 'poma' - ] - const name = uniqueNamesGenerator({ dictionaries: [adjectives, t12names], separator: ' ', style: 'capital' }); - - const creator = await client.users.fetch(creatorId) - - if (!creator) { - return res.status(404).send({message: 'Utilisateur introuvable'}) - } - if (Object.values(pokerRooms).find(room => room.host_id === creatorId)) { - return res.status(403).send({message: 'Tu ne peux créer qu\'une seule table à la fois'}) - } - - const alreadyInARoom = Object.values(pokerRooms).find((room) => { - return Object.keys(room.players).includes(creatorId) - }) - - if (alreadyInARoom) return res.status(403).send({ message: 'Tu es déjà assis à une table' }) - - pokerRooms[id] = { - id: id, - host_id: creatorId, - host_name: creator.globalName, - name: name, - created_at: Date.now(), - last_move_at: Date.now(), - players: {}, - queue: {}, - afk: {}, - pioche: initialShuffledCards(), - tapis: [], - dealer: null, - sb: null, - bb: null, - highest_bet: null, - current_player: null, - current_turn: null, - playing: false, - winners: [], - waiting_for_restart: false, - fakeMoney: false, - } - - res.status(200).send({ roomId: id }) - - try { - const url = (process.env.DEV_SITE === 'true' ? process.env.API_URL_DEV : process.env.API_URL) + '/poker-room/join' - const response = await axios.post(url, { userId: creatorId, roomId: id }) - } catch (e) { - console.log(e) - } - - io.emit('new-poker-room') -}); - -app.get('/poker-rooms', (req, res) => { - return res.status(200).send({ rooms: pokerRooms }) -}) - -app.get('/poker-rooms/:id', (req, res) => { - return res.status(200).send({ room: pokerRooms[req.params.id] }) -}) - -app.post('/poker-room/join', async (req, res) => { - const { userId, roomId } = req.body - - const user = await client.users.fetch(userId) - - const alreadyInARoom = Object.values(pokerRooms).find((room) => { - return Object.keys(room.players).includes(userId) - }) - - if (alreadyInARoom) return res.status(403).send({ message: 'Déjà assis à une table' }) - - let amount = getUser.get(userId)?.coins - let fakeMoney = false - - if (!amount || amount < 1000) { - amount = 1000 - fakeMoney = true - } - - const player = { - id: user.id, - globalName: user.globalName, - hand: [], - bank: amount, - bet: null, - solve: null, - folded: false, - allin: false, - last_played_turn: null, - is_last_raiser: false, - } - - try { - if (pokerRooms[roomId].playing) { - pokerRooms[roomId].queue[userId] = player - } else { - pokerRooms[roomId].players[userId] = player - } - if (pokerRooms[roomId].afk[userId]) { - delete pokerRooms[roomId].afk[userId] - } - if (fakeMoney) pokerRooms[roomId].fakeMoney = true - } catch (e) { - // - } - - io.emit('new-poker-room') - return res.status(200) -}); - -app.post('/poker-room/accept', async (req, res) => { - const { userId, roomId } = req.body - - const player = pokerRooms[roomId].queue[userId] - - if (!player) return res.status(404).send({ message: 'Joueur introuvable dans le file d\'attente'}); - - try { - pokerRooms[roomId].players[userId] = player - delete pokerRooms[roomId].queue[userId] - if (pokerRooms[roomId].afk[userId]) { - delete pokerRooms[roomId].afk[userId] - } - } catch (e) { - // - } - - io.emit('new-poker-room') - return res.status(200) -}) - -app.post('/poker-room/kick', async (req, res) => { - //TODO -}) - -app.post('/poker-room/leave', async (req, res) => { - const { userId, roomId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - if (!pokerRooms[roomId].players[userId]) return res.status(404).send({ message: 'Joueur introuvable' }) - - if (pokerRooms[roomId].playing && (pokerRooms[roomId].current_turn !== null && pokerRooms[roomId].current_turn !== 4)) { - pokerRooms[roomId].afk[userId] = pokerRooms[roomId].players[userId] - - try { - pokerRooms[roomId].players[userId].folded = true - pokerRooms[roomId].players[userId].last_played_turn = pokerRooms[roomId].current_turn - } catch(e) { - console.log(e) - } - - io.emit('new-poker-room') - - return res.status(200) - } - - try { - delete pokerRooms[roomId].players[userId] - - if (userId === pokerRooms[roomId].host_id) { - const newHostId = Object.keys(pokerRooms[roomId].players).find(id => id !== userId) - if (!newHostId) { - delete pokerRooms[roomId] - } else { - pokerRooms[roomId].host_id = newHostId - } - } - } catch (e) { - // - } - - io.emit('new-poker-room') - return res.status(200) -}); - -app.post('/poker-room/start', async (req, res) => { - const { roomId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - - // preflop - try { - for (const playerId in pokerRooms[roomId].players) { - const player = pokerRooms[roomId].players[playerId] - for (let i = 0; i < 2; i++) { - if (pokerRooms[roomId].pioche.length > 0) { - player.hand.push(pokerRooms[roomId].pioche[0]) - pokerRooms[roomId].pioche.shift() - } - } - } - for (const playerId in pokerRooms[roomId].players) { - try { - const player = pokerRooms[roomId].players[playerId] - let fullHand = pokerRooms[roomId].tapis - player.solve = Hand.solve(fullHand.concat(player.hand), 'standard', false)?.descr - } catch (e) { - console.log('erreur lors du hand solver') - } - } - } catch (e) { - console.log(e) - } - - pokerRooms[roomId].dealer = Object.keys(pokerRooms[roomId].players)[0] - pokerRooms[roomId].sb = Object.keys(pokerRooms[roomId].players)[1] - pokerRooms[roomId].bb = Object.keys(pokerRooms[roomId].players)[2 % Object.keys(pokerRooms[roomId].players).length] - pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[1]].bet = 10 //SB - pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[1]].bank -= 10 //SB - pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[2 % Object.keys(pokerRooms[roomId].players).length]].bet = 20 //BB - pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[2 % Object.keys(pokerRooms[roomId].players).length]].bank -= 20 //BB - pokerRooms[roomId].highest_bet = 20 - pokerRooms[roomId].current_player = Object.keys(pokerRooms[roomId].players)[3 % Object.keys(pokerRooms[roomId].players).length] - pokerRooms[roomId].current_turn = 0; - - pokerRooms[roomId].players[pokerRooms[roomId].bb].last_played_turn = pokerRooms[roomId].current_turn - - if (!pokerRooms[roomId].fakeMoney) { - const DB_SBplayer = await getUser.get(Object.keys(pokerRooms[roomId].players)[1]) - const DB_BBplayer = await getUser.get(Object.keys(pokerRooms[roomId].players)[2 % Object.keys(pokerRooms[roomId].players).length]) - if (DB_SBplayer) { - updateUserCoins.run({ - id: DB_SBplayer.id, - coins: pokerRooms[roomId].players[DB_SBplayer.id].bank, - }) - insertLog.run({ - id: DB_SBplayer.id + '-' + Date.now(), - user_id: DB_SBplayer.id, - action: 'POKER_SMALL_BLIND', - target_user_id: DB_SBplayer.id, - coins_amount: -10, - user_new_amount: DB_SBplayer.coins - 10, - }) - } - if (DB_BBplayer) { - updateUserCoins.run({ - id: DB_BBplayer.id, - coins: pokerRooms[roomId].players[DB_BBplayer.id].bank, - }) - insertLog.run({ - id: DB_BBplayer.id + '-' + Date.now(), - user_id: DB_BBplayer.id, - action: 'POKER_BIG_BLIND', - target_user_id: DB_BBplayer.id, - coins_amount: -20, - user_new_amount: DB_BBplayer.coins - 20, - }) - } - io.emit('data-updated', {table: 'users', action: 'update'}); - } - - pokerRooms[roomId].playing = true - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return res.status(200) -}) - -async function handleRoomStart(roomId, dealerId = 0) { - if (!pokerRooms[roomId]) return false - - // preflop - try { - for (const playerId in pokerRooms[roomId].players) { - const player = pokerRooms[roomId].players[playerId] - for (let i = 0; i < 2; i++) { - if (pokerRooms[roomId].pioche.length > 0) { - player.hand.push(pokerRooms[roomId].pioche[0]) - pokerRooms[roomId].pioche.shift() - } - } - } - for (const playerId in pokerRooms[roomId].players) { - try { - const player = pokerRooms[roomId].players[playerId] - let fullHand = pokerRooms[roomId].tapis - player.solve = Hand.solve(fullHand.concat(player.hand), 'standard', false)?.descr - } catch(e) { - console.log('erreur lors du hand solver') - } - } - } catch (e) { - console.log(e) - } - - pokerRooms[roomId].dealer = Object.keys(pokerRooms[roomId].players)[(dealerId + 1) % Object.keys(pokerRooms[roomId].players).length] - pokerRooms[roomId].sb = Object.keys(pokerRooms[roomId].players)[(dealerId + 2) % Object.keys(pokerRooms[roomId].players).length] - pokerRooms[roomId].bb = Object.keys(pokerRooms[roomId].players)[(dealerId + 3) % Object.keys(pokerRooms[roomId].players).length] - pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[(dealerId + 2) % Object.keys(pokerRooms[roomId].players).length]].bet = 10 //SB - pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[(dealerId + 2) % Object.keys(pokerRooms[roomId].players).length]].bank -= 10 //SB - pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[(dealerId + 3) % Object.keys(pokerRooms[roomId].players).length]].bet = 20 //BB - pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[(dealerId + 3) % Object.keys(pokerRooms[roomId].players).length]].bank -= 20 //BB - pokerRooms[roomId].highest_bet = 20 - pokerRooms[roomId].current_player = Object.keys(pokerRooms[roomId].players)[(dealerId + 4) % Object.keys(pokerRooms[roomId].players).length] - pokerRooms[roomId].current_turn = 0; - - pokerRooms[roomId].players[pokerRooms[roomId].bb].last_played_turn = pokerRooms[roomId].current_turn - - if (!pokerRooms[roomId].fakeMoney) { - const DB_SBplayer = await getUser.get(Object.keys(pokerRooms[roomId].players)[(dealerId + 2) % Object.keys(pokerRooms[roomId].players).length]) - const DB_BBplayer = await getUser.get(Object.keys(pokerRooms[roomId].players)[(dealerId + 3) % Object.keys(pokerRooms[roomId].players).length]) - if (DB_SBplayer) { - updateUserCoins.run({ - id: DB_SBplayer.id, - coins: pokerRooms[roomId].players[DB_SBplayer.id].bank, - }) - insertLog.run({ - id: DB_SBplayer.id + '-' + Date.now(), - user_id: DB_SBplayer.id, - action: 'POKER_SMALL_BLIND', - target_user_id: DB_SBplayer.id, - coins_amount: -10, - user_new_amount: DB_SBplayer.coins - 10, - }) - } - if (DB_BBplayer) { - updateUserCoins.run({ - id: DB_BBplayer.id, - coins: pokerRooms[roomId].players[DB_BBplayer.id].bank, - }) - insertLog.run({ - id: DB_BBplayer.id + '-' + Date.now(), - user_id: DB_BBplayer.id, - action: 'POKER_BIG_BLIND', - target_user_id: DB_BBplayer.id, - coins_amount: -20, - user_new_amount: DB_BBplayer.coins - 20, - }) - } - io.emit('data-updated', {table: 'users', action: 'update'}); - } - - pokerRooms[roomId].playing = true - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return true -} - -app.post('/poker-room/flop', async (req, res) => { - const { roomId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - - //flop - pokerRooms[roomId].current_turn = 1 - try { - for (let i = 0; i < 3; i++) { - if (pokerRooms[roomId].pioche.length > 0) { - pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) - pokerRooms[roomId].pioche.shift() - } - } - await updatePokerPlayersSolve(roomId) - } catch(e) { - console.log(e) - } - - pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return res.status(200) -}); - -async function handleFlop(roomId) { - if (!pokerRooms[roomId]) return false - - //flop - pokerRooms[roomId].current_turn = 1 - try { - for (let i = 0; i < 3; i++) { - if (pokerRooms[roomId].pioche.length > 0) { - pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) - pokerRooms[roomId].pioche.shift() - } - } - await updatePokerPlayersSolve(roomId) - } catch(e) { - console.log(e) - } - - pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return true -} - -app.post('/poker-room/turn', async (req, res) => { - const { roomId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - - //turn - pokerRooms[roomId].current_turn = 2 - try { - if (pokerRooms[roomId].pioche.length > 0) { - pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) - pokerRooms[roomId].pioche.shift() - } - - await updatePokerPlayersSolve(roomId) - } catch(e) { - console.log(e) - } - - pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return res.status(200) -}); - -async function handleTurn(roomId) { - if (!pokerRooms[roomId]) return false - - //turn - pokerRooms[roomId].current_turn = 2 - try { - if (pokerRooms[roomId].pioche.length > 0) { - pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) - pokerRooms[roomId].pioche.shift() - } - - await updatePokerPlayersSolve(roomId) - } catch(e) { - console.log(e) - } - - pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return true -} - -app.post('/poker-room/river', async (req, res) => { - const { roomId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - - //river - pokerRooms[roomId].current_turn = 3 - try { - if (pokerRooms[roomId].pioche.length > 0) { - pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) - pokerRooms[roomId].pioche.shift() - } - - await updatePokerPlayersSolve(roomId) - } catch(e) { - console.log(e) - } - - pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return res.status(200) -}); - -async function handleRiver(roomId) { - if (!pokerRooms[roomId]) return false - - //river - pokerRooms[roomId].current_turn = 3 - try { - if (pokerRooms[roomId].pioche.length > 0) { - pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) - pokerRooms[roomId].pioche.shift() - } - - await updatePokerPlayersSolve(roomId) - } catch(e) { - console.log(e) - } - - pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return true -} - -app.post('/poker-room/showdown', async (req, res) => { - const { roomId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - - //showdown - pokerRooms[roomId].current_turn = 4 - pokerRooms[roomId].current_player = null - - await updatePokerPlayersSolve(roomId) - - pokerRooms[roomId].winners = checkRoomWinners(pokerRooms[roomId]) - - try { - const url = (process.env.DEV_SITE === 'true' ? process.env.API_URL_DEV : process.env.API_URL) + '/poker-room/winner' - const response = await axios.post(url, { roomId: roomId, winnerIds: pokerRooms[roomId].winners }) - } catch (e) { - console.log(e) - } - - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return res.status(200) -}) - -async function handleShowdown(roomId) { - if (!pokerRooms[roomId]) return false - - //showdown - pokerRooms[roomId].current_turn = 4 - pokerRooms[roomId].current_player = null - - await updatePokerPlayersSolve(roomId) - - pokerRooms[roomId].winners = checkRoomWinners(pokerRooms[roomId]) - - try { - await handleWinner(roomId, pokerRooms[roomId].winners) - } catch (e) { - console.log(e) - } - - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') - return true -} - -app.post('/poker-room/progressive-showdown', async (req, res) => { - const { roomId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - - while(pokerRooms[roomId].current_turn < 4) { - let allGood = true - switch (pokerRooms[roomId].current_turn) { - case 0: - allGood = await handleFlop(roomId) - break; - case 1: - allGood = await handleTurn(roomId) - break; - case 2: - allGood = await handleRiver(roomId) - break; - case 3: - allGood = await handleShowdown(roomId) - break; - default: - allGood = false - break; - } - - if (!allGood) console.log('error in progressive showdown') - - await sleep(1000) - } - - return res.status(200) -}) - -app.post('/poker-room/winner', async (req, res) => { - const { roomId, winnerIds } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - //if (!pokerRooms[roomId].players[winnerIds]) return res.status(404).send({ message: 'Joueur introuvable' }) - - pokerRooms[roomId].current_player = null - pokerRooms[roomId].current_turn = 4 - - let pool = 0; - for (const playerId in pokerRooms[roomId].players) { - const player = pokerRooms[roomId].players[playerId] - pool += player?.bet ?? 0 - player.bet = 0 - if (player.bank === 0 && !pokerRooms[roomId].winners.includes(player.id)) { - try { - delete pokerRooms[roomId].players[player.id] - - if (player.id === pokerRooms[roomId].host_id) { - const newHostId = Object.keys(pokerRooms[roomId].players).find(id => id !== player.id) - if (!newHostId) { - delete pokerRooms[roomId] - } else { - pokerRooms[roomId].host_id = newHostId - } - } - } catch (e) { - // - } - } - } - - pokerRooms[roomId].winners.forEach((winner) => { - pokerRooms[roomId].players[winner].bank += Math.floor(pool / winnerIds.length) - if (!pokerRooms[roomId].fakeMoney) { - const DBplayer = getUser.get(winner) - if (DBplayer) { - updateUserCoins.run({ - id: winner, - coins: pokerRooms[roomId].players[winner].bank, - }) - insertLog.run({ - id: winner + '-' + Date.now(), - user_id: winner, - action: 'POKER_WIN', - target_user_id: winner, - coins_amount: Math.floor(pool / winnerIds.length), - user_new_amount: pokerRooms[roomId].players[winner].bank, - }) - } - io.emit('data-updated', {table: 'users', action: 'update'}); - } - }); - - pokerRooms[roomId].waiting_for_restart = true - - io.emit('player-winner', { roomId: roomId, playerIds: winnerIds, amount: Math.floor(pool / winnerIds.length) }) - - await pokerEloHandler(pokerRooms[roomId]) - - io.emit('new-poker-room') - return res.status(200) -}) - -async function handleWinner(roomId, winnerIds) { - if (!pokerRooms[roomId]) return false - - pokerRooms[roomId].current_player = null - pokerRooms[roomId].current_turn = 4 - - let pool = 0; - for (const playerId in pokerRooms[roomId].players) { - const player = pokerRooms[roomId].players[playerId] - pool += player?.bet ?? 0 - player.bet = 0 - if (player.bank === 0 && !pokerRooms[roomId].winners.includes(player.id)) { - try { - delete pokerRooms[roomId].players[player.id] - } catch (e) { - // - } - } - } - - pokerRooms[roomId].winners = checkRoomWinners(pokerRooms[roomId]) - - pokerRooms[roomId]?.winners.forEach((winner) => { - pokerRooms[roomId].players[winner].bank += Math.floor(pool / winnerIds.length) - }); - - pokerRooms[roomId].waiting_for_restart = true - - io.emit('player-winner', { roomId: roomId, playerIds: pokerRooms[roomId].winners, amount: Math.floor(pool / winnerIds.length) }) - - await pokerEloHandler(pokerRooms[roomId]) - - io.emit('new-poker-room') - return true -} - -app.post('/poker-room/next-round', async (req, res) => { - const { roomId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - - const dealerId = Object.keys(pokerRooms[roomId].players).findIndex(p => p === pokerRooms[roomId].dealer) - console.log('dealer id', dealerId) - - pokerRooms[roomId].waiting_for_restart = false - pokerRooms[roomId].winners = [] - pokerRooms[roomId].pioche = initialShuffledCards() - pokerRooms[roomId].tapis = [] - pokerRooms[roomId].dealer = null - pokerRooms[roomId].sb = null - pokerRooms[roomId].bb = null - pokerRooms[roomId].highest_bet = null - pokerRooms[roomId].current_player = null - pokerRooms[roomId].current_turn = null - - for (const playerId in pokerRooms[roomId].afk) { - try { - delete pokerRooms[roomId].players[playerId] - } catch (e) { console.log(e) } - try { - delete pokerRooms[roomId].afk[playerId] - } catch (e) { console.log(e) } - } - - for (const playerId in pokerRooms[roomId].players) { - const player = pokerRooms[roomId].players[playerId] - player.hand = [] - player.bet = null - player.solve = null - player.folded = false - player.allin = false - player.last_played_turn = null - player.is_last_raiser = false - } - - try { - await handleRoomStart(roomId, dealerId) - } catch (e) { - console.log(e) - } - - io.emit('new-poker-room') - return res.status(200) -}) - -app.post('/poker-room/action/fold', async (req, res) => { - const { roomId, playerId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - if (!pokerRooms[roomId].players[playerId]) return res.status(404).send({ message: 'Joueur introuvable' }) - - if (pokerRooms[roomId].current_player !== playerId) return res.status(403).send({ message: 'Ce n\'est pas ton tour' }); - - try { - pokerRooms[roomId].players[playerId].folded = true - pokerRooms[roomId].players[playerId].last_played_turn = pokerRooms[roomId].current_turn - - io.emit('player-fold', { roomId: roomId, playerId: playerId, playerName: pokerRooms[roomId].players[playerId].globalName }) - - await checksAfterPokerAction(roomId) - - io.emit('new-poker-room') - } catch(e) { - console.log(e) - return res.status(500).send({ message: e}) - } - - return res.status(200) -}); - -app.post('/poker-room/action/check', async (req, res) => { - const { roomId, playerId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - if (!pokerRooms[roomId].players[playerId]) return res.status(404).send({ message: 'Joueur introuvable' }) - - if (pokerRooms[roomId].current_player !== playerId) return res.status(403).send({ message: 'Ce n\'est pas ton tour' }); - - try { - pokerRooms[roomId].players[playerId].last_played_turn = pokerRooms[roomId].current_turn - - io.emit('player-check', { roomId: roomId, playerId: playerId, playerName: pokerRooms[roomId].players[playerId].globalName }) - - await checksAfterPokerAction(roomId) - - io.emit('new-poker-room') - } catch(e) { - console.log(e) - return res.status(500).send({ message: e}) - } - - return res.status(200) -}); - -app.post('/poker-room/action/call', async (req, res) => { - const { roomId, playerId } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - if (!pokerRooms[roomId].players[playerId]) return res.status(404).send({ message: 'Joueur introuvable' }) - - if (pokerRooms[roomId].current_player !== playerId) return res.status(403).send({ message: 'Ce n\'est pas ton tour' }); - - try { - let diff = pokerRooms[roomId].highest_bet - pokerRooms[roomId].players[playerId].bet - if (diff > pokerRooms[roomId].players[playerId].bank) { - diff = pokerRooms[roomId].players[playerId].bank - pokerRooms[roomId].players[playerId].allin = true - } - pokerRooms[roomId].players[playerId].bet += diff - pokerRooms[roomId].players[playerId].bank -= diff - pokerRooms[roomId].players[playerId].last_played_turn = pokerRooms[roomId].current_turn - - if (Object.values(pokerRooms[roomId].players).find(p => p.allin)) pokerRooms[roomId].players[playerId].allin = true - if (!pokerRooms[roomId].fakeMoney) { - const DBplayer = await getUser.get(playerId) - if (DBplayer) { - updateUserCoins.run({ - id: playerId, - coins: pokerRooms[roomId].players[playerId].bank, - }) - insertLog.run({ - id: playerId + '-' + Date.now(), - user_id: playerId, - action: 'POKER_CALL', - target_user_id: playerId, - coins_amount: -diff, - user_new_amount: pokerRooms[roomId].players[playerId].bank, - }) - } - io.emit('data-updated', { table: 'users', action: 'update' }); - } - - io.emit('player-call', { roomId: roomId, playerId: playerId, playerName: pokerRooms[roomId].players[playerId].globalName }) - - await checksAfterPokerAction(roomId) - - io.emit('new-poker-room') - } catch(e) { - console.log(e) - return res.status(500).send({ message: e}) - } - - return res.status(200) -}); - -app.post('/poker-room/action/raise', async (req, res) => { - const { roomId, playerId, amount } = req.body - - if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) - if (!pokerRooms[roomId].players[playerId]) return res.status(404).send({ message: 'Joueur introuvable' }) - - if (pokerRooms[roomId].current_player !== playerId) return res.status(403).send({ message: 'Ce n\'est pas ton tour' }); - if (amount > pokerRooms[roomId].players[playerId].bank) return res.status(403).send({ message: 'Tu n\as pas assez'}); - - try { - if (amount === pokerRooms[roomId].players[playerId].bank) { - pokerRooms[roomId].players[playerId].allin = true - } - pokerRooms[roomId].players[playerId].bet += amount - pokerRooms[roomId].players[playerId].bank -= amount - pokerRooms[roomId].players[playerId].last_played_turn = pokerRooms[roomId].current_turn - for (let id in pokerRooms[roomId].players) { - pokerRooms[roomId].players[id].is_last_raiser = false - } - pokerRooms[roomId].players[playerId].is_last_raiser = true - pokerRooms[roomId].highest_bet = pokerRooms[roomId].players[playerId].bet - - if (!pokerRooms[roomId].fakeMoney) { - const DBplayer = await getUser.get(playerId) - if (DBplayer) { - updateUserCoins.run({ - id: playerId, - coins: DBplayer.coins - amount, - }) - insertLog.run({ - id: playerId + '-' + Date.now(), - user_id: playerId, - action: 'POKER_RAISE', - target_user_id: playerId, - coins_amount: -amount, - user_new_amount: DBplayer.coins - amount, - }) - } - io.emit('data-updated', { table: 'users', action: 'update' }); - } - - io.emit('player-raise', { roomId: roomId, playerId: playerId, amount: amount, playerName: pokerRooms[roomId].players[playerId].globalName }) - - await checksAfterPokerAction(roomId) - - io.emit('new-poker-room') - } catch(e) { - console.log(e) - return res.status(500).send({ message: e}) - } - - return res.status(200) -}); - -async function checksAfterPokerAction(roomId) { - const data = checkEndOfBettingRound(pokerRooms[roomId]) - - if (data.winner !== null) { - try { - pokerRooms[roomId].winners = [data.winner] - const url = (process.env.DEV_SITE === 'true' ? process.env.API_URL_DEV : process.env.API_URL) + '/poker-room/winner' - const response = await axios.post(url, { roomId: roomId, winnerIds: [data.winner] }) - } catch (e) { - console.log(e) - } - } else if (data.endRound) { - try { - const url = (process.env.DEV_SITE === 'true' ? process.env.API_URL_DEV : process.env.API_URL) + '/poker-room/' + data.nextPhase - const response = await axios.post(url, { roomId: roomId}) - } catch (e) { - console.log(e) - } - } else { - pokerRooms[roomId].current_player = getNextActivePlayer(pokerRooms[roomId]) - } - - pokerRooms[roomId].last_move_at = Date.now() - - io.emit('new-poker-room') -} - -async function updatePokerPlayersSolve(roomId) { - for (const playerId in pokerRooms[roomId].players) { - const player = pokerRooms[roomId].players[playerId] - let fullHand = pokerRooms[roomId].tapis - if (!fullHand && !player.hand) { - player.solve = Hand.solve([], 'standard', false)?.descr - } else if (!fullHand) { - player.solve = Hand.solve(player.hand, 'standard', false)?.descr - } else if (!player.hand) { - player.solve = Hand.solve(fullHand, 'standard', false)?.descr - } else { - player.solve = Hand.solve(fullHand.concat(player.hand), 'standard', false)?.descr - } - } -} - -app.get('/solitaire/sotd/rankings', async (req, res) => { - const rankings = getAllSOTDStats.all() - - return res.json({ rankings }) -}) - -app.post('/solitaire/start', async (req, res) => { - const userId = req.body.userId; - let userSeed = req.body.userSeed; - - if (activeSolitaireGames[userId] && !activeSolitaireGames[userId].isSOTD) { - return res.json({ succes: true, gameState: activeSolitaireGames[userId]}) - } - - if (userSeed) { - let numericSeed = 0 - for (let i = 0; i < userSeed.length; i++) { - numericSeed = (numericSeed + userSeed.charCodeAt(i)) & 0xFFFFFFFF; - } - - const rng = createSeededRNG(numericSeed); - const deck = createDeck() - const shuffledDeck = seededShuffle(deck, rng); - const gameState = deal(shuffledDeck); - gameState.seed = userSeed; - - activeSolitaireGames[userId] = gameState; - - return res.json({ success: true, gameState }); - } else { - const newRandomSeed = Date.now()?.toString(36) + Math.random()?.toString(36).substr(2); - let numericSeed = 0; - for (let i = 0; i < newRandomSeed.length; i++) { - numericSeed = (numericSeed + newRandomSeed.charCodeAt(i)) & 0xFFFFFFFF; - } - - const rng = createSeededRNG(numericSeed); - const deck = createDeck(); - const shuffledDeck = seededShuffle(deck, rng); - const gameState = deal(shuffledDeck); - gameState.seed = newRandomSeed; - - activeSolitaireGames[userId] = gameState; - - return res.json({ success: true, gameState }); - } -}); - -app.post('/solitaire/start/sotd', async (req, res) => { - const userId = req.body.userId - const sotd = getSOTD.get(); - - const user = getUser.get(userId); - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - if (activeSolitaireGames[userId] && activeSolitaireGames[userId].isSOTD) { - return res.json({ success: true, gameState: activeSolitaireGames[userId]}) - } - - const gameState = { - tableauPiles: JSON.parse(sotd.tableauPiles), - foundationPiles: JSON.parse(sotd.foundationPiles), - stockPile: JSON.parse(sotd.stockPile), - wastePile: JSON.parse(sotd.wastePile), - isDone: false, - isSOTD: true, - hasFinToday: false, - startTime: Date.now(), - endTime: null, - moves: 0, - score: 0, - seed: sotd.seed, - } - - activeSolitaireGames[userId] = gameState - res.json({ success: true, gameState }); -}) - -app.post('/solitaire/reset', async (req, res) => { - const userId = req.body.userId; - delete activeSolitaireGames[userId] - res.json({ success: true }); -}); - -/** - * GET /solitaire/state/:userId - * Gets the current game state for a user. If no game exists, creates a new one. - */ -app.get('/solitaire/state/:userId', (req, res) => { - const { userId } = req.params; - let gameState = activeSolitaireGames[userId]; - /*if (!gameState) { - const deck = shuffle(createDeck()); - gameState = deal(deck); - activeSolitaireGames[userId] = gameState; - }*/ - res.json({ success: true, gameState }); -}); - -/** - * POST /solitaire/move - * Receives all necessary move data from the frontend. - */ -app.post('/solitaire/move', async (req, res) => { - // Destructure the complete move data from the request body - // Frontend must send all these properties. - const { - userId, - sourcePileType, - sourcePileIndex, - sourceCardIndex, - destPileType, - destPileIndex - } = req.body; - - const gameState = activeSolitaireGames[userId]; - - if (!gameState) { - return res.status(404).json({ error: 'Game not found for this user.' }); - } - - // Pass the entire data object to the validation function - if (isValidMove(gameState, req.body)) { - // If valid, mutate the state - await moveCard(gameState, req.body); - const win = checkWinCondition(gameState); - if (win) { - gameState.isDone = true - if (gameState.isSOTD) { - gameState.hasFinToday = true; - gameState.endTime = Date.now(); - const userStats = getUserSOTDStats.get(userId); - if (userStats) { - if ( - (gameState.score > userStats.score) || - (gameState.score === userStats.score && gameState.moves < userStats.moves) || - (gameState.score === userStats.score && gameState.moves === userStats.moves && gameState.time < userStats.time) - ) { - deleteUserSOTDStats.run(userId); - insertSOTDStats.run({ - id: userId, - user_id: userId, - time: gameState.endTime - gameState.startTime, - moves: gameState.moves, - score: gameState.score, - }) - } - } else { - insertSOTDStats.run({ - id: userId, - user_id: userId, - time: gameState.endTime - gameState.startTime, - moves: gameState.moves, - score: gameState.score, - }) - const user = getUser.get(userId) - if (user) { - updateUserCoins.run({ id: userId, coins: user.coins + 1000 }); - insertLog.run({ - id: userId + '-' + Date.now(), - user_id: userId, - action: 'SOTD_WIN', - target_user_id: null, - coins_amount: 1000, - user_new_amount: user.coins + 1000, - }) - } - } - } - } - res.json({ success: true, gameState, win, endTime: win ? Date.now() : null }); - } else { - // If the move is invalid, send a specific error message - res.status(400).json({ error: 'Invalid move' }); - } -}); - -/** - * POST /solitaire/draw - * Draws a card from the stock pile to the waste pile. - */ -app.post('/solitaire/draw', async (req, res) => { - const { userId } = req.body; - const gameState = activeSolitaireGames[userId]; - if (!gameState) { - return res.status(404).json({ error: `Game not found for user ${userId}` }); - } - await drawCard(gameState); - res.json({ success: true, gameState }); -}); - import http from 'http'; import { Server } from 'socket.io'; -import * as test from "node:test"; + +import { app } from './src/server/app.js'; +import { client } from './src/bot/client.js'; +import { initializeEvents } from './src/bot/events.js'; +import { initializeSocket } from './src/server/socket.js'; +import { getAkhys, setupCronJobs } from './src/utils/index.js'; + +// --- SERVER INITIALIZATION --- +const PORT = process.env.PORT || 25578; const server = http.createServer(app); +// --- SOCKET.IO INITIALIZATION --- +const FLAPI_URL = process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL; const io = new Server(server, { - cors: { - Origin: FLAPI_URL, - methods: ['GET', 'POST', 'PUT', 'OPTIONS'], - } + cors: { + origin: FLAPI_URL, + methods: ['GET', 'POST', 'PUT', 'OPTIONS'], + }, +}); +initializeSocket(io, client); + + +// --- BOT INITIALIZATION --- +initializeEvents(client, io); +client.login(process.env.BOT_TOKEN).then(() => { + console.log(`Logged in as ${client.user.tag}`); + console.log('[Discord Bot Events Initialized]'); }); -let queue = [] -let playingArray = [] -let connect4Queue = [] -let connect4PlayingArray = [] -export const C4_ROWS = 6 -export const C4_COLS = 7 +// --- APP STARTUP --- +server.listen(PORT, async () => { + console.log(`Express+Socket.IO server listening on port ${PORT}`); + console.log(`[Connected with ${FLAPI_URL}]`); -io.on('connection', (socket) => { + // Initial data fetch and setup + await getAkhys(client); - socket.on('user-connected', async (user) => { - const username = getUser.get(user) - - queue = queue.filter(obj => obj !== user) - let names = []; - for (const n of queue) { - let name = await client.users.fetch(n) - names.push(name?.globalName) - } - io.emit('tictactoequeue', { allPlayers: playingArray, queue: names }) - - connect4Queue = connect4Queue.filter(obj => obj !== user) - let C4names = [] - for (const n of connect4Queue) { - let name = await client.users.fetch(n) - C4names.push(name?.globalName) - } - - io.emit('connect4queue', { allPlayers: playingArray, queue: C4names }) - }) - - socket.on('tictactoeconnection', async (e) => { - queue = queue.filter(obj => obj !== e.id) - let names = []; - for (const n of queue) { - let name = await client.users.fetch(n) - names.push(name?.globalName) - } - io.emit('tictactoequeue', { allPlayers: playingArray, queue: names }) - }) - - socket.on('connect4connection', async (e) => { - connect4Queue = connect4Queue.filter(obj => obj !== e.id) - let names = []; - for (const n of connect4Queue) { - let name = await client.users.fetch(n) - names.push(name?.globalName) - } - io.emit('connect4queue', { allPlayers: playingArray, queue: names }) - }) - - socket.on('tictactoequeue', async (e) => { - console.log(`${e.playerId} in tic tac toe queue`); - - if (playingArray.find(obj => obj.p1.id === e.playerId || obj.p2.id === e.playerId)) { - let names = []; - for (const n of queue) { - let name = await client.users.fetch(n) - names.push(name?.globalName) - } - io.emit('tictactoequeue', { allPlayers: playingArray, queue: names }) - return - } - - let msgId; - - if (!queue.find(obj => obj === e.playerId)) { - queue.push(e.playerId) - - if (queue.length === 1) { - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = guild.channels.cache.find( - ch => ch.name === 'général' || ch.name === 'general' - ); - const user = await client.users.fetch(e.playerId) - - const embed = new EmbedBuilder() - .setTitle(`Tic Tac Toe`) - .setDescription(`**${user.username}** est dans la file d'attente`) - .setColor('#5865f2') - .setTimestamp(new Date()); - - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel(`Jouer contre ${user.username}`) - .setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/tic-tac-toe`) - .setStyle(ButtonStyle.Link) - ) - - await generalChannel.send({ embeds: [embed], components: [row] }); - } catch (e) { - console.log(e) - } - } - } - - if (queue.length >= 2) { - let p1 = await client.users.fetch(queue[0]) - let p2 = await client.users.fetch(queue[1]) - - let msgId - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = guild.channels.cache.find( - ch => ch.name === 'général' || ch.name === 'general' - ); - - const embed = new EmbedBuilder() - .setTitle(`Tic Tac Toe`) - .setDescription(`### **❌ ${p1.globalName}** vs **${p2.globalName} ⭕**\n` + - `🟦🟦🟦\n` + - `🟦🟦🟦\n` + - `🟦🟦🟦\n`) - .setColor('#5865f2') - .setTimestamp(new Date()); - - const msg = await generalChannel.send({ embeds: [embed] }); - msgId = msg.id - } catch (e) { - console.log(e) - } - - let p1obj = { - id: queue[0], - name: p1.globalName, - val: 'X', - move: "", - } - let p2obj = { - id: queue[1], - name: p2.globalName, - val: 'O', - move: "", - } - - let lobby = { - p1: p1obj, - p2: p2obj, - sum: 1, - xs: [], - os: [], - lastmove: Date.now(), - msgId: msgId, - } - - playingArray.push(lobby) - - queue.splice(0, 2) - } - - let names = []; - for (const n of queue) { - let name = await client.users.fetch(n) - names.push(name?.globalName) - } - - io.emit('tictactoequeue', { allPlayers: playingArray, queue: names }) - }) - - socket.on('tictactoeplaying', async (e) => { - console.log('playing', e.value) - let lobbyToChange; - if (e.value === 'X') { - lobbyToChange = playingArray.find(obj => obj.p1.id === e.playerId) - - if (lobbyToChange.sum%2 === 1) { - console.log('yeah', e.value) - lobbyToChange.p2.move = '' - lobbyToChange.p1.move = e.boxId - lobbyToChange.sum++ - lobbyToChange.xs.push(e.boxId) - lobbyToChange.lastmove = Date.now() - } - } - else if (e.value === 'O') { - lobbyToChange = playingArray.find(obj => obj.p2.id === e.playerId) - - if (lobbyToChange.sum%2 === 0) { - console.log('yeah', e.value) - lobbyToChange.p1.move = '' - lobbyToChange.p2.move = e.boxId - lobbyToChange.sum++ - lobbyToChange.os.push(e.boxId) - lobbyToChange.lastmove = Date.now() - } - } - - let gridText = '' - for (let i = 1; i <= 9; i++) { - if (lobbyToChange.os.includes(i)) { - gridText += '⭕' - } else if (lobbyToChange.xs.includes(i)) { - gridText += '❌' - } else { - gridText += '🟦' - } - if (i%3 === 0) { - gridText += '\n' - } - } - - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = await guild.channels.cache.find( - ch => ch.name === 'général' || ch.name === 'general' - ); - - const message = await generalChannel.messages.fetch(lobbyToChange.msgId) - - const embed = new EmbedBuilder() - .setTitle(`Tic Tac Toe`) - .setDescription(`### **❌ ${lobbyToChange.p1.name}** vs **${lobbyToChange.p2.name} ⭕**\n` + gridText) - .setColor('#5865f2') - .setTimestamp(new Date()); - - await message.edit({ embeds: [embed] }); - } catch (e) { - console.log(e) - } - - io.emit('tictactoeplaying', { allPlayers: playingArray }) - }) - - socket.on('tictactoegameOver', async (e) => { - const winner = e.winner - const game = playingArray.find(obj => obj.p1.id === e.playerId) - - if (game && game.sum < 100) { - game.sum = 100 - let gridText = '' - for (let i = 1; i <= 9; i++) { - if (game.os.includes(i)) { - gridText += '⭕' - } else if (game.xs.includes(i)) { - gridText += '❌' - } else { - gridText += '🟦' - } - if (i%3 === 0) { - gridText += '\n' - } - } - - if (winner === null) { - await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, 'TICTACTOE') - - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = await guild.channels.cache.find( - ch => ch.name === 'général' || ch.name === 'general' - ); - - const message = await generalChannel.messages.fetch(game.msgId) - - const embed = new EmbedBuilder() - .setTitle(`Tic Tac Toe`) - .setDescription(`### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n` + gridText + `\n### Égalité`) - .setColor('#5865f2') - .setTimestamp(new Date()); - - await message.edit({ embeds: [embed] }); - } catch (e) { - console.log(e) - } - } else { - await eloHandler(game.p1.id, game.p2.id, game.p1.id === winner ? 1 : 0, game.p2.id === winner ? 1 : 0, 'TICTACTOE') - - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = await guild.channels.cache.find( - ch => ch.name === 'général' || ch.name === 'general' - ); - - const message = await generalChannel.messages.fetch(game.msgId) - - const embed = new EmbedBuilder() - .setTitle(`Tic Tac Toe`) - .setDescription(`### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n` + gridText + `\n### Victoire de ${game.p1.id === winner ? game.p1.name : game.p2.name}`) - .setColor('#5865f2') - .setTimestamp(new Date()); - - await message.edit({ embeds: [embed] }); - } catch (e) { - console.log(e) - } - - } - } - - playingArray = playingArray.filter(obj => obj.p1.id !== e.playerId) - }) - - socket.on('connect4queue', async (e) => { - console.log(`${e.playerId} in Connect 4 queue`); - - if (connect4PlayingArray.find(obj => obj.p1.id === e.playerId || obj.p2.id === e.playerId)) { - let names = []; - for (const n of connect4Queue) { - let name = await client.users.fetch(n); - names.push(name?.globalName); - } - io.emit('connect4queue', { allPlayers: connect4PlayingArray, queue: names }); - return - } - - if (!connect4Queue.find(obj => obj === e.playerId)) { - connect4Queue.push(e.playerId); - } - - if (connect4Queue.length === 1) { - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = guild.channels.cache.find( - ch => ch.name === 'général' || ch.name === 'general' - ); - const user = await client.users.fetch(e.playerId) - - const embed = new EmbedBuilder() - .setTitle(`Puissance 4`) - .setDescription(`**${user.username}** est dans la file d'attente`) - .setColor('#5865f2') - .setTimestamp(new Date()); - - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel(`Jouer contre ${user.username}`) - .setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/connect-4`) - .setStyle(ButtonStyle.Link) - ) - - await generalChannel.send({ embeds: [embed], components: [row] }); - } catch (e) { - console.log(e) - } - } - - if (connect4Queue.length >= 2) { - const p1Id = connect4Queue[0]; - const p2Id = connect4Queue[1]; - const p1 = await client.users.fetch(p1Id); - const p2 = await client.users.fetch(p2Id); - let msgId; - - const board = createConnect4Board(); - const boardText = formatConnect4BoardForDiscord(board); - - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = guild.channels.cache.find(ch => ch.name === 'général' || ch.name === 'general'); - const embed = new EmbedBuilder() - .setTitle('Puissance 4') - .setDescription(`**🔴 ${p1.globalName}** vs **${p2.globalName} 🟡**\n\n${boardText}`) - .setColor('#5865f2') - .setTimestamp(new Date()); - const msg = await generalChannel.send({ embeds: [embed] }); - msgId = msg.id; - } catch (err) { - console.error("Error sending Connect 4 start message:", err); - } - - const lobby = { - p1: { id: p1Id, name: p1.globalName, val: 'R' }, - p2: { id: p2Id, name: p2.globalName, val: 'Y' }, - turn: p1Id, - sum: 1, - board: board, - msgId: msgId, - gameOver: false, - lastmove: Date.now(), - winningPieces: [] - }; - - connect4PlayingArray.push(lobby); - connect4Queue.splice(0, 2); - } - - let names = []; - for (const n of connect4Queue) { - let name = await client.users.fetch(n); - names.push(name?.globalName); - } - io.emit('connect4queue', { allPlayers: connect4PlayingArray, queue: names }); - }); - - socket.on('connect4playing', async (e) => { - const lobby = connect4PlayingArray.find(l => (l.p1.id === e.playerId || l.p2.id === e.playerId) && !l.gameOver); - if (!lobby || lobby.turn !== e.playerId) return; - - const player = lobby.p1.id === e.playerId ? lobby.p1 : lobby.p2; - const col = e.col; - - // Drop the piece - lobby.lastmove = Date.now() - lobby.sum++ - let row; - for (row = C4_ROWS - 1; row >= 0; row--) { - if (lobby.board[row][col] === null) { - lobby.board[row][col] = player.val; - break; - } - } - - // Check for win - const winCheck = checkConnect4Win(lobby.board, player.val); - if (winCheck.win) { - lobby.gameOver = true; - lobby.winningPieces = winCheck.pieces; - await eloHandler(lobby.p1.id, lobby.p2.id, lobby.p1.id === player.id ? 1 : 0, lobby.p2.id === player.id ? 1 : 0, 'CONNECT4'); - io.emit('connect4gameOver', { game: lobby, winner: player.id }); - - connect4PlayingArray = connect4PlayingArray.filter(obj => obj.p1.id !== lobby.p1.id) - } - // Check for draw - else if (checkConnect4Draw(lobby.board)) { - lobby.gameOver = true; - await eloHandler(lobby.p1.id, lobby.p2.id, 0.5, 0.5, 'CONNECT4'); - io.emit('connect4gameOver', { game: lobby, winner: 'draw' }); - connect4PlayingArray = connect4PlayingArray.filter(obj => obj.p1.id !== lobby.p1.id) - } - // Switch turns - else { - lobby.turn = lobby.p1.id === player.id ? lobby.p2.id : lobby.p1.id; - } - - // Update Discord message - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = guild.channels.cache.find(ch => ch.name === 'général' || ch.name === 'general'); - const message = await generalChannel.messages.fetch(lobby.msgId); - let description = `**🔴 ${lobby.p1.name}** vs **${lobby.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(lobby.board)}`; - if (lobby.gameOver) { - if(winCheck.win) { - description += `\n\n### Victoire de ${player.name}!`; - } else { - description += `\n\n### Match Nul!`; - } - } - const embed = new EmbedBuilder() - .setTitle('Puissance 4') - .setDescription(description) - .setColor(lobby.gameOver ? '#2ade2a' : '#5865f2') - .setTimestamp(new Date()); - await message.edit({ embeds: [embed] }); - } catch (err) { - console.error("Error updating Connect 4 message:", err); - } - - if (!winCheck.win && !checkConnect4Draw(lobby.board)) { - io.emit('connect4playing', { allPlayers: connect4PlayingArray }); - } - }); - - socket.on('connect4NoTime', async (e) => { - const lobby = connect4PlayingArray.find(l => (l.p1.id === e.playerId || l.p2.id === e.playerId) && !l.gameOver); - const winner = e.winner - - if (lobby) { - lobby.gameOver = true; - await eloHandler(lobby.p1?.id, lobby.p2?.id, lobby.p1?.id === winner ? 1 : 0, lobby.p2?.id === winner ? 1 : 0, 'CONNECT4'); - - try { - const guild = await client.guilds.fetch(process.env.GUILD_ID); - const generalChannel = guild.channels.cache.find(ch => ch.name === 'général' || ch.name === 'general'); - const message = await generalChannel.messages.fetch(lobby.msgId); - let description = `**🔴 ${lobby.p1.name}** vs **${lobby.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(lobby.board)}`; - description += `\n\n### Victoire de ${lobby.p1.id === winner ? lobby.p1.name : lobby.p2.name}! (temps écoulé)`; - const embed = new EmbedBuilder() - .setTitle('Puissance 4') - .setDescription(description) - .setColor(lobby.gameOver ? '#2ade2a' : '#5865f2') - .setTimestamp(new Date()); - await message.edit({ embeds: [embed] }); - } catch (err) { - console.error("Error updating Connect 4 message:", err); - } - - try { - connect4PlayingArray = connect4PlayingArray.filter(obj => obj.p1.id !== lobby.p1.id) - } catch (e) { - console.log(e) - } - } - }); -}); - -server.listen(PORT, () => { - console.log(`Express+Socket.IO listening on port ${PORT}`); -}); + // Setup scheduled tasks + setupCronJobs(client, io); + console.log('[Cron Jobs Initialized]'); + console.log('--- FlopoBOT is ready ---'); +}); \ No newline at end of file diff --git a/old_index.js b/old_index.js new file mode 100644 index 0000000..85ec644 --- /dev/null +++ b/old_index.js @@ -0,0 +1,5167 @@ +/* +import 'dotenv/config'; +import express from 'express'; +import { + ButtonStyleTypes, + InteractionResponseFlags, + InteractionResponseType, + InteractionType, + MessageComponentTypes, + verifyKeyMiddleware, +} from 'discord-interactions'; +import { + getRandomEmoji, + DiscordRequest, + //getOnlineUsersWithRole, + formatTime, + gork, + getRandomHydrateText, + getAPOUsers, + postAPOBuy, + initialShuffledCards, + getFirstActivePlayerAfterDealer, + getNextActivePlayer, checkEndOfBettingRound, initialCards, checkRoomWinners, pruneOldLogs +} from './utils.js'; +import { + channelPointsHandler, checkConnect4Draw, checkConnect4Win, createConnect4Board, + eloHandler, formatConnect4BoardForDiscord, + pokerEloHandler, + randomSkinPrice, + slowmodesHandler, + deal, isValidMove, moveCard, seededShuffle, drawCard, checkWinCondition, createDeck, initTodaysSOTD, createSeededRNG, +} from './game.js'; +import { Client, GatewayIntentBits, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import cron from 'node-cron'; +import Database from "better-sqlite3"; +import { + flopoDB, + insertUser, + insertManyUsers, + updateUser, + updateManyUsers, + getUser, + getAllUsers, + stmtUsers, + stmtSkins, + updateManySkins, + insertSkin, + updateSkin, + insertManySkins, + getAllSkins, + getSkin, + getAllAvailableSkins, + getUserInventory, + getTopSkins, + updateUserCoins, + insertLog, + stmtLogs, + getLogs, + getUserLogs, + getUserElo, + getUserGames, + getUsersByElo, + resetDailyReward, + queryDailyReward, + deleteSOTD, + insertSOTD, getSOTD, insertSOTDStats, deleteUserSOTDStats, getUserSOTDStats, getAllSOTDStats, +} from './init_database.js'; +import { getValorantSkins, getSkinTiers } from './valo.js'; +import {sleep} from "openai/core"; +import { v4 as uuidv4 } from 'uuid'; +import { uniqueNamesGenerator, adjectives, languages, animals } from 'unique-names-generator'; +import pkg from 'pokersolver'; +const { Hand } = pkg; +import axios from 'axios'; + +// Create an express app +const app = express(); +// Get port, or default to 25578 +const PORT = process.env.PORT || 25578; +const FLAPI_URL = process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL + +app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', FLAPI_URL); + res.header('Access-Control-Allow-Headers', 'Content-type, X-API-Key, ngrok-skip-browser-warning'); + next(); +}); +// To keep track of our active games +const activeGames = {}; +const activeSolitaireGames = {}; +const activePolls = {}; +const activeInventories = {}; +const activeSearchs = {}; +const activeSlowmodes = {}; +const activePredis = {}; +let todaysSOTD = {}; +const SPAM_INTERVAL = process.env.SPAM_INTERVAL + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, // For guild events + GatewayIntentBits.GuildMessages, // For messages in guilds + GatewayIntentBits.MessageContent, // For reading message content (privileged intent) + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildPresences, + ] +}); + +const requestTimestamps = new Map(); // userId => [timestamp1, timestamp2, ...] +const MAX_REQUESTS_PER_INTERVAL = parseInt(process.env.MAX_REQUESTS || "5"); + +const akhysData= new Map() +export const skins = [] + +async function getAkhys() { + try { + stmtUsers.run(); + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const members = await guild.members.fetch(); // Fetch all members + + const akhys = members.filter(m => !m.user.bot && m.roles.cache.has(process.env.AKHY_ROLE_ID)); + + akhys.forEach(akhy => { + akhysData.set(akhy.user.id, { + id: akhy.user.id, + username: akhy.user.username, + globalName: akhy.user.globalName, + warned: false, + warns: 0, + allTimeWarns: 0, + totalRequests: 0, + }); + insertManyUsers([ + { + id: akhy.user.id, + username: akhy.user.username, + globalName: akhy.user.globalName, + warned: 0, + warns: 0, + allTimeWarns: 0, + totalRequests: 0 + }, + ]); + }); + } catch (err) { + console.error('Error while counting akhys:', err); + } + try { + stmtSkins.run(); + + const fetchedSkins = await getValorantSkins() + const fetchedTiers = await getSkinTiers() + + fetchedSkins.forEach((skin) => { + const chromas = [] + const levels = [] + skin.chromas.forEach((chroma) => { + chromas.push({ + uuid: chroma.uuid, + displayName: chroma.displayName, + displayIcon: chroma.displayIcon, + fullRender: chroma.fullRender, + swatch: chroma.swatch, + streamedVideo: chroma.streamedVideo, + }) + }) + skin.levels.forEach((level) => { + levels.push({ + uuid: level.uuid, + displayName: level.displayName, + displayIcon: level.displayIcon, + streamedVideo: level.streamedVideo, + }) + }) + skins.push({ + uuid: skin.uuid, + displayName: skin.displayName, + contentTierUuid: skin.contentTierUuid, + displayIcon: skin.displayIcon, + chromas: chromas, + levels: levels, + }) + }) + + let newSkinCount = 0; + let newSkinText = ''; + for (const skin of skins) { + try { + if (skin.contentTierUuid !== null) { + const tierRank = () => { + const tier = fetchedTiers.filter((tier) => { return tier.uuid === skin.contentTierUuid})[0] + const rank = tier ? tier['rank'] : null; + return rank ? rank + 1 : 0; + } + const tierColor = () => { + const tier = fetchedTiers.filter((tier) => { return tier.uuid === skin.contentTierUuid})[0] + return tier ? tier['highlightColor']?.slice(0, 6) : 'F2F3F3' + } + const tierText = () => { + const tier = fetchedTiers.filter((tier) => { return tier.uuid === skin.contentTierUuid})[0] + const rank = tier ? tier['rank'] : null; + let res; + if (rank === null) return 'Pas de tier'; + switch(rank) { + case 0: + res = '**<:select:1362964319498670222> Select**' + break + case 1: + res = '**<:deluxe:1362964308094488797> Deluxe**' + break + case 2: + res = '**<:premium:1362964330349330703> Premium**' + break + case 3: + res = '**<:exclusive:1362964427556651098> Exclusive**' + break + case 4: + res = '**<:ultra:1362964339685986314> Ultra**' + break + default: + return 'Pas de tier' + } + res += skin.displayName.includes('VCT') ? ' | Esports Edition' : '' + res += skin.displayName.toLowerCase().includes('champions') ? ' | Champions' : '' + res += skin.displayName.toLowerCase().includes('arcane') ? ' | Arcane' : '' + return res + } + const basePrice = () => { + let res; + if (skin.displayName.toLowerCase().includes('classic')){ + res = 150; + } else if (skin.displayName.toLowerCase().includes('shorty')) { + res = 300; + } else if (skin.displayName.toLowerCase().includes('frenzy')) { + res = 450; + } else if (skin.displayName.toLowerCase().includes('ghost')) { + res = 500; + } else if (skin.displayName.toLowerCase().includes('sheriff')) { + res = 800; + } else if (skin.displayName.toLowerCase().includes('stinger')) { + res = 1100; + } else if (skin.displayName.toLowerCase().includes('spectre')) { + res = 1600; + } else if (skin.displayName.toLowerCase().includes('bucky')) { + res = 850; + } else if (skin.displayName.toLowerCase().includes('judge')) { + res = 1850; + } else if (skin.displayName.toLowerCase().includes('bulldog')) { + res = 2050; + } else if (skin.displayName.toLowerCase().includes('guardian')) { + res = 2250; + } else if (skin.displayName.toLowerCase().includes('phantom')) { + res = 2900; + } else if (skin.displayName.toLowerCase().includes('vandal')) { + res = 2900; + } else if (skin.displayName.toLowerCase().includes('marshal')) { + res = 950; + } else if (skin.displayName.toLowerCase().includes('outlaw')) { + res = 2400; + } else if (skin.displayName.toLowerCase().includes('operator')) { + res = 4700; + } else if (skin.displayName.toLowerCase().includes('ares')) { + res = 1600; + } else if (skin.displayName.toLowerCase().includes('odin')) { + res = 3200; + } else { + res = 6000; + } + + res *= (1 + (tierRank())) + res *= skin.displayName.includes('VCT') ? 1.25 : 1; + res *= skin.displayName.toLowerCase().includes('champions') ? 2 : 1; + res *= skin.displayName.toLowerCase().includes('arcane') ? 1.5 : 1; + res *= 1+(Math.random()/100) // [1 to 1.01] + + return (res/1111).toFixed(2); + } + + const skinBasePrice = basePrice(); + + const maxPrice = (price) => { + let res = price + + res *= (1 + (skin.levels.length / Math.max(skin.levels.length, 2))) + res *= (1 + (skin.chromas.length / 4)) + + return res.toFixed(2); + } + + await insertSkin.run( + { + uuid: skin.uuid, + displayName: skin.displayName, + contentTierUuid: skin.contentTierUuid, + displayIcon: skin.displayIcon, + user_id: null, + tierRank: tierRank(), + tierColor: tierColor(), + tierText: tierText(), + basePrice: skinBasePrice, + currentLvl: null, + currentChroma: null, + currentPrice: null, + maxPrice: maxPrice(skinBasePrice), + }); + newSkinCount++; + newSkinText += skin.displayName + ' | '; + } + } catch (e) { + // + } + } + console.log(`New skins : ${newSkinCount}`); + if (newSkinCount <= 30 && newSkinCount > 0) console.log(newSkinText); + } catch (e) { + console.error('Error while fetching skins:', e); + } + try { + stmtLogs.run() + } catch (e) { + console.log('Logs table init error') + } +} + +async function getOnlineUsersWithRole(guild_id=process.env.GUILD_ID, role_id=process.env.VOTING_ROLE_ID) { + try { + const guild = await client.guilds.fetch(guild_id); + const members = await guild.members.fetch(); // Fetch all members + + const online = members.filter(m => !m.user.bot && m.presence?.status && m.roles.cache.has(role_id)); + return online + } catch (err) { + console.error('Error while counting online members:', err); + } +} + +// Login to Discord using bot token (optional) +client.login(process.env.BOT_TOKEN).then(r => console.log('')); + +// Listen for message events +client.on('messageCreate', async (message) => { + // Ignore messages from bots to avoid feedback loops + if (message.author.bot) return; + + // hihihiha + if (message.author.id === process.env.PATA_ID) { + if (message.content.startsWith('feur') + || message.content.startsWith('rati')) { + await sleep(1000) + await message.delete() + } + } + + // coins mechanic and slowmodes check + if (message.guildId === process.env.GUILD_ID) { + channelPointsHandler(message) + io.emit('data-updated', { table: 'users', action: 'update' }); + const deletedSlowmode = await slowmodesHandler(message, activeSlowmodes) + if (deletedSlowmode) io.emit('new-slowmode', { action: 'deleted slowmode' }); + } + + if (message.content.toLowerCase().startsWith(`<@${process.env.APP_ID}>`) || message.mentions.repliedUser?.id === process.env.APP_ID) { + let startTime = Date.now() + let akhyAuthor = await getUser.get(message.author.id) + + const now = Date.now(); + const timestamps = requestTimestamps.get(message.author.id) || []; + + // Remove timestamps older than SPAM_INTERVAL seconds + const updatedTimestamps = timestamps.filter(ts => now - ts < SPAM_INTERVAL); + + if (updatedTimestamps.length >= MAX_REQUESTS_PER_INTERVAL) { + console.log(akhyAuthor.warned ? `${message.author.username} is restricted : ${updatedTimestamps}` : `Rate limit exceeded for ${message.author.username}`); + if (!akhyAuthor.warned) { + await message.reply(`T'abuses fréro, attends un peu ⏳`) + } else if (akhyAuthor.warns === Math.max(1, process.env.MAX_WARNS - 3)) { + await message.author.send("Attention si tu continues de spam tu vas te faire timeout 🤯") + } + await updateManyUsers([ + { + id: akhyAuthor.id, + username: akhyAuthor.username, + globalName: akhyAuthor.globalName, + warned: 1, // true + warns: akhyAuthor.warns + 1, + allTimeWarns: akhyAuthor.allTimeWarns + 1, + totalRequests: akhyAuthor.totalRequests + }, + ]) + akhyAuthor = await getUser.get(akhyAuthor.id) + if (akhyAuthor.warns > process.env.MAX_WARNS ?? 10) { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const time = parseInt(process.env.SPAM_TIMEOUT_TIME) + try { + await guild.members.edit(akhyAuthor.id, { + communication_disabled_until: new Date(Date.now() + time).toISOString(), + reason: 'Dose le spam fdp', + }); + } catch (e) { + console.log('Tried timeout for AI spam : ', e) + message.channel.send(`<@${akhyAuthor.id}> tu me fais chier !! T'as de la chance que je puisse pas te timeout 🔪`) + .catch(console.error); + return + } + message.channel.send(`Ce bouffon de <@${akhyAuthor.id}> a été timeout pendant ${formatTime(time/1000)}, il me cassait les couilles 🤫`) + .catch(console.error); + return + } + return; + } + + + // Track this new usage + updatedTimestamps.push(now); + requestTimestamps.set(akhyAuthor.id, updatedTimestamps); + await updateManyUsers([ + { + id: akhyAuthor.id, + username: akhyAuthor.username, + globalName: akhyAuthor.globalName, + warned: 0, // false + warns: 0, // reset + allTimeWarns: akhyAuthor.allTimeWarns, + totalRequests: akhyAuthor.totalRequests + 1 + }, + ]) + akhyAuthor = await getUser.get(akhyAuthor.id) + + try { + // Fetch last messages from the channel + const fetched = await message.channel.messages.fetch({ limit: 100 }); + const messagesArray = Array.from(fetched.values()).reverse(); // oldest to newest + + const requestMessage = message.content.replace(`<@${process.env.APP_ID}>`, '') + + // Map to OpenAI/Gemini format + console.log('AI fetch', process.env.MODEL) + const allAkhys = await getAllUsers.all() + let allAkhysText = '' + allAkhys.forEach(akhy => { + allAkhysText += `<@${akhy.id}> alias ${akhy.globalName}, ` + }) + let convo = 'Voici les derniers messages de la conversation pour contexte (du plus vieux au plus récent) :\n' + messagesArray.forEach(msg => { + convo += `<@${msg.author.id}> a dit : ${msg.content}.\n` + }) + let formatted = []; + if (process.env.MODEL === 'OpenAI' || process.env.MODEL === 'Gemini') { + formatted.push({ + role: 'developer', + content: `${convo}`, + }); + formatted.push({ + role: 'developer', + content: `Voici la liste des différents utilisateurs présents : ${allAkhysText}`, + }) + formatted.push({ + role: 'developer', + content: `Voici une liste de quelques emojis que tu peux utiliser sur le serveur: <:CAUGHT:1323810730155446322> quand tu te fais prendre la main dans le sac ou que tu a un avis divergent ou risqué, <:hinhinhin:1072510144933531758> pour le rire ou quand tu es moqueur, <:o7:1290773422451986533> pour payer respect ou remercier ou dire au revoir, <:zhok:1115221772623683686> pour quand quelquechose manque de sens, <:nice:1154049521110765759> pour quelquechose de bien, <:nerd:1087658195603951666> pour une explication technique ou une attitude nerd, <:peepSelfie:1072508131839594597> pour à peu près n\'importe quelle situation quand tu es blazé` + }) + + formatted.push( + { + role: "developer", + content: "Adopte une attitude détendue et répond comme si tu participais à la conversation, pas trop long, pas de retour à la ligne, simple et utilise les emojis du serveur. N'hésites pas à utiliser des abréviations mais sans en abuser." + }, + { + role: 'developer', + content: message.mentions.repliedUser?.id ? `La phrase de l'utilisateur répond à un message de ${message.mentions.repliedUser?.id === process.env.APP_ID ? 'toi-même' : message.mentions.repliedUser?.id}` : '', + }, + { + role: "developer", + content: `Ton id est : <@${process.env.APP_ID}>, évite de l'utiliser. Ton username et global_name sont : ${process.env.APP_NAME}` + }, + { + role: "developer", + content: `L'utilisateur qui s'adresse a toi est : <@${akhyAuthor.id}>` + }, + { + role: "user", + content: requestMessage.length > 1 ? requestMessage : 'Salut', + }); + } + else if (process.env.MODEL === 'Mistral') { + // Map to Mistral format + formatted.push({ + role: 'system', + content: `${convo}`, + }); + + formatted.push({ + role: 'system', + content: `Voici la liste des différents utilisateurs présents : ${allAkhysText}`, + }); + + formatted.push( + { + role: "system", + content: "Adopte une attitude détendue et répond comme si tu participais à la conversation, pas trop long, pas de retour à la ligne, simple. N'hésites pas à utiliser des abréviations mais sans en abuser." + }, + { + role: 'system', + content: message.mentions.repliedUser?.id ? `La phrase de l'utilisateur répond à un message de ${message.mentions.repliedUser?.id === process.env.APP_ID ? 'toi-même' : message.mentions.repliedUser?.id}` : '', + }, + + { + role: "system", + content: `Ton id est : <@${process.env.APP_ID}>, évite de l'utiliser. Ton username et global_name sont : ${process.env.APP_NAME}` + }, + { + role: "system", + content: `L'utilisateur qui s'adresse a toi est : <@${akhyAuthor.id}>` + }, + { + role: "user", + content: requestMessage.length > 1 ? requestMessage : 'Salut', + }); + } + + // await gork(formatted); IA en marche + const reply = await gork(formatted); + + // Send response to the channel + await message.reply(reply); + } catch (err) { + console.error("Error fetching or sending messages:", err); + await message.reply("Oups, y'a eu un problème!"); + } + } + else if (message.content.toLowerCase().includes("quoi")) { + let prob = Math.random() + console.log(`feur ${prob}`) + if (prob < process.env.FEUR_PROB) { + // Send a message "feur" to the same channel + message.channel.send(`feur`) + .catch(console.error); + } + } + else if (message.guildId === process.env.DEV_GUILD_ID) { + // ADMIN COMMANDS + if (message.content.toLowerCase().startsWith('?u')) { + console.log(await getAPOUsers()) + } + else if (message.content.toLowerCase().startsWith('?b')) { + const amount = message.content.replace('?b ', '') + console.log(amount) + console.log(await postAPOBuy('650338922874011648', amount)) + } + else if (message.content.toLowerCase().startsWith('?v')) { + console.log('active polls :') + console.log(activePolls) + } + else if (message.content.toLowerCase().startsWith('?sv')) { + const amount = parseInt(message.content.replace('?sv ', '')) + let sum = 0 + let start_at = Date.now() + for (let i = 0; i < amount; i++) { + sum += parseFloat(randomSkinPrice(i+1)) + if (i%10 === 0 || i === amount-1) console.log(`Avg Skin Cost : ~${(sum/i+1).toFixed(2)}€ (~${sum.toFixed(2)}/${i+1}) - ${(Date.now() - start_at)}ms elapsed`) + } + console.log(`Result for ${amount} skins`) + } + else if (message.author.id === process.env.DEV_ID) { + const prefix = process.env.DEV_SITE === 'true' ? 'dev' : 'flopo' + if (message.content === prefix + ':add-coins-to-users') { + console.log(message.author.id) + try { + const stmtUpdateUsers = flopoDB.prepare(` + ALTER TABLE users + ADD coins INTEGER DEFAULT 0 + `); + stmtUpdateUsers.run() + } catch (e) { + console.log(e) + } + } + else if (message.content === prefix + ':sotd') { + initTodaysSOTD() + } + else if (message.content === prefix + ':users') { + const allAkhys = getAllUsers.all() + console.log(allAkhys) + } + else if (message.content === prefix + ':cancel') { + await message.delete() + } + else if (message.content.startsWith(prefix + ':reset-user-coins')) { + const userId = message.content.replace(prefix + ':reset-user-coins ', '') + const authorDB = getUser.get(userId) + if (authorDB) { + updateUserCoins.run({ + id: userId, + coins: 0, + }) + console.log(`${authorDB.username}'s coins were reset to 0`) + } else { + console.log('invalid user') + } + } + else if (message.content.startsWith(prefix + ':send-message')) { + const msg = message.content.replace(prefix + ':send-message ', '') + await fetch(process.env.BASE_URL + '/send-message', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + channelId: '1368908514545631262', + message: msg, + }) + }); + } + else if (message.content.startsWith(prefix + ':sql')) { + let sqlCommand = message.content.replace(prefix + ':sql ', '') + console.log(sqlCommand) + try { + if (sqlCommand.startsWith('SELECT')) { + const stmt = flopoDB.prepare(`${sqlCommand}`).all(); + console.log(stmt) + } else { + const stmt = flopoDB.prepare(`${sqlCommand}`).run(); + console.log(stmt) + } + } catch (e) { + console.log(e) + } + } + else if (message.content.startsWith(prefix + ':poker')) { + console.log('poker') + } + else if (message.content.startsWith(prefix + ':elo-test')) { + const numbers = message.content.match(/\d+/g); + + const score1 = parseInt(numbers[0]); + const score2 = parseInt(numbers[1]); + + const prob1 = 1 / (1 + Math.pow(10, (score2 - score1)/400)) + const prob2 = 1 / (1 + Math.pow(10, (score1 - score2)/400)) + + const res1 = Math.floor(score1 + 10 * (1 - prob1)) + const res2 = Math.floor(score2 + 10 * (0 - prob2)) + + console.log(res1, res2) + } + } + } +}); + +// Once bot is ready +client.once('ready', async () => { + console.log(`Logged in as ${client.user.tag}`); + console.log(`[Connected with ${FLAPI_URL}]`) + await getAkhys(); + console.log('FlopoBOT marked as ready') + + // every 10 minutes + cron.schedule('*!/10 * * * *', async () => { + const FIVE_MINUTES = 5 * 60 * 1000; + + // clean 5 minutes old inventories + for (const id in activeInventories) { + const inventory = activeInventories[id]; + if (Date.now() >= inventory.timestamp + FIVE_MINUTES) { + console.log(`Removing expired inventory : ${id}`); + delete activeInventories[id]; + } + } + for (const id in activeSearchs) { + const search = activeSearchs[id]; + if (Date.now() >= search.timestamp + FIVE_MINUTES) { + console.log(`Removing expired search : ${id}`); + delete activeSearchs[id]; + } + } + for (const id in activePredis) { + const predi = activePredis[id]; + if (predi.closed) { + if (predi.paidTime && Date.now() >= predi.paidTime + (24 * 60 * 60 * 1000)) { + console.log(`Removing expired paid predi : ${id}`); + delete activePredis[id]; + } else if (Date.now() >= predi.cancelledTime + (24 * 60 * 60 * 1000)) { + console.log(`Removing expired cancelled predi : ${id}`); + delete activePredis[id]; + } + } + } + for (const roomId in Object.keys(pokerRooms)) { + const room = pokerRooms[roomId]; + if (Object.keys(room.players)?.length === 0) { + delete pokerRooms[roomId]; + console.log(`Removing empty poker room : ${roomId}`); + io.emit('new-poker-room') + } + } + }); + + // at midnight + cron.schedule(process.env.CRON_EXPR, async () => { + try { + const akhys = getAllUsers.all() + akhys.forEach((akhy) => { + resetDailyReward.run(akhy.id); + }) + } catch (e) { + console.log(e) + } + + initTodaysSOTD() + }); + + // users/skins dayly fetch at 7am + cron.schedule('0 7 * * *', async() => { + // fetch eventual new users/skins + await getAkhys(); + console.log('Users and skins fetched') + }) +}); + +/!** + * Interactions endpoint URL where Discord will send HTTP requests + * Parse request body and verifies incoming requests using discord-interactions package + *!/ +app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), async function (req, res) { + // Interaction id, type and data + const { id, type, data } = req.body; + + /!** + * Handle verification requests + *!/ + if (type === InteractionType.PING) { + return res.send({ type: InteractionResponseType.PONG }); + } + + /!** + * Handle slash command requests + * See https://discord.com/developers/docs/interactions/application-commands#slash-commands + *!/ + if (type === InteractionType.APPLICATION_COMMAND) { + const { name } = data; + + // 'timeout' command + if (name === 'timeout') { + // Interaction context + const context = req.body.context; + // User ID is in user field for (G)DMs, and member for servers + const userId = context === 0 ? req.body.member.user.id : req.body.user.id; + // User's choices + const akhy = req.body.data.options[0].value; + const time = req.body.data.options[1].value; + + const guild = await client.guilds.fetch(req.body.guild_id); + const fromMember = await guild.members.fetch(userId); + const toMember = await guild.members.fetch(akhy); + + const already = Object.values(activePolls).find(poll => poll.toUsername === toMember.user); + + if (already) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Impossible de timeout **${toMember.user}** car un vote est déjà en cours`, + flags: InteractionResponseFlags.EPHEMERAL, + } + }); + } + + if (toMember.communicationDisabledUntilTimestamp > Date.now()) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `**${toMember.user}** est déjà timeout`, + flags: InteractionResponseFlags.EPHEMERAL, + } + }); + } + + // Save the poll information along with channel ID so we can notify later + activePolls[id] = { + id: userId, + username: fromMember.user, + toUserId: akhy, + toUsername: toMember.user, + time: time, + time_display: formatTime(time), + for: 0, + against: 0, + voters: [], + channelId: req.body.channel_id, // Capture channel for follow-up notification + endpoint: `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, + }; + + const guildId = req.body.guild_id; + const roleId = process.env.VOTING_ROLE_ID; // Set this in your .env file + const onlineEligibleUsers = await getOnlineUsersWithRole(guildId, roleId); + const requiredMajority = Math.max(parseInt(process.env.MIN_VOTES), Math.floor(onlineEligibleUsers.size / (time >= 21600 ? 2 : 3)) + 1); + const votesNeeded = Math.max(0, requiredMajority - activePolls[id].for); + + activePolls[id].endTime = Date.now() + process.env.POLL_TIME * 1000; + activePolls[id].requiredMajority = requiredMajority; + +// Set an interval to update the countdown every 10 seconds (or more often if you want) + const countdownInterval = setInterval(async () => { + const poll = activePolls[id]; + + if (!poll) { + clearInterval(countdownInterval); + io.emit('new-poll', { action: 'timeout cleared' }); + return; + } + + const remaining = Math.max(0, Math.floor((poll?.endTime - Date.now()) / 1000)); + const minutes = Math.floor(remaining / 60); + const seconds = remaining % 60; + const countdownText = `**${minutes}m ${seconds}s** restantes`; + const votesNeeded = Math.max(0, activePolls[id].requiredMajority - activePolls[id].for); + + if (!poll || remaining === 0) { + try { + let forText = '' + poll.voters.forEach((voter) => { + const user = getUser.get(voter); + forText += `- ${user.globalName}\n` + }) + await DiscordRequest( + poll.endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Le vote pour timeout ${poll.toUsername.username} pendant ${poll.time_display} a échoué 😔`, + description: `Il manquait **${votesNeeded}** vote(s)`, + fields: [ + { + name: 'Pour', + value: '✅ ' + poll.for + '\n' + forText, + inline: true, + }, + { + name: 'Temps restant', + value: '⏳ ' + countdownText, + inline: false, + }, + ], + color: 0xF2F3F3, // You can set the color of the embed + }, + ], + components: [], + }, + } + ); + } catch (err) { + console.error('Error sending message', err); + } + console.log('clear poll') + clearInterval(countdownInterval); + delete activePolls[id]; + io.emit('new-poll', { action: 'timeout cleared' }); + return; + } + + try { + let forText = '' + poll.voters.forEach((voter) => { + const user = getUser.get(voter); + forText += `- ${user.globalName}\n` + }) + await DiscordRequest( + poll.endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Timeout`, + description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}\nIl manque **${votesNeeded}** vote(s)`, + fields: [ + { + name: 'Pour', + value: '✅ ' + poll.for + '\n' + forText, + inline: true, + }, + { + name: 'Temps restant', + value: '⏳ ' + countdownText, + inline: false, + }, + ], + color: 0xF2F3F3, // You can set the color of the embed + }, + ], + components: [ + { + type: MessageComponentTypes.ACTION_ROW, + components: [ + { + type: MessageComponentTypes.BUTTON, + custom_id: `vote_for_${req.body.id}`, + label: 'Oui ✅', + style: ButtonStyleTypes.SECONDARY, + }, + { + type: MessageComponentTypes.BUTTON, + custom_id: `vote_against_${req.body.id}`, + label: 'Non ❌', + style: ButtonStyleTypes.SECONDARY, + }, + ], + }, + ], + }, + } + ); + } catch (err) { + console.error('Error updating countdown:', err); + } + }, 1000); // every second + + const remaining = Math.max(0, Math.floor((activePolls[id].endTime - Date.now()) / 1000)); + const minutes = Math.floor(remaining / 60); + const seconds = remaining % 60; + const countdownText = `**${minutes}m ${seconds}s** restantes`; + + // web site update + io.emit('new-poll', { action: 'timeout command' }); + + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: `Timeout`, + description: `**${activePolls[id].username}** propose de timeout **${activePolls[id].toUsername}** pendant ${activePolls[id].time_display}\nIl manque **${votesNeeded}** vote(s)`, + fields: [ + { + name: 'Pour', + value: '✅ ' + activePolls[id].for, + inline: true, + }, + { + name: 'Temps restant', + value: '⏳ ' + countdownText, + inline: false, + }, + ], + color: 0xF2F3F3, // You can set the color of the embed + }, + ], + components: [ + { + type: MessageComponentTypes.ACTION_ROW, + components: [ + { + type: MessageComponentTypes.BUTTON, + custom_id: `vote_for_${req.body.id}`, + label: 'Oui ✅', + style: ButtonStyleTypes.SECONDARY, + }, + { + type: MessageComponentTypes.BUTTON, + custom_id: `vote_against_${req.body.id}`, + label: 'Non ❌', + style: ButtonStyleTypes.SECONDARY, + }, + ], + }, + ], + }, + }); + } + + if (name === 'inventory') { + // Interaction context + const context = req.body.context; + // User ID is in user field for (G)DMs, and member for servers + const userId = context === 0 ? req.body.member.user.id : req.body.user.id; + // User's choices + const akhy = req.body.data.options ? req.body.data.options[0].value : userId; + + const guild = await client.guilds.fetch(req.body.guild_id); + const completeAkhy = await guild.members.fetch(akhy); + + const invSkins = getUserInventory.all({user_id: akhy}); + + const chromaText = (skin) => { + let result = "" + for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { + result += skin.currentChroma === i ? '💠 ' : '◾ ' + } + return result + } + const chromaName = (skin) => { + if (skin.currentChroma >= 2) { + const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') + const match = name.match(/variante\s+[1-4]\s+([^)]+)/) + const result = match ? match[2] : null; + if (match) { + return match[1].trim() + } else { + return name + } + } + if (skin.currentChroma === 1) { + return 'Base' + } + return '' + }; + let content = ''; + let totalPrice = 0; + let fields = []; + invSkins.forEach(skin => { + content += `- ${skin.displayName} | ${skin.currentPrice.toFixed()}€ \n`; + totalPrice += skin.currentPrice; + fields.push({ + name: `${skin.displayName} | ${skin.currentPrice.toFixed(2)}€`, + value: `${skin.tierText}\nChroma : ${chromaText(skin)} | ${chromaName(skin)}\nLvl : **${skin.currentLvl}**!/${skins.find((s) => s.uuid === skin.uuid).levels.length}\n`, + inline: false, + }) + }) + + activeInventories[id] = { + akhyId: akhy, + userId: userId, + page: 0, + amount: invSkins.length, + reqBodyId: req.body.id, + endpoint: `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, + timestamp: Date.now(), + }; + + if (invSkins.length === 0) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: `Inventaire de ${completeAkhy.user.username}`, + description: "Aucun skin dans l'inventaire", + color: 0xF2F3F3, + footer: {text: `Total : ${totalPrice.toFixed(2)}€`}, + }, + ], + }, + }); + } + const trueSkin = skins.find((s) => s.uuid === invSkins[0].uuid); + + const imageUrl = () => { + let result; + if (invSkins[0].currentLvl === trueSkin.levels.length) { + if (invSkins[0].currentChroma === 1) { + result = trueSkin.chromas[0].displayIcon + + } else { + result = trueSkin.chromas[invSkins[0].currentChroma-1].fullRender ?? trueSkin.chromas[invSkins[0].currentChroma-1].displayIcon + } + } else if (invSkins[0].currentLvl === 1) { + result = trueSkin.levels[0].displayIcon ?? trueSkin.chromas[0].fullRender + } else if (invSkins[0].currentLvl === 2 || invSkins[0].currentLvl === 3) { + result = trueSkin.displayIcon + } + if (result) return result; + return trueSkin.displayIcon + }; + + let components = [ + { + type: MessageComponentTypes.BUTTON, + custom_id: `prev_page_${req.body.id}`, + label: '⏮️ Préc.', + style: ButtonStyleTypes.SECONDARY, + }, + { + type: MessageComponentTypes.BUTTON, + custom_id: `next_page_${req.body.id}`, + label: 'Suiv. ⏭️', + style: ButtonStyleTypes.SECONDARY, + }, + ] + + if ((invSkins[0].currentLvl < trueSkin.levels.length || invSkins[0].currentChroma < trueSkin.chromas.length) && akhy === userId) { + components.push({ + type: MessageComponentTypes.BUTTON, + custom_id: `upgrade_${req.body.id}`, + label: `Upgrade ⏫`, + style: ButtonStyleTypes.PRIMARY, + }) + } + + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: `Inventaire de ${completeAkhy.user.username}`, + description: `${invSkins?.length > 0 ? '' : "Aucun skin dans l'inventaire"}`, + color: 0xF2F3F3, + footer: {text: `${activeInventories[id].page+1}/${invSkins?.length} | Total : ${totalPrice.toFixed(2)}€`}, + fields: [fields[activeInventories[id].page]], + image: { + url: invSkins?.length > 0 ? imageUrl() : '', + } + }, + ], + components: [ + { + type: MessageComponentTypes.ACTION_ROW, + components: components, + }, + ], + }, + }); + } + + if (name === 'valorant') { + const buyResponse = await postAPOBuy(req.body.member.user.id, process.env.VALO_PRICE ?? 150) + + if (buyResponse.status === 500 || buyResponse.ok === false) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Tu n'as pas assez d'argent...`, + flags: InteractionResponseFlags.EPHEMERAL, + } + }); + } + + // First, send the initial response immediately + const initialEmbed = new EmbedBuilder() + .setTitle(`\t`) + .setImage('https://media.tenor.com/gIWab6ojBnYAAAAd/weapon-line-up-valorant.gif') + .setColor(`#F2F3F3`); + + // Send the initial response and store the reply object + await res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { embeds: [initialEmbed] } + }); + + // Get a random skin + const dbSkins = getAllAvailableSkins.all(); + const randomIndex = Math.floor(Math.random() * dbSkins.length); + let randomSkin; + + try { + randomSkin = skins.find((skin) => skin.uuid === dbSkins[randomIndex].uuid); + if (!randomSkin) throw new Error("Skin not found"); + } catch (e) { + // Edit the original message if there's an error + await DiscordRequest( + `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, + { + method: 'PATCH', + body: { + content: "Oups, ya eu un ptit problème", + embeds: [] + } + } + ); + return; + } + + // Generate random level and chroma + const randomLevel = Math.floor(Math.random() * randomSkin.levels.length + 1); + let randomChroma = randomLevel === randomSkin.levels.length + ? Math.floor(Math.random() * randomSkin.chromas.length + 1) + : 1; + if (randomChroma === randomSkin.chromas.length && randomSkin.chromas.length >= 2) randomChroma-- + const selectedLevel = randomSkin.levels[randomLevel - 1] + const selectedChroma = randomSkin.chromas[randomChroma - 1] + + // Set timeout for the reveal + setTimeout(async () => { + // Prepare the final embed + const selectedLevel = randomSkin.levels[randomLevel - 1]; + const selectedChroma = randomSkin.chromas[randomChroma - 1]; + + // Helper functions (unchanged from your original code) + const videoUrl = () => { + let result; + if (randomLevel === randomSkin.levels.length) { + if (randomChroma === 1) { + result = randomSkin.levels[randomSkin.levels.length - 1].streamedVideo ?? randomSkin.chromas[0].streamedVideo + } else { + result = randomSkin.chromas[randomChroma-1].streamedVideo + } + } else { + result = randomSkin.levels[randomLevel-1].streamedVideo + } + return result; + }; + const imageUrl = () => { + let result; + if (randomLevel === randomSkin.levels.length) { + if (randomChroma === 1) { + result = randomSkin.chromas[0].displayIcon + + } else { + result = randomSkin.chromas[randomChroma-1].fullRender ?? randomSkin.chromas[randomChroma-1].displayIcon + } + } else if (randomLevel === 1) { + result = randomSkin.levels[0].displayIcon ?? randomSkin.chromas[0].fullRender + } else if (randomLevel === 2 || randomLevel === 3) { + result = randomSkin.displayIcon + } + if (result) return result; + return randomSkin.displayIcon + }; + const chromaName = () => { + if (randomChroma >= 2) { + const name = selectedChroma.displayName.replace(/[\r\n]+/g, '').replace(randomSkin.displayName, '') + const match = name.match(/variante\s+[1-4]\s+([^)]+)/) + const result = match ? match[2] : null; + if (match) { + return match[1].trim() + } else { + return name + } + } + if (randomChroma === 1) { + return 'Base' + } + return '' + }; + const lvlText = () => { + let result = "" + if (randomLevel >= 1) { + result += '1️⃣ ' + } + if (randomLevel >= 2) { + result += '2️⃣ ' + } + if (randomLevel >= 3) { + result += '3️⃣ ' + } + if (randomLevel >= 4) { + result += '4️⃣ ' + } + if (randomLevel >= 5) { + result += '5️⃣ ' + } + for (let i = 0; i < randomSkin.levels.length - randomLevel; i++) { + result += '◾ ' + } + return result + } + const chromaText = () => { + let result = "" + for (let i = 1; i <= randomSkin.chromas.length; i++) { + result += randomChroma === i ? '💠 ' : '◾ ' + } + return result + } + const price = () => { + let result = dbSkins[randomIndex].basePrice; + + result *= (1 + (randomLevel / Math.max(randomSkin.levels.length, 2))) + result *= (1 + (randomChroma / 4)) + + return result.toFixed(2); + } + + // Update the database + try { + await updateSkin.run({ + uuid: randomSkin.uuid, + user_id: req.body.member.user.id, + currentLvl: randomLevel, + currentChroma: randomChroma, + currentPrice: price() + }); + } catch (e) { + console.log('Database error', e); + } + + // Build the final embed + const finalEmbed = new EmbedBuilder() + .setTitle(`${randomSkin.displayName} | ${chromaName()}`) + .setFields([ + { name: '', value: `**Lvl** | ${lvlText()}`, inline: true }, + { name: '', value: `**Chroma** | ${chromaText()}`, inline: true }, + { name: '', value: `**Prix** | ${price()} <:vp:1362964205808128122>`, inline: true }, + ]) + .setDescription(dbSkins[randomIndex].tierText) + .setImage(imageUrl()) + .setFooter({ text: 'Ajouté à ton inventaire' }) + .setColor(`#${dbSkins[randomIndex].tierColor}`); + + // Prepare components if video exists + const video = videoUrl(); + const components = []; + + if (video) { + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('🎬 Aperçu vidéo') + .setStyle(ButtonStyle.Link) + .setURL(video) + ) + ); + } + + // Edit the original message + try { + await DiscordRequest( + `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, + { + method: 'PATCH', + body: { + embeds: [finalEmbed], + components: components + } + } + ); + } catch (err) { + console.error('Error editing message:', err); + } + }, 5000); + + return; + } + + if (name === 'info') { + const guild = await client.guilds.fetch(req.body.guild_id); + + await guild.members.fetch() + + const timedOutMembers = guild.members.cache.filter( + (member) => + member.communicationDisabledUntil && + member.communicationDisabledUntil > new Date() + ); + + if (timedOutMembers.size === 0) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: `Membres timeout`, + description: "Aucun membre n'est actuellement timeout.", + color: 0xF2F3F3, + }, + ], + }, + }); + } + + const list = timedOutMembers.map( + (member) => + `**${member.user.tag}** (jusqu'à ${member.communicationDisabledUntil.toLocaleString()})` + ).join("\n"); + + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: `Membres timeout`, + description: `${list}`, + color: 0xF2F3F3, + }, + ], + }, + }); + } + + if (name === 'skins') { + const topSkins = getTopSkins.all() + const guild = await client.guilds.fetch(req.body.guild_id) + + let fields = [] + + for (const skin of topSkins) { + const index = topSkins.indexOf(skin); + const owner = skin.user_id ? await guild.members.fetch(skin.user_id) : null; + fields.push({ + name: `#${index+1} - **${skin.displayName}**`, + value: `${skin.maxPrice}€ ${skin.user_id ? '| **@'+ owner.user.username+'** ✅' : ''}\n`, + inline: false + }); + } + + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + fields: fields, + color: 0xF2F3F3, + }, + ], + }, + }); + } + + if (name === 'floposite') { + const originalComponents = [ + { + type: MessageComponentTypes.BUTTON, + label: 'Aller sur FlopoSite', + style: ButtonStyleTypes.LINK, + url: 'https://floposite.com', + }, + ]; + + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: 'FlopoSite', + description: 'L\'officiel et très goatesque site de FlopoBot.', + color: 0x6571F3, + thumbnail: { + url: process.env.API_URL + '/public/images/flopo.png' + } + } + ], + components: [ + { + type: MessageComponentTypes.ACTION_ROW, + components: originalComponents, + }, + ], + } + }) + } + + if (name === 'search') { + const context = req.body.context; + // User ID is in user field for (G)DMs, and member for servers + const userId = context === 0 ? req.body.member.user.id : req.body.user.id; + const searchValue = req.body.data.options[0].value.toLowerCase(); + + const guild = await client.guilds.fetch(req.body.guild_id); + + let dbSkins = getAllSkins.all() + + let resultSkins = dbSkins.filter((skin) => { + return skin.displayName.toLowerCase().includes(searchValue) || skin.tierText.toLowerCase().includes(searchValue); + }) + + if (resultSkins.length === 0) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Aucun résultat ne correspond à ta recherche', + flags: InteractionResponseFlags.EPHEMERAL, + } + }) + } + + const owner = await guild.members.fetch(resultSkins[0].user_id) + let fields = [ + { + name: `**${resultSkins[0].displayName}** | ${resultSkins[0].tierText}`, + value: `${resultSkins[0].maxPrice}€ ${resultSkins[0].user_id ? '| **@'+ owner.user.username +'** ✅' : ''}`, + inline: false, + } + ] + + activeSearchs[id] = { + userId: userId, + page: 0, + amount: resultSkins.length, + resultSkins: resultSkins, + endpoint: `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, + timestamp: Date.now(), + searchValue: searchValue, + }; + + const trueSkin = skins.find((s) => s.uuid === resultSkins[0].uuid); + const imageUrl = () => { + let res; + if (trueSkin.chromas[trueSkin.chromas.length-1].displayIcon) { + res = trueSkin.chromas[trueSkin.chromas.length-1].displayIcon + } else if (trueSkin.levels[trueSkin.levels.length-1].displayIcon) { + res = trueSkin.levels[trueSkin.levels.length-1].displayIcon + } else { + res = trueSkin.displayIcon + } + return res + }; + + const videoUrl = () => { + let res; + if (trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo) { + res = trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo + } else if (trueSkin.levels[trueSkin.levels.length-1].streamedVideo) { + res = trueSkin.levels[trueSkin.levels.length-1].streamedVideo + } else { + res = null + } + return res + }; + + const originalComponents = [ + { + type: MessageComponentTypes.BUTTON, + custom_id: `prev_search_page_${req.body.id}`, + label: '⏮️ Préc.', + style: ButtonStyleTypes.SECONDARY, + }, + { + type: MessageComponentTypes.BUTTON, + custom_id: `next_search_page_${req.body.id}`, + label: 'Suiv. ⏭️', + style: ButtonStyleTypes.SECONDARY, + }, + ]; + + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: `Résultat de recherche`, + description: `🔎 ${searchValue}`, + fields: fields, + color: parseInt(resultSkins[0].tierColor, 16), + image: { url: imageUrl() }, + footer: { text: `1/${resultSkins.length} résultat(s)` }, + }, + ], + components: [ + { + type: MessageComponentTypes.ACTION_ROW, + components: originalComponents, + }, + ], + }, + }); + } + + console.error(`unknown command: ${name}`); + return res.status(400).json({ error: 'unknown command' }); + } + + if (type === InteractionType.MESSAGE_COMPONENT) { +// custom_id set in payload when sending message component + const componentId = data.custom_id; + + if (componentId.startsWith('accept_button_')) { + // get the associated game ID + const gameId = componentId.replace('accept_button_', ''); + // Delete message with token in request body + const endpoint = `webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`; + try { + await res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'What is your object of choice?', + // Indicates it'll be an ephemeral message + flags: InteractionResponseFlags.EPHEMERAL, + components: [ + { + type: MessageComponentTypes.ACTION_ROW, + components: [ + { + type: MessageComponentTypes.STRING_SELECT, + // Append game ID + custom_id: `select_choice_${gameId}`, + options: getShuffledOptions(), + }, + ], + }, + ], + }, + }); + // Delete previous message + await DiscordRequest(endpoint, { method: 'DELETE' }); + } catch (err) { + console.error('Error sending message:', err); + } + } + else if (componentId.startsWith('vote_')) { + let gameId, isVotingFor; + + if (componentId.startsWith('vote_for_')) { + gameId = componentId.replace('vote_for_', ''); + isVotingFor = true; + } else { + gameId = componentId.replace('vote_against_', ''); + isVotingFor = false; + } + + if (activePolls[gameId]) { + const poll = activePolls[gameId]; + poll.voters = poll.voters || []; + const voterId = req.body.member.user.id; + + // Check if the voter has the required voting role + const voterRoles = req.body.member.roles || []; + if (!voterRoles.includes(process.env.VOTING_ROLE_ID)) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Tu n'as pas le rôle requis pour voter.", + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // Enforce one vote per eligible user + if (poll.voters.find(u => u === voterId)) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Tu as déjà voté oui!", + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // Record the vote + if (isVotingFor) { + poll.voters.push(voterId); + poll.for++; + } else { + poll.against++; + } + + io.emit('new-poll', { action: 'new vote' }); + + // Retrieve online eligible users (ensure your bot has the necessary intents) + const guildId = req.body.guild_id; + const roleId = process.env.VOTING_ROLE_ID; // Set this in your .env file + const onlineEligibleUsers = await getOnlineUsersWithRole(guildId, roleId); + const votesNeeded = Math.max(0, poll.requiredMajority - poll.for); + + // Check if the majority is reached + if (poll.for >= poll.requiredMajority) { + try { + // Build the updated poll message content + let forText = '' + poll.voters.forEach((voter) => { + const user = getUser.get(voter); + forText += `- ${user.globalName}\n` + }) + await DiscordRequest( + poll.endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Timeout`, + description: `Proposition de timeout **${poll.toUsername}** pendant ${poll.time_display}`, + fields: [ + { + name: 'Votes totaux', + value: '✅ ' + poll.for + '\n' + forText, + inline: true, + }, + ], + color: 0xF2F3F3, // You can set the color of the embed + }, + ], + components: [], // remove buttons + }, + } + ); + } catch (err) { + console.error('Error updating poll message:', err); + } + // Clear the poll so the setTimeout callback doesn't fire later + delete activePolls[gameId]; + + // **Actual Timeout Action** + try { + // Calculate the ISO8601 timestamp to disable communications until now + poll.time seconds + const timeoutUntil = new Date(Date.now() + poll.time * 1000).toISOString(); + const endpointTimeout = `guilds/${req.body.guild_id}/members/${poll.toUserId}`; + await DiscordRequest(endpointTimeout, { + method: 'PATCH', + body: { communication_disabled_until: timeoutUntil }, + }); + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `<@${poll.toUserId}> a été timeout pendant ${poll.time_display} par décision démocratique 👊`, + }, + }); + } catch (err) { + console.error('Error timing out user:', err); + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Impossible de timeout <@${poll.toUserId}>, désolé... 😔`, + }, + }); + } + } + + // If the vote is "for", update the original poll message to reflect the new vote count. + if (isVotingFor) { + const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000)); + const minutes = Math.floor(remaining / 60); + const seconds = remaining % 60; + const countdownText = `**${minutes}m ${seconds}s** restantes`; + try { + // Build the updated poll message content + let forText = '' + poll.voters.forEach((voter) => { + const user = getUser.get(voter); + forText += `- ${user.globalName}\n` + }) + await DiscordRequest( + poll.endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Timeout`, + description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}\nIl manque **${votesNeeded}** vote(s)`, + fields: [ + { + name: 'Pour', + value: '✅ ' + poll.for + '\n' + forText, + inline: true, + }, + { + name: 'Temps restant', + value: '⏳ ' + countdownText, + inline: false, + }, + ], + color: 0xF2F3F3, // You can set the color of the embed + }, + ], + components: req.body.message.components, // preserve the buttons + }, + } + ); + } catch (err) { + console.error('Error updating poll message:', err); + } + } + + // Send an ephemeral acknowledgement to the voter + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Vote enregistré ! ✅`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + } + else if (componentId.startsWith('prev_page')) { + let invId = componentId.replace('prev_page_', ''); + const context = req.body.context; + // User ID is in user field for (G)DMs, and member for servers + const userId = context === 0 ? req.body.member.user.id : req.body.user.id; + + const guild = await client.guilds.fetch(req.body.guild_id); + if (!activeInventories[invId]) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Oups, cet affichage n'est plus actif.\nRelance la commande pour avoir un nouvel élément intéractif`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const completeAkhy = await guild.members.fetch(activeInventories[invId].akhyId); + + const invSkins = getUserInventory.all({user_id: activeInventories[invId].akhyId}); + + const chromaText = (skin) => { + let res = "" + for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { + res += skin.currentChroma === i ? '💠 ' : '◾ ' + } + return res + } + const chromaName = (skin) => { + if (skin.currentChroma >= 2) { + const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') + const match = name.match(/variante\s+[1-4]\s+([^)]+)/) + const result = match ? match[2] : null; + if (match) { + return match[1].trim() + } else { + return name + } + } + if (skin.currentChroma === 1) { + return 'Base' + } + return '' + }; + let content = ''; + let totalPrice = 0; + let fields = []; + invSkins.forEach(skin => { + content += `- ${skin.displayName} | ${skin.currentPrice.toFixed()}€ \n`; + totalPrice += skin.currentPrice; + fields.push({ + name: `${skin.displayName} | ${skin.currentPrice.toFixed(2)}€`, + value: `${skin.tierText}\nChroma : ${chromaText(skin)} | ${chromaName(skin)}\nLvl : **${skin.currentLvl}**!/${skins.find((s) => s.uuid === skin.uuid).levels.length}\n`, + inline: false, + }) + }) + + if (activeInventories[invId] && activeInventories[invId].userId === req.body.member.user.id) { + if (activeInventories[invId].page === 0) { + activeInventories[invId].page = activeInventories[invId].amount-1 + } else { + activeInventories[invId].page-- + } + } else { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Tu n'est pas à l'origine de cette commande /inventory`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const trueSkin = skins.find((s) => s.uuid === invSkins[activeInventories[invId].page].uuid); + const imageUrl = () => { + let res; + if (invSkins[activeInventories[invId].page].currentLvl === trueSkin.levels.length) { + if (invSkins[activeInventories[invId].page].currentChroma === 1) { + res = trueSkin.chromas[0].displayIcon + + } else { + res = trueSkin.chromas[invSkins[activeInventories[invId].page].currentChroma-1].fullRender ?? trueSkin.chromas[invSkins[activeInventories[invId].page].currentChroma-1].displayIcon + } + } else if (invSkins[activeInventories[invId].page].currentLvl === 1) { + res = trueSkin.levels[0].displayIcon ?? trueSkin.chromas[0].fullRender + } else if (invSkins[activeInventories[invId].page].currentLvl === 2 || invSkins[activeInventories[invId].page].currentLvl === 3) { + res = trueSkin.displayIcon + } + if (res) return res; + return trueSkin.displayIcon + }; + + let components = req.body.message.components; + + if ((invSkins[activeInventories[invId].page].currentLvl < trueSkin.levels.length || invSkins[activeInventories[invId].page].currentChroma < trueSkin.chromas.length) && activeInventories[invId].akhyId === activeInventories[invId].userId) { + if (components[0].components.length === 2) { + components[0].components.push({ + type: MessageComponentTypes.BUTTON, + custom_id: `upgrade_${activeInventories[invId].reqBodyId}`, + label: `Upgrade ⏫`, + style: ButtonStyleTypes.PRIMARY, + }) + } + } else { + if (components[0].components.length === 3) { + components[0].components.pop() + } + } + + try { + await DiscordRequest( + activeInventories[invId].endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Inventaire de ${completeAkhy.user.username}`, + description: `${invSkins?.length > 0 ? '' : "Aucun skin dans l'inventaire"}`, + color: 0xF2F3F3, + footer: {text: `${activeInventories[invId].page+1}/${invSkins?.length} | Total : ${totalPrice.toFixed(2)}€`}, + fields: [fields[activeInventories[invId].page]], + image: { + url: invSkins?.length > 0 ? imageUrl() : '', + } + }, + ], + components: components, + }, + } + ); + } catch (err) { + console.log('Pas trouvé : ', err) + } + return res.send({ + type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, + }); + } + else if (componentId.startsWith('next_page')) { + let invId = componentId.replace('next_page_', ''); + const context = req.body.context; + // User ID is in user field for (G)DMs, and member for servers + const userId = context === 0 ? req.body.member.user.id : req.body.user.id; + + const guild = await client.guilds.fetch(req.body.guild_id); + if (!activeInventories[invId]) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Oups, cet inventaire n'est plus actif.\nRelance la commande pour avoir un nouvel inventaire interactif`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + const completeAkhy = await guild.members.fetch(activeInventories[invId].akhyId); + + const invSkins = getUserInventory.all({user_id: activeInventories[invId].akhyId}); + + const chromaText = (skin) => { + let res = "" + for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { + res += skin.currentChroma === i ? '💠 ' : '◾ ' + } + return res + } + const chromaName = (skin) => { + if (skin.currentChroma >= 2) { + const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') + const match = name.match(/variante\s+[1-4]\s+([^)]+)/) + const result = match ? match[2] : null; + if (match) { + return match[1].trim() + } else { + return name + } + } + if (skin.currentChroma === 1) { + return 'Base' + } + return '' + }; + let content = ''; + let totalPrice = 0; + let fields = []; + invSkins.forEach(skin => { + content += `- ${skin.displayName} | ${skin.currentPrice.toFixed()}€ \n`; + totalPrice += skin.currentPrice; + fields.push({ + name: `${skin.displayName} | ${skin.currentPrice.toFixed(2)}€`, + value: `${skin.tierText}\nChroma : ${chromaText(skin)} | ${chromaName(skin)}\nLvl : **${skin.currentLvl}**!/${skins.find((s) => s.uuid === skin.uuid).levels.length}\n`, + inline: false, + }) + }) + + if (activeInventories[invId] && activeInventories[invId].userId === req.body.member.user.id) { + if (activeInventories[invId].page === activeInventories[invId].amount-1) { + activeInventories[invId].page = 0 + } else { + activeInventories[invId].page++ + } + } else { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Tu n'est pas à l'origine de cette commande /inventory`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const trueSkin = skins.find((s) => s.uuid === invSkins[activeInventories[invId].page].uuid); + const imageUrl = () => { + let res; + if (invSkins[activeInventories[invId].page].currentLvl === trueSkin.levels.length) { + if (invSkins[activeInventories[invId].page].currentChroma === 1) { + res = trueSkin.chromas[0].displayIcon + + } else { + res = trueSkin.chromas[invSkins[activeInventories[invId].page].currentChroma-1].fullRender ?? trueSkin.chromas[invSkins[activeInventories[invId].page].currentChroma-1].displayIcon + } + } else if (invSkins[activeInventories[invId].page].currentLvl === 1) { + res = trueSkin.levels[0].displayIcon ?? trueSkin.chromas[0].fullRender + } else if (invSkins[activeInventories[invId].page].currentLvl === 2 || invSkins[activeInventories[invId].page].currentLvl === 3) { + res = trueSkin.displayIcon + } + if (res) return res; + return trueSkin.displayIcon + }; + + let components = req.body.message.components; + + if ((invSkins[activeInventories[invId].page].currentLvl < trueSkin.levels.length || invSkins[activeInventories[invId].page].currentChroma < trueSkin.chromas.length) && activeInventories[invId].akhyId === activeInventories[invId].userId) { + if (components[0].components.length === 2) { + components[0].components.push({ + type: MessageComponentTypes.BUTTON, + custom_id: `upgrade_${activeInventories[invId].reqBodyId}`, + label: `Upgrade ⏫`, + style: ButtonStyleTypes.PRIMARY, + }) + } + } else { + if (components[0].components.length === 3) { + components[0].components.pop() + } + } + + try { + await DiscordRequest( + activeInventories[invId].endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Inventaire de ${completeAkhy.user.username}`, + description: `${invSkins?.length > 0 ? '' : "Aucun skin dans l'inventaire"}`, + color: 0xF2F3F3, + footer: {text: `${activeInventories[invId].page+1}/${invSkins?.length} | Total : ${totalPrice.toFixed(2)}€`}, + fields: [fields[activeInventories[invId].page]], + image: { + url: invSkins?.length > 0 ? imageUrl() : '', + } + }, + ], + components: components, + }, + } + ); + } catch (err) { + console.log('Pas trouvé : ', err) + } + return res.send({ + type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, + }); + } + else if (componentId.startsWith('upgrade_')) { + let invId = componentId.replace('upgrade_', '') + const context = req.body.context + const userId = context === 0 ? req.body.member.user.id : req.body.user.id + + const guild = await client.guilds.fetch(req.body.guild.id) + if (!activeInventories[invId]) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Oups, cet inventaire n'est plus actif.\nRelance la commande pour avoir un nouvel inventaire interactif`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + const completeAkhy = await guild.members.fetch(activeInventories[invId].akhyId) + + const invSkins = getUserInventory.all({user_id: activeInventories[invId].akhyId}) + + if (!activeInventories[invId] || activeInventories[invId].userId !== req.body.member.user.id) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Tu n'est pas à l'origine de cette commande /inventory`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const upgradePrice = process.env.VALO_UPGRADE_PRICE ?? invSkins[activeInventories[invId].page].maxPrice/10 + const buyResponse = await postAPOBuy(req.body.member.user.id, upgradePrice) + + if (buyResponse.status === 500 || buyResponse.ok === false) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Tu n'as pas assez d'argent, cette amélioration coûte ${upgradePrice}€`, + flags: InteractionResponseFlags.EPHEMERAL, + } + }); + } + + const skin = invSkins[activeInventories[invId].page]; + const trueSkin = skins.find((s) => s.uuid === invSkins[activeInventories[invId].page].uuid); + + const lvlNb = trueSkin.levels.length + const chromaNb = trueSkin.chromas.length + const tierRank = trueSkin.tierRank + const currentLvl = skin.currentLvl + const currentChroma = skin.currentChroma + + let succeeded = false + + if (currentLvl < lvlNb) { + let prob = (currentLvl/lvlNb) + if (tierRank) prob *= ((tierRank+1)/4)+.1 + let rand = Math.random() + console.log(`lvl upgrade prob : ${prob} | ${rand}`) + succeeded = rand >= prob + //amélioration du lvl + if (succeeded) { + let newLvl = skin.currentLvl + 1 + const price = () => { + let res = skin.basePrice; + + res *= (1 + (newLvl / Math.max(trueSkin.levels.length, 2))) + res *= (1 + (skin.currentChroma / 4)) + + return res.toFixed(2); + } + try { + await updateSkin.run({ + uuid: skin.uuid, + user_id: skin.user_id, + currentLvl: newLvl, + currentChroma: skin.currentChroma, + currentPrice: price() + }); + } catch (e) { + console.log('Database error', e); + } + } + } + else if (currentChroma < chromaNb) { + let prob = (currentChroma/chromaNb) + if (tierRank) prob *= ((tierRank+1)/4)+.1 + let rand = Math.random() + console.log(`lvl upgrade prob : ${prob} | ${rand}`) + succeeded = rand >= prob + //amélioration du chroma + if (succeeded) { + let newChroma = skin.currentChroma + 1 + const price = () => { + let res = skin.basePrice; + + res *= (1 + (skin.currentLvl / Math.max(trueSkin.levels.length, 2))) + res *= (1 + (newChroma / 4)) + + return res.toFixed(2); + } + try { + await updateSkin.run({ + uuid: skin.uuid, + user_id: skin.user_id, + currentLvl: skin.currentLvl, + currentChroma: newChroma, + currentPrice: price() + }); + } catch (e) { + console.log('Database error', e); + } + } + } else { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Ce skin n'est pas améliorable`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // gif + const initialEmbed = new EmbedBuilder() + .setTitle(`Amélioration en cours...`) + .setImage('https://media.tenor.com/HD8nVN2QP9MAAAAC/thoughts-think.gif') + .setColor(0xF2F3F3); + + // Send the initial response and store the reply object + await res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { embeds: [initialEmbed] } + }); + + // then result + setTimeout(async () => { + // Prepare the final embed + let updatedSkin = await getSkin.get(trueSkin.uuid) + const randomLevel = updatedSkin.currentLvl + const randomChroma = updatedSkin.currentChroma + const selectedChroma = trueSkin.chromas[randomChroma-1] + + // Helper functions (unchanged from your original code) + const videoUrl = () => { + let res; + if (randomLevel === trueSkin.levels.length) { + if (randomChroma === 1) { + res = trueSkin.levels[trueSkin.levels.length - 1].streamedVideo ?? trueSkin.chromas[0].streamedVideo + } else { + res = trueSkin.chromas[randomChroma-1].streamedVideo + } + } else { + res = trueSkin.levels[randomLevel-1].streamedVideo + } + return res; + }; + const imageUrl = () => { + let res; + if (randomLevel === trueSkin.levels.length) { + if (randomChroma === 1) { + res = trueSkin.chromas[0].displayIcon + + } else { + res = trueSkin.chromas[randomChroma-1].fullRender ?? trueSkin.chromas[randomChroma-1].displayIcon + } + } else if (randomLevel === 1) { + res = trueSkin.levels[0].displayIcon ?? trueSkin.chromas[0].fullRender + } else if (randomLevel === 2 || randomLevel === 3) { + res = trueSkin.displayIcon + } + if (res) return res; + return trueSkin.displayIcon + }; + const chromaName = () => { + if (randomChroma >= 2) { + const name = selectedChroma.displayName.replace(/[\r\n]+/g, '').replace(trueSkin.displayName, '') + const match = name.match(/variante\s+[1-4]\s+([^)]+)/) + const result = match ? match[2] : null; + if (match) { + return match[1].trim() + } else { + return name + } + } + if (randomChroma === 1) { + return 'Base' + } + return '' + }; + const lvlText = () => { + let res = "" + if (randomLevel >= 1) { + res += '1️⃣ ' + } + if (randomLevel >= 2) { + res += '2️⃣ ' + } + if (randomLevel >= 3) { + res += '3️⃣ ' + } + if (randomLevel >= 4) { + res += '4️⃣ ' + } + if (randomLevel >= 5) { + res += '5️⃣ ' + } + for (let i = 0; i < trueSkin.levels.length - randomLevel; i++) { + res += '◾ ' + } + return res + } + const chromaText = () => { + let res = "" + for (let i = 1; i <= trueSkin.chromas.length; i++) { + res += randomChroma === i ? '💠 ' : '◾ ' + } + return res + } + + // Build the final embed + let finalEmbed; + if (succeeded) { + finalEmbed = new EmbedBuilder() + .setTitle(`L'amélioration a réussi ! 🎉`) + .setFields([ + { name: '', value: `${updatedSkin.displayName} | ${chromaName()}`, inline: false }, + { name: '', value: `**Lvl** | ${lvlText()}`, inline: true }, + { name: '', value: `**Chroma** | ${chromaText()}`, inline: true }, + { name: '', value: `**Prix** | ${updatedSkin.currentPrice} <:vp:1362964205808128122>`, inline: true }, + ]) + .setDescription(updatedSkin.tierText) + .setImage(imageUrl()) + .setColor(0x00FF00); + } + else { + finalEmbed = new EmbedBuilder() + .setTitle(`L'amélioration a râté... ❌`) + .setFields([ + { name: '', value: `${updatedSkin.displayName} | ${chromaName()}`, inline: false }, + ]) + .setDescription(updatedSkin.tierText) + .setImage(imageUrl()) + .setColor(0xFF0000); + } + + + // Prepare components if video exists + const video = videoUrl(); + const components = []; + + if (!succeeded) { + components.push(new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('Réessayer 🔄️') + .setStyle(ButtonStyle.Primary) + .setCustomId(`upgrade_${activeInventories[invId].reqBodyId}`) + )) + } else if (video) { + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('🎬 Aperçu vidéo') + .setStyle(ButtonStyle.Link) + .setURL(video) + ) + ); + } + + // Edit the original message + try { + await DiscordRequest( + `webhooks/${process.env.APP_ID}/${req.body.token}/messages/@original`, + { + method: 'PATCH', + body: { + embeds: [finalEmbed], + components: components + } + } + ); + } catch (err) { + console.error('Error editing message:', err); + } + }, 500); + } + else if (componentId.startsWith('prev_search_page')) { + let searchId = componentId.replace('prev_search_page_', ''); + const context = req.body.context; + // User ID is in user field for (G)DMs, and member for servers + const userId = context === 0 ? req.body.member.user.id : req.body.user.id; + + const guild = await client.guilds.fetch(req.body.guild_id); + if (!activeSearchs[searchId]) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Oups, cet affichage n'est plus actif.\nRelance la commande pour avoir un nouvel élément intéractif`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const chromaText = (skin) => { + let res = "" + for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { + res += skin.currentChroma === i ? '💠 ' : '◾ ' + } + return res + } + const chromaName = (skin) => { + if (skin.currentChroma >= 2) { + const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') + const match = name.match(/variante\s+[1-4]\s+([^)]+)/) + const result = match ? match[2] : null; + if (match) { + return match[1].trim() + } else { + return name + } + } + if (skin.currentChroma === 1) { + return 'Base' + } + return '' + }; + + if (activeSearchs[searchId] && activeSearchs[searchId].userId === req.body.member.user.id) { + if (activeSearchs[searchId].page === 0) { + activeSearchs[searchId].page = activeSearchs[searchId].amount-1 + } else { + activeSearchs[searchId].page-- + } + } else { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Tu n'est pas à l'origine de cette commande /search`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const trueSkin = skins.find((s) => s.uuid === activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].uuid); + const imageUrl = () => { + let res; + if (trueSkin.chromas[trueSkin.chromas.length-1].displayIcon) { + res = trueSkin.chromas[trueSkin.chromas.length-1].displayIcon + } else if (trueSkin.levels[trueSkin.levels.length-1].displayIcon) { + res = trueSkin.levels[trueSkin.levels.length-1].displayIcon + } else { + res = trueSkin.displayIcon + } + return res + }; + + const videoUrl = () => { + let res; + if (trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo) { + res = trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo + } else if (trueSkin.levels[trueSkin.levels.length-1].streamedVideo) { + res = trueSkin.levels[trueSkin.levels.length-1].streamedVideo + } else { + res = null + } + return res + }; + + const owner = await guild.members.fetch(activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].user_id) + let fields = [ + { + name: `**${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].displayName}** | ${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].tierText}`, + value: `${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].maxPrice}€ ${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].user_id ? '| **@'+ owner.user.username +'** ✅' : ''}`, + inline: false, + } + ] + + try { + const originalComponents = req.body.message.components || []; + + await DiscordRequest( + activeSearchs[searchId].endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Résultat de recherche`, + description: `🔎 ${activeSearchs[searchId].searchValue}`, + fields: fields, + color: parseInt(activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].tierColor, 16), + image: { url: imageUrl() }, + footer: { text: `${activeSearchs[searchId].page+1}/${activeSearchs[searchId].resultSkins.length} résultat(s)` }, + }, + ], + components: originalComponents, + }, + } + ); + } catch (err) { + console.log('Pas trouvé : ', err) + } + return res.send({ + type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, + }); + } + else if (componentId.startsWith('next_search_page')) { + let searchId = componentId.replace('next_search_page_', ''); + const context = req.body.context; + // User ID is in user field for (G)DMs, and member for servers + const userId = context === 0 ? req.body.member.user.id : req.body.user.id; + + const guild = await client.guilds.fetch(req.body.guild_id); + if (!activeSearchs[searchId]) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Oups, cet affichage n'est plus actif.\nRelance la commande pour avoir un nouvel élément intéractif`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const chromaText = (skin) => { + let res = "" + for (let i = 1; i <= skins.find((s) => s.uuid === skin.uuid).chromas.length; i++) { + res += skin.currentChroma === i ? '💠 ' : '◾ ' + } + return res + } + const chromaName = (skin) => { + if (skin.currentChroma >= 2) { + const name = skins.find((s) => s.uuid === skin.uuid).chromas[skin.currentChroma-1].displayName.replace(/[\r\n]+/g, '').replace(skin.displayName, '') + const match = name.match(/variante\s+[1-4]\s+([^)]+)/) + const result = match ? match[2] : null; + if (match) { + return match[1].trim() + } else { + return name + } + } + if (skin.currentChroma === 1) { + return 'Base' + } + return '' + }; + + if (activeSearchs[searchId] && activeSearchs[searchId].userId === req.body.member.user.id) { + if (activeSearchs[searchId].page === activeSearchs[searchId].amount-1) { + activeSearchs[searchId].page = 0 + } else { + activeSearchs[searchId].page++ + } + } else { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Tu n'est pas à l'origine de cette commande /search`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const trueSkin = skins.find((s) => s.uuid === activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].uuid); + const imageUrl = () => { + let res; + if (trueSkin.chromas[trueSkin.chromas.length-1].displayIcon) { + res = trueSkin.chromas[trueSkin.chromas.length-1].displayIcon + } else if (trueSkin.levels[trueSkin.levels.length-1].displayIcon) { + res = trueSkin.levels[trueSkin.levels.length-1].displayIcon + } else { + res = trueSkin.displayIcon + } + return res + }; + + const videoUrl = () => { + let res; + if (trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo) { + res = trueSkin.chromas[trueSkin.chromas.length-1].streamedVideo + } else if (trueSkin.levels[trueSkin.levels.length-1].streamedVideo) { + res = trueSkin.levels[trueSkin.levels.length-1].streamedVideo + } else { + res = null + } + return res + }; + + const owner = await guild.members.fetch(activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].user_id) + let fields = [ + { + name: `**${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].displayName}** | ${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].tierText}`, + value: `${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].maxPrice}€ ${activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].user_id ? '| **@'+ owner.user.username +'** ✅' : ''}`, + inline: false, + } + ] + + try { + const originalComponents = req.body.message.components || []; + + await DiscordRequest( + activeSearchs[searchId].endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Résultat de recherche`, + description: `🔎 ${activeSearchs[searchId].searchValue}`, + fields: fields, + color: parseInt(activeSearchs[searchId].resultSkins[activeSearchs[searchId].page].tierColor, 16), + image: { url: imageUrl() }, + footer: { text: `${activeSearchs[searchId].page+1}/${activeSearchs[searchId].resultSkins.length} résultat(s)` }, + }, + ], + components: originalComponents, + }, + } + ); + } catch (err) { + console.log('Pas trouvé : ', err) + } + return res.send({ + type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, + }); + } + else if (componentId.startsWith('option_')) { + const optionId = parseInt(componentId.replace('option_', '')[0]); + const prediId = componentId.replace(`option_${optionId}_`, ''); + let intAmount = 10; + + const commandUserId = req.body.member.user.id + const commandUser = getUser.get(commandUserId); + if (!commandUser) return res.status(403).send({ message: 'Oups, je ne te connais pas'}) + if (commandUser.coins < intAmount) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Tu n\'as pas assez de FlopoCoins', + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const prediObject = activePredis[prediId] + if (!prediObject) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Prédiction introuvable', + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + if (prediObject.endTime < Date.now()) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Les votes de cette prédiction sont clos', + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + const otherOption = optionId === 0 ? 1 : 0; + if (prediObject.options[otherOption].votes.find(v => v.id === commandUserId) && commandUserId !== process.env.DEV_ID) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Tu ne peux pas voter pour les 2 deux options', + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + let stopMaxCoins = false + if (prediObject.options[optionId].votes.find(v => v.id === commandUserId)) { + activePredis[prediId].options[optionId].votes.forEach(v => { + if (v.id === commandUserId) { + if (v.amount >= 250000) { + stopMaxCoins = true + return + } + if (v.amount + intAmount > 250000) { + intAmount = 250000-v.amount + } + v.amount += intAmount + } + }) + } else { + activePredis[prediId].options[optionId].votes.push({ + id: commandUserId, + amount: intAmount, + }) + } + + if (stopMaxCoins) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Tu as déjà parié le max (250K)', + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + activePredis[prediId].options[optionId].total += intAmount + + activePredis[prediId].options[optionId].percent = (activePredis[prediId].options[optionId].total / (activePredis[prediId].options[otherOption].total + activePredis[prediId].options[optionId].total)) * 100 + activePredis[prediId].options[otherOption].percent = 100 - activePredis[prediId].options[optionId].percent + + io.emit('new-predi', { action: 'new vote' }); + + updateUserCoins.run({ + id: commandUserId, + coins: commandUser.coins - intAmount, + }) + insertLog.run({ + id: commandUserId + '-' + Date.now(), + user_id: commandUserId, + action: 'PREDI_VOTE', + target_user_id: null, + coins_amount: -intAmount, + user_new_amount: commandUser.coins - intAmount, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + + try { + const totalAmount = activePredis[prediId].options[optionId].votes.find(v => v.id === commandUserId)?.amount; + const optionLabel = activePredis[prediId].options[optionId].label; + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Vote enregistré, **${intAmount}** Flopocoins sur **"${optionLabel}"** (**${totalAmount}** au total)`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } catch (err) { + console.log('Pas trouvé : ', err) + return res.send({ + type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE, + }); + } + } + return; + } + + console.error('unknown interaction type', type); + return res.status(400).json({ error: 'unknown interaction type' }); +}); + +app.use(express.json()); +app.use('/public', express.static('public')); + +// Check flAPI +app.get('/check', (req, res) => { + res.status(200).json({ check: true, status: 'OK' }); +}); + +// Get all users ordered by coins +app.get('/users', (req, res) => { + const users = getAllUsers.all(); + res.json(users); +}); + +app.get('/users/by-elo', (req, res) => { + const users = getUsersByElo.all() + res.json(users); +}) + +app.get('/logs', async (req, res) => { + // purge old logs + await pruneOldLogs() + + return res.status(200).json(getLogs.all()) +}) + +app.post('/timedout', async (req, res) => { + const { userId } = req.body + const guild = await client.guilds.fetch(process.env.GUILD_ID); + + let member; + try { + member = await guild.members.fetch(userId); + } catch (e) { + return res.status(404).send({ message: 'Unknown member' }) + } + + return res.status(200).json({ isTimedOut: member?.communicationDisabledUntilTimestamp > Date.now()}) +}) + +// Get user's avatar +app.get('/user/:id/avatar', async (req, res) => { + try { + const userId = req.params.id; // Get the ID from route parameters + const user = await client.users.fetch(userId); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const avatarUrl = user.displayAvatarURL({ format: 'png', size: 256 }); + res.json({ avatarUrl }); + + } catch (error) { + console.error('Error fetching user avatar'); + res.status(500).json({ error: 'Failed to fetch avatar' }); + } +}) + +app.get('/user/:id/username', async (req, res) => { + try { + const userId = req.params.id; // Get the ID from route parameters + const user = await client.users.fetch(userId); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ user }); + } catch (error) { + console.error('Error fetching user'); + res.status(500).json({ error: 'Failed to fetch user' }); + } +}) + +app.get('/user/:id/sparkline', async (req, res) => { + try { + const userId = req.params.id + + const user = getUser.get(userId) + + if (!user) return res.status(404).send({ message: 'Utilisateur introuvable'}) + + return res.status(200).json({ sparkline: getUserLogs.all({user_id: userId}) }) + } catch (e) { + return res.status(500).send({ message: 'erreur'}) + } +}) + +app.get('/user/:id/elo', async (req, res) => { + try { + const userId = req.params.id + + const user = getUser.get(userId) + + if (!user) return res.status(404).send({ message: 'Utilisateur introuvable'}) + + const userElo = getUserElo.get({ id: userId }) + + if (!userElo) return res.status(200).json({ elo: null }) + + return res.status(200).json({ elo: userElo.elo }) + } catch (e) { + return res.status(500).send({ message: 'erreur'}) + } +}) + +app.get('/user/:id/elo-graph', async (req, res) => { + try { + const userId = req.params.id + + const user = getUser.get(userId) + + if (!user) return res.status(404).send({ message: 'Utilisateur introuvable'}) + + + const games = getUserGames.all({ user_id: userId }); + + if (!games) return res.status(404).send({ message: 'Aucune partie'}) + + let array = [] + games.forEach((game, index) => { + if (game.p1 === userId) { + array.push(game.p1_elo) + if (index === games.length - 1) array.push(game.p1_new_elo) + } else if (game.p2 === userId) { + array.push(game.p2_elo) + if (index === games.length - 1) array.push(game.p2_new_elo) + } + }) + + return res.status(200).json({ elo_graph: array }) + } catch (e) { + return res.status(500).send({ message: 'erreur'}) + } +}) + +// Get user's inventory +app.get('/user/:id/inventory', async (req, res) => { + try { + const userId = req.params.id; // Get the ID from route parameters + const user = await client.users.fetch(userId); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const inventory = getUserInventory.all({user_id: userId}); + res.json({ inventory }); + + } catch (error) { + console.error('Error fetching user avatar'); + res.status(500).json({ error: 'Failed to fetch inventory' }); + } +}) + +app.get('/user/:id/daily', async (req, res) => { + const userId = req.params.id + + const akhy = getUser.get(userId) + + if (!akhy) return res.status(404).send({ message: 'Utilisateur introuvable'}) + + if (akhy.dailyQueried) return res.status(403).send({ message: 'Récompense déjà récupérée'}) + + const amount = 200 + const coins = akhy.coins + + queryDailyReward.run(userId) + updateUserCoins.run({ + id: userId, + coins: coins + amount, + }) + insertLog.run({ + id: userId + '-' + Date.now(), + user_id: userId, + action: 'DAILY_REWARD', + target_user_id: null, + coins_amount: amount, + user_new_amount: coins + amount, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + + return res.status(200).send({ message: 'Récompense récupérée !' }) +}) + +// Get active polls +app.get('/polls', async (req, res) => { + try { + res.json({ activePolls }); + + } catch (error) { + console.error('Error fetching active polls'); + res.status(500).json({ error: 'Failed to fetch active polls' }); + } +}) + +// Send a custom message in the admin command channel +app.post('/send-message', (req, res) => { + const { userId, channelId, message } = req.body; + const channel = client.channels.cache.get(channelId); + + const user = getUser.get(userId); + + if (!user) return res.status(404).json({ error: 'User not found' }); + + if (!channel) return res.status(404).json({ error: 'Channel not found' }); + + if (user.coins < 10) return res.status(403).json({ error: 'Pas assez de coins' }); + + updateUserCoins.run({ + id: userId, + coins: user.coins - 10, + }) + insertLog.run({ + id: userId + '-' + Date.now(), + user_id: userId, + action: 'SEND_MESSAGE', + target_user_id: null, + coins_amount: -10, + user_new_amount: user.coins - 10, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + + channel.send(message) + .then(() => res.json({ success: true })) + .catch(err => res.status(500).json({ error: err.message })); +}); + +// Change user's server specific username +app.post('/change-nickname', async (req, res) => { + const { userId, nickname, commandUserId } = req.body; + + const commandUser = getUser.get(commandUserId); + + if (!commandUser) return res.status(404).json({ message: 'Oups petit soucis' }); + + if (commandUser.coins < 1000) return res.status(403).json({ message: 'Pas assez de coins' }); + + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const member = await guild.members.fetch(userId); + await member.setNickname(nickname); + let message = nickname ? `Le pseudo de '${member.user.tag}' a été changé en '${nickname}'` : `Le pseudo de '${member.user.tag}' a été remis par défaut` + res.status(200).json({ message : message }); + updateUserCoins.run({ + id: commandUserId, + coins: commandUser.coins - 1000, + }) + insertLog.run({ + id: commandUserId + '-' + Date.now(), + user_id: commandUserId, + action: 'CHANGE_NICKNAME', + target_user_id: userId, + coins_amount: -1000, + user_new_amount: commandUser.coins - 1000, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + } catch (error) { + res.status(500).json({ message : `J'ai pas réussi à changer le pseudo` }); + } +}) + +app.post('/spam-ping', async (req, res) => { + const { userId, commandUserId } = req.body; + + const user = getUser.get(userId); + const commandUser = getUser.get(commandUserId); + + if (!commandUser || !user) return res.status(404).json({ message: 'Oups petit soucis' }); + + if (commandUser.coins < 10000) return res.status(403).json({ message: 'Pas assez de coins' }); + + try { + const discordUser = await client.users.fetch(userId); + + await discordUser.send(`<@${userId}>`) + + res.status(200).json({ message : 'C\'est parti ehehe' }); + + updateUserCoins.run({ + id: commandUserId, + coins: commandUser.coins - 10000, + }) + insertLog.run({ + id: commandUserId + '-' + Date.now(), + user_id: commandUserId, + action: 'SPAM_PING', + target_user_id: userId, + coins_amount: -10000, + user_new_amount: commandUser.coins - 10000, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + + for (let i = 0; i < 29; i++) { + await discordUser.send(`<@${userId}>`) + await sleep(1000); + } + } catch (err) { + console.log(err) + res.status(500).json({ message : "Oups ça n'a pas marché" }); + } +}) + +app.post('/timeout/vote', async (req, res) => { + const { commandUserId, voteKey, voteFor } = req.body; + + const commandUser = getUser.get(commandUserId); + const poll = activePolls[voteKey]; + const isVotingFor = voteFor; + + if (!commandUser) return res.status(404).json({ message: 'Oups petit soucis' }); + if (!poll) return res.status(404).json({ message: 'Vote de timeout introuvable' }); + + if (activePolls[voteKey]) { + const poll = activePolls[voteKey]; + poll.voters = poll.voters || []; + const voterId = commandUserId; + + const guild = await client.guilds.fetch(process.env.GUILD_ID) + const commandMember = await guild.members.fetch(commandUserId); + // Check if the voter has the required voting role + const voterRoles = commandMember.roles.cache.map(role => role.id) || []; + if (!voterRoles.includes(process.env.VOTING_ROLE_ID)) { + return res.status(403).json({ message: 'Tu n\'as pas le rôle requis pour voter'}) + } + + // Enforce one vote per eligible user + if (poll.voters.find(u => u === voterId)) { + return res.status(403).json({ message: 'Tu as déjà voté'}) + } + + // Record the vote + poll.voters.push(voterId); + if (isVotingFor) { + poll.for++; + } else { + poll.against++; + } + + io.emit('new-poll', { action: 'new vote' }); + + // Retrieve online eligible users (ensure your bot has the necessary intents) + const guildId = process.env.GUILD_ID; + const roleId = process.env.VOTING_ROLE_ID; // Set this in your .env file + const onlineEligibleUsers = await getOnlineUsersWithRole(guildId, roleId); + const votesNeeded = Math.max(0, poll.requiredMajority - poll.for); + + // Check if the majority is reached + if (poll.for >= poll.requiredMajority) { + try { + // Build the updated poll message content + let forText = '' + poll.voters.forEach((voter) => { + const user = getUser.get(voter); + forText += `- ${user.globalName}\n` + }) + await DiscordRequest( + poll.endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Timeout`, + description: `Proposition de timeout **${poll.toUsername}** pendant ${poll.time_display}`, + fields: [ + { + name: 'Votes totaux', + value: '✅ ' + poll.for + '\n' + forText, + inline: true, + }, + ], + color: 0xF2F3F3, // You can set the color of the embed + }, + ], + components: [], // remove buttons + }, + } + ); + } catch (err) { + console.error('Error updating poll message:', err); + } + // Clear the poll so the setTimeout callback doesn't fire later + delete activePolls[voteKey]; + + // **Actual Timeout Action** + try { + // Calculate the ISO8601 timestamp to disable communications until now + poll.time seconds + const timeoutUntil = new Date(Date.now() + poll.time * 1000).toISOString(); + const endpointTimeout = `guilds/${process.env.GUILD_ID}/members/${poll.toUserId}`; + await DiscordRequest(endpointTimeout, { + method: 'PATCH', + body: { communication_disabled_until: timeoutUntil }, + }); + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `<@${poll.toUserId}> a été timeout pendant ${poll.time_display} par décision démocratique 👊`, + }, + }); + } catch (err) { + console.error('Error timing out user:', err); + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Impossible de timeout <@${poll.toUserId}>, désolé... 😔`, + }, + }); + } + } + + // If the vote is "for", update the original poll message to reflect the new vote count. + if (isVotingFor) { + const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000)); + const minutes = Math.floor(remaining / 60); + const seconds = remaining % 60; + const countdownText = `**${minutes}m ${seconds}s** restantes`; + try { + // Build the updated poll message content + let forText = '' + poll.voters.forEach((voter) => { + const user = getUser.get(voter); + forText += `- ${user.globalName}\n` + }) + await DiscordRequest( + poll.endpoint, + { + method: 'PATCH', + body: { + embeds: [ + { + title: `Timeout`, + description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}\nIl manque **${votesNeeded}** vote(s)`, + fields: [ + { + name: 'Pour', + value: '✅ ' + poll.for + '\n' + forText, + inline: true, + }, + { + name: 'Temps restant', + value: '⏳ ' + countdownText, + inline: false, + }, + ], + color: 0xF2F3F3, // You can set the color of the embed + }, + ], + components: req.body.message.components, // preserve the buttons + }, + } + ); + } catch (err) { + console.error('Error updating poll message:', err); + } + } + + return res.status(200).json({ message: 'Vote enregistré !'}) + } +}) + +app.post('/slowmode', async (req, res) => { + let { userId, commandUserId} = req.body + + const user = getUser.get(userId) + const commandUser = getUser.get(commandUserId); + + if (!commandUser || !user) return res.status(404).json({ message: 'Oups petit soucis' }); + + if (commandUser.coins < 10000) return res.status(403).json({ message: 'Pas assez de coins' }); + + if (!user) return res.status(403).send({ message: 'Oups petit problème'}) + + if (activeSlowmodes[userId]) { + if (userId === commandUserId) { + delete activeSlowmodes[userId]; + return res.status(200).json({ message: 'Slowmode retiré'}) + } else { + let timeLeft = (activeSlowmodes[userId].endAt - Date.now())/1000 + timeLeft = timeLeft > 60 ? (timeLeft/60).toFixed()?.toString() + 'min' : timeLeft.toFixed()?.toString() + 'sec' + return res.status(403).json({ message: `${user.globalName} est déjà en slowmode (${timeLeft})`}) + } + } else if (userId === commandUserId) { + return res.status(403).json({ message: 'Impossible de te mettre toi-même en slowmode'}) + } + + activeSlowmodes[userId] = { + userId: userId, + endAt: Date.now() + 60 * 60 * 1000, // 1 heure + lastMessage: null, + }; + io.emit('new-slowmode', { action: 'new slowmode' }); + + updateUserCoins.run({ + id: commandUserId, + coins: commandUser.coins - 10000, + }) + insertLog.run({ + id: commandUserId + '-' + Date.now(), + user_id: commandUserId, + action: 'SLOWMODE', + target_user_id: userId, + coins_amount: -10000, + user_new_amount: commandUser.coins - 10000, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + + return res.status(200).json({ message: `${user.globalName} est maintenant en slowmode pour 1h`}) +}) + +app.get('/slowmodes', async (req, res) => { + res.status(200).json({ slowmodes: activeSlowmodes }); +}) + +app.post('/start-predi', async (req, res) => { + let { commandUserId, label, options, closingTime, payoutTime } = req.body + + const commandUser = getUser.get(commandUserId) + + if (!commandUser) return res.status(403).send({ message: 'Oups petit problème'}) + if (commandUser.coins < 100) return res.status(403).send({ message: 'Tu n\'as pas assez de FlopoCoins'}) + + if (Object.values(activePredis).find(p => p.creatorId === commandUserId && (p.endTime > Date.now() && !p.closed))) { + return res.status(403).json({ message: `Tu ne peux pas lancer plus d'une prédi à la fois !`}) + } + + const startTime = Date.now() + const newPrediId = commandUserId?.toString() + '-' + startTime?.toString() + + let msgId; + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = guild.channels.cache.find( + ch => ch.name === 'général' || ch.name === 'general' + ); + const embed = new EmbedBuilder() + .setTitle(`Prédiction de ${commandUser.username}`) + .setDescription(`**${label}**`) + .addFields( + { name: `${options[0]}`, value: ``, inline: true }, + { name: ``, value: `ou`, inline: true }, + { name: `${options[1]}`, value: ``, inline: true } + ) + .setFooter({ text: `${formatTime(closingTime).replaceAll('*', '')} pour voter` }) + .setColor('#5865f2') + .setTimestamp(new Date()); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`option_0_${newPrediId}`) + .setLabel(`+10 sur '${options[0]}'`) + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`option_1_${newPrediId}`) + .setLabel(`+10 sur '${options[1]}'`) + .setStyle(ButtonStyle.Primary) + ); + + const row2 = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setLabel('Voter sur FlopoSite') + .setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/dashboard`) + .setStyle(ButtonStyle.Link) + ) + + const msg = await generalChannel.send({ embeds: [embed], components: [row, row2] }); + msgId = msg.id; + } catch (e) { + return res.status(500).send({ message: 'Erreur lors de l\'envoi du message'}) + } + + const formattedOptions = [ + { label: options[0], votes: [], total: 0, percent: 0, }, + { label: options[1], votes: [], total: 0, percent: 0, }, + ] + activePredis[newPrediId] = { + creatorId: commandUserId, + label: label, + options: formattedOptions, + startTime: startTime, + closingTime: startTime + (closingTime * 1000), + endTime: startTime + (closingTime * 1000) + (payoutTime * 1000), + closed: false, + winning: null, + cancelledTime: null, + paidTime: null, + msgId: msgId, + }; + io.emit('new-predi', { action: 'new predi' }); + + updateUserCoins.run({ + id: commandUserId, + coins: commandUser.coins - 100, + }) + insertLog.run({ + id: commandUserId + '-' + Date.now(), + user_id: commandUserId, + action: 'START_PREDI', + target_user_id: null, + coins_amount: -100, + user_new_amount: commandUser.coins - 100, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + + return res.status(200).json({ message: `Ta prédi '${label}' a commencée !`}) +}) + +app.get('/predis', async (req, res) => { + const reversedPredis = Object.entries(activePredis).reverse(); + + const openEntries = []; + const closedEntries = []; + + for (const [key, value] of reversedPredis) { + if (value.closed === true) { + closedEntries.push([key, value]); + } else { + openEntries.push([key, value]); + } + } + + const reorderedPredis = Object.fromEntries([...openEntries, ...closedEntries]); + + res.status(200).json({ predis: reorderedPredis }); +}); + +app.post('/vote-predi', async (req, res) => { + const { commandUserId, predi, amount, option } = req.body + + let warning = false; + + let intAmount = parseInt(amount) + if (intAmount < 10 || intAmount > 250000) return res.status(403).send({ message: 'Montant invalide'}) + + const commandUser = getUser.get(commandUserId) + if (!commandUser) return res.status(403).send({ message: 'Oups, je ne te connais pas'}) + if (commandUser.coins < intAmount) return res.status(403).send({ message: 'Tu n\'as pas assez de FlopoCoins'}) + + const prediObject = activePredis[predi] + if (!prediObject) return res.status(403).send({ message: 'Prédiction introuvable'}) + + if (prediObject.endTime < Date.now()) return res.status(403).send({ message: 'Les votes de cette prédiction sont clos'}) + + const otherOption = option === 0 ? 1 : 0; + if (prediObject.options[otherOption].votes.find(v => v.id === commandUserId) && commandUserId !== process.env.DEV_ID) return res.status(403).send({ message: 'Tu ne peux pas voter pour les 2 deux options'}) + + if (prediObject.options[option].votes.find(v => v.id === commandUserId)) { + activePredis[predi].options[option].votes.forEach(v => { + if (v.id === commandUserId) { + if (v.amount === 250000) { + return res.status(403).send({ message: 'Tu as déjà parié le max (250K)'}) + } + if (v.amount + intAmount > 250000) { + intAmount = 250000-v.amount + warning = true + } + v.amount += intAmount + } + }) + } else { + activePredis[predi].options[option].votes.push({ + id: commandUserId, + amount: intAmount, + }) + } + activePredis[predi].options[option].total += intAmount + + activePredis[predi].options[option].percent = (activePredis[predi].options[option].total / (activePredis[predi].options[otherOption].total + activePredis[predi].options[option].total)) * 100 + activePredis[predi].options[otherOption].percent = 100 - activePredis[predi].options[option].percent + + io.emit('new-predi', { action: 'new vote' }); + + updateUserCoins.run({ + id: commandUserId, + coins: commandUser.coins - intAmount, + }) + insertLog.run({ + id: commandUserId + '-' + Date.now(), + user_id: commandUserId, + action: 'PREDI_VOTE', + target_user_id: null, + coins_amount: -intAmount, + user_new_amount: commandUser.coins - intAmount, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + + return res.status(200).send({ message : `Vote enregistré!` }); +}) + +app.post('/end-predi', async (req, res) => { + const { commandUserId, predi, confirm, winningOption } = req.body + + const commandUser = getUser.get(commandUserId) + if (!commandUser) return res.status(403).send({ message: 'Oups, je ne te connais pas'}) + if (commandUserId !== process.env.DEV_ID) return res.status(403).send({ message: 'Tu n\'as pas les permissions requises' }) + + const prediObject = activePredis[predi] + if (!prediObject) return res.status(403).send({ message: 'Prédiction introuvable'}) + if (prediObject.closed) return res.status(403).send({ message: 'Prédiction déjà close'}) + + if (!confirm) { + activePredis[predi].cancelledTime = new Date(); + activePredis[predi].options[0].votes.forEach((v) => { + const tempUser = getUser.get(v.id) + try { + updateUserCoins.run({ + id: v.id, + coins: tempUser.coins + v.amount + }) + insertLog.run({ + id: v.id + '-' + Date.now(), + user_id: v.id, + action: 'PREDI_REFUND', + target_user_id: v.id, + coins_amount: v.amount, + user_new_amount: tempUser.coins + v.amount, + }) + } catch (e) { + console.log(`Impossible de rembourser ${v.id} (${v.amount} coins)`) + } + }) + activePredis[predi].options[1].votes.forEach((v) => { + const tempUser = getUser.get(v.id) + try { + updateUserCoins.run({ + id: v.id, + coins: tempUser.coins + v.amount + }) + insertLog.run({ + id: v.id + '-' + Date.now(), + user_id: v.id, + action: 'PREDI_REFUND', + target_user_id: v.id, + coins_amount: v.amount, + user_new_amount: tempUser.coins + v.amount, + }) + } catch (e) { + console.log(`Impossible de rembourser ${v.id} (${v.amount} coins)`) + } + }) + activePredis[predi].closed = true; + } + else { + const losingOption = winningOption === 0 ? 1 : 0; + activePredis[predi].options[winningOption].votes.forEach((v) => { + const tempUser = getUser.get(v.id) + const ratio = activePredis[predi].options[winningOption].total === 0 ? 0 : activePredis[predi].options[losingOption].total / activePredis[predi].options[winningOption].total + try { + updateUserCoins.run({ + id: v.id, + coins: tempUser.coins + (v.amount * (1 + ratio)) + }) + insertLog.run({ + id: v.id + '-' + Date.now(), + user_id: v.id, + action: 'PREDI_RESULT', + target_user_id: v.id, + coins_amount: v.amount * (1 + ratio), + user_new_amount: tempUser.coins + (v.amount * (1 + ratio)), + }) + } catch (e) { + console.log(`Impossible de créditer ${v.id} (${v.amount} coins pariés, *${1 + ratio})`) + } + }) + activePredis[predi].paidTime = new Date(); + activePredis[predi].closed = true; + activePredis[predi].winning = winningOption; + } + + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = guild.channels.cache.find( + ch => ch.name === 'général' || ch.name === 'general' + ); + const message = await generalChannel.messages.fetch(activePredis[predi].msgId) + const updatedEmbed = new EmbedBuilder() + .setTitle(`Prédiction de ${commandUser.username}`) + .setDescription(`**${activePredis[predi].label}**`) + .setFields({ name: `${activePredis[predi].options[0].label}`, value: ``, inline: true }, + { name: ``, value: `ou`, inline: true }, + { name: `${activePredis[predi].options[1].label}`, value: ``, inline: true }, + ) + .setFooter({ text: `${activePredis[predi].cancelledTime !== null ? 'Prédi annulée' : 'Prédi confirmée !' }` }) + .setTimestamp(new Date()); + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setLabel('Voir') + .setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/dashboard`) + .setStyle(ButtonStyle.Link) + ) + await message.edit({ embeds: [updatedEmbed], components: [row] }); + } catch (err) { + console.error('Error updating prédi message:', err); + } + + io.emit('new-predi', { action: 'closed predi' }); + io.emit('data-updated', { table: 'users', action: 'fin predi' }); + + return res.status(200).json({ message: 'Prédi close' }); +}) + +// ADMIN Add coins +app.post('/add-coins', (req, res) => { + const { commandUserId } = req.body; + + const commandUser = getUser.get(commandUserId); + + if (!commandUser) return res.status(404).json({ error: 'User not found' }); + if (commandUserId !== process.env.DEV_ID) return res.status(404).json({ error: 'Not admin' }); + + updateUserCoins.run({ + id: commandUserId, + coins: commandUser.coins + 1000, + }) + insertLog.run({ + id: commandUserId + '-' + Date.now(), + user_id: commandUserId, + action: 'ADD_COINS', + target_user_id: commandUserId, + coins_amount: 1000, + user_new_amount: commandUser.coins + 1000, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + + res.status(200).json({ message : `+1000` }); +}); + +app.post('/buy-coins', (req, res) => { + const { commandUserId, coins } = req.body; + + const commandUser = getUser.get(commandUserId); + + if (!commandUser) return res.status(404).json({ error: 'User not found' }); + + updateUserCoins.run({ + id: commandUserId, + coins: commandUser.coins + coins, + }) + insertLog.run({ + id: commandUserId + '-' + Date.now(), + user_id: commandUserId, + action: 'ADD_COINS', + target_user_id: commandUserId, + coins_amount: coins, + user_new_amount: commandUser.coins + coins, + }) + io.emit('data-updated', { table: 'users', action: 'update' }); + + res.status(200).json({ message : `+${coins}` }); +}); + +const pokerRooms = {} +app.post('/create-poker-room', async (req, res) => { + const { creatorId } = req.body + const id = uuidv4() + const t12names = [ + 'cassoule', + 'passoule', + 'kiwiko', + 'piwiko', + 'wata', + 'pata', + 'apologize', + 'apologay', + 'daspoon', + 'esteban', + 'edorima', + 'momozhok', + 'popozhok', + 'dodozhok', + 'flopozhok', + 'thomas', + 'poma' + ] + const name = uniqueNamesGenerator({ dictionaries: [adjectives, t12names], separator: ' ', style: 'capital' }); + + const creator = await client.users.fetch(creatorId) + + if (!creator) { + return res.status(404).send({message: 'Utilisateur introuvable'}) + } + if (Object.values(pokerRooms).find(room => room.host_id === creatorId)) { + return res.status(403).send({message: 'Tu ne peux créer qu\'une seule table à la fois'}) + } + + const alreadyInARoom = Object.values(pokerRooms).find((room) => { + return Object.keys(room.players).includes(creatorId) + }) + + if (alreadyInARoom) return res.status(403).send({ message: 'Tu es déjà assis à une table' }) + + pokerRooms[id] = { + id: id, + host_id: creatorId, + host_name: creator.globalName, + name: name, + created_at: Date.now(), + last_move_at: Date.now(), + players: {}, + queue: {}, + afk: {}, + pioche: initialShuffledCards(), + tapis: [], + dealer: null, + sb: null, + bb: null, + highest_bet: null, + current_player: null, + current_turn: null, + playing: false, + winners: [], + waiting_for_restart: false, + fakeMoney: false, + } + + res.status(200).send({ roomId: id }) + + try { + const url = (process.env.DEV_SITE === 'true' ? process.env.API_URL_DEV : process.env.API_URL) + '/poker-room/join' + const response = await axios.post(url, { userId: creatorId, roomId: id }) + } catch (e) { + console.log(e) + } + + io.emit('new-poker-room') +}); + +app.get('/poker-rooms', (req, res) => { + return res.status(200).send({ rooms: pokerRooms }) +}) + +app.get('/poker-rooms/:id', (req, res) => { + return res.status(200).send({ room: pokerRooms[req.params.id] }) +}) + +app.post('/poker-room/join', async (req, res) => { + const { userId, roomId } = req.body + + const user = await client.users.fetch(userId) + + const alreadyInARoom = Object.values(pokerRooms).find((room) => { + return Object.keys(room.players).includes(userId) + }) + + if (alreadyInARoom) return res.status(403).send({ message: 'Déjà assis à une table' }) + + let amount = getUser.get(userId)?.coins + let fakeMoney = false + + if (!amount || amount < 1000) { + amount = 1000 + fakeMoney = true + } + + const player = { + id: user.id, + globalName: user.globalName, + hand: [], + bank: amount, + bet: null, + solve: null, + folded: false, + allin: false, + last_played_turn: null, + is_last_raiser: false, + } + + try { + if (pokerRooms[roomId].playing) { + pokerRooms[roomId].queue[userId] = player + } else { + pokerRooms[roomId].players[userId] = player + } + if (pokerRooms[roomId].afk[userId]) { + delete pokerRooms[roomId].afk[userId] + } + if (fakeMoney) pokerRooms[roomId].fakeMoney = true + } catch (e) { + // + } + + io.emit('new-poker-room') + return res.status(200) +}); + +app.post('/poker-room/accept', async (req, res) => { + const { userId, roomId } = req.body + + const player = pokerRooms[roomId].queue[userId] + + if (!player) return res.status(404).send({ message: 'Joueur introuvable dans le file d\'attente'}); + + try { + pokerRooms[roomId].players[userId] = player + delete pokerRooms[roomId].queue[userId] + if (pokerRooms[roomId].afk[userId]) { + delete pokerRooms[roomId].afk[userId] + } + } catch (e) { + // + } + + io.emit('new-poker-room') + return res.status(200) +}) + +app.post('/poker-room/kick', async (req, res) => { + //TODO +}) + +app.post('/poker-room/leave', async (req, res) => { + const { userId, roomId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + if (!pokerRooms[roomId].players[userId]) return res.status(404).send({ message: 'Joueur introuvable' }) + + if (pokerRooms[roomId].playing && (pokerRooms[roomId].current_turn !== null && pokerRooms[roomId].current_turn !== 4)) { + pokerRooms[roomId].afk[userId] = pokerRooms[roomId].players[userId] + + try { + pokerRooms[roomId].players[userId].folded = true + pokerRooms[roomId].players[userId].last_played_turn = pokerRooms[roomId].current_turn + } catch(e) { + console.log(e) + } + + io.emit('new-poker-room') + + return res.status(200) + } + + try { + delete pokerRooms[roomId].players[userId] + + if (userId === pokerRooms[roomId].host_id) { + const newHostId = Object.keys(pokerRooms[roomId].players).find(id => id !== userId) + if (!newHostId) { + delete pokerRooms[roomId] + } else { + pokerRooms[roomId].host_id = newHostId + } + } + } catch (e) { + // + } + + io.emit('new-poker-room') + return res.status(200) +}); + +app.post('/poker-room/start', async (req, res) => { + const { roomId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + + // preflop + try { + for (const playerId in pokerRooms[roomId].players) { + const player = pokerRooms[roomId].players[playerId] + for (let i = 0; i < 2; i++) { + if (pokerRooms[roomId].pioche.length > 0) { + player.hand.push(pokerRooms[roomId].pioche[0]) + pokerRooms[roomId].pioche.shift() + } + } + } + for (const playerId in pokerRooms[roomId].players) { + try { + const player = pokerRooms[roomId].players[playerId] + let fullHand = pokerRooms[roomId].tapis + player.solve = Hand.solve(fullHand.concat(player.hand), 'standard', false)?.descr + } catch (e) { + console.log('erreur lors du hand solver') + } + } + } catch (e) { + console.log(e) + } + + pokerRooms[roomId].dealer = Object.keys(pokerRooms[roomId].players)[0] + pokerRooms[roomId].sb = Object.keys(pokerRooms[roomId].players)[1] + pokerRooms[roomId].bb = Object.keys(pokerRooms[roomId].players)[2 % Object.keys(pokerRooms[roomId].players).length] + pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[1]].bet = 10 //SB + pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[1]].bank -= 10 //SB + pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[2 % Object.keys(pokerRooms[roomId].players).length]].bet = 20 //BB + pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[2 % Object.keys(pokerRooms[roomId].players).length]].bank -= 20 //BB + pokerRooms[roomId].highest_bet = 20 + pokerRooms[roomId].current_player = Object.keys(pokerRooms[roomId].players)[3 % Object.keys(pokerRooms[roomId].players).length] + pokerRooms[roomId].current_turn = 0; + + pokerRooms[roomId].players[pokerRooms[roomId].bb].last_played_turn = pokerRooms[roomId].current_turn + + if (!pokerRooms[roomId].fakeMoney) { + const DB_SBplayer = await getUser.get(Object.keys(pokerRooms[roomId].players)[1]) + const DB_BBplayer = await getUser.get(Object.keys(pokerRooms[roomId].players)[2 % Object.keys(pokerRooms[roomId].players).length]) + if (DB_SBplayer) { + updateUserCoins.run({ + id: DB_SBplayer.id, + coins: pokerRooms[roomId].players[DB_SBplayer.id].bank, + }) + insertLog.run({ + id: DB_SBplayer.id + '-' + Date.now(), + user_id: DB_SBplayer.id, + action: 'POKER_SMALL_BLIND', + target_user_id: DB_SBplayer.id, + coins_amount: -10, + user_new_amount: DB_SBplayer.coins - 10, + }) + } + if (DB_BBplayer) { + updateUserCoins.run({ + id: DB_BBplayer.id, + coins: pokerRooms[roomId].players[DB_BBplayer.id].bank, + }) + insertLog.run({ + id: DB_BBplayer.id + '-' + Date.now(), + user_id: DB_BBplayer.id, + action: 'POKER_BIG_BLIND', + target_user_id: DB_BBplayer.id, + coins_amount: -20, + user_new_amount: DB_BBplayer.coins - 20, + }) + } + io.emit('data-updated', {table: 'users', action: 'update'}); + } + + pokerRooms[roomId].playing = true + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return res.status(200) +}) + +async function handleRoomStart(roomId, dealerId = 0) { + if (!pokerRooms[roomId]) return false + + // preflop + try { + for (const playerId in pokerRooms[roomId].players) { + const player = pokerRooms[roomId].players[playerId] + for (let i = 0; i < 2; i++) { + if (pokerRooms[roomId].pioche.length > 0) { + player.hand.push(pokerRooms[roomId].pioche[0]) + pokerRooms[roomId].pioche.shift() + } + } + } + for (const playerId in pokerRooms[roomId].players) { + try { + const player = pokerRooms[roomId].players[playerId] + let fullHand = pokerRooms[roomId].tapis + player.solve = Hand.solve(fullHand.concat(player.hand), 'standard', false)?.descr + } catch(e) { + console.log('erreur lors du hand solver') + } + } + } catch (e) { + console.log(e) + } + + pokerRooms[roomId].dealer = Object.keys(pokerRooms[roomId].players)[(dealerId + 1) % Object.keys(pokerRooms[roomId].players).length] + pokerRooms[roomId].sb = Object.keys(pokerRooms[roomId].players)[(dealerId + 2) % Object.keys(pokerRooms[roomId].players).length] + pokerRooms[roomId].bb = Object.keys(pokerRooms[roomId].players)[(dealerId + 3) % Object.keys(pokerRooms[roomId].players).length] + pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[(dealerId + 2) % Object.keys(pokerRooms[roomId].players).length]].bet = 10 //SB + pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[(dealerId + 2) % Object.keys(pokerRooms[roomId].players).length]].bank -= 10 //SB + pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[(dealerId + 3) % Object.keys(pokerRooms[roomId].players).length]].bet = 20 //BB + pokerRooms[roomId].players[Object.keys(pokerRooms[roomId].players)[(dealerId + 3) % Object.keys(pokerRooms[roomId].players).length]].bank -= 20 //BB + pokerRooms[roomId].highest_bet = 20 + pokerRooms[roomId].current_player = Object.keys(pokerRooms[roomId].players)[(dealerId + 4) % Object.keys(pokerRooms[roomId].players).length] + pokerRooms[roomId].current_turn = 0; + + pokerRooms[roomId].players[pokerRooms[roomId].bb].last_played_turn = pokerRooms[roomId].current_turn + + if (!pokerRooms[roomId].fakeMoney) { + const DB_SBplayer = await getUser.get(Object.keys(pokerRooms[roomId].players)[(dealerId + 2) % Object.keys(pokerRooms[roomId].players).length]) + const DB_BBplayer = await getUser.get(Object.keys(pokerRooms[roomId].players)[(dealerId + 3) % Object.keys(pokerRooms[roomId].players).length]) + if (DB_SBplayer) { + updateUserCoins.run({ + id: DB_SBplayer.id, + coins: pokerRooms[roomId].players[DB_SBplayer.id].bank, + }) + insertLog.run({ + id: DB_SBplayer.id + '-' + Date.now(), + user_id: DB_SBplayer.id, + action: 'POKER_SMALL_BLIND', + target_user_id: DB_SBplayer.id, + coins_amount: -10, + user_new_amount: DB_SBplayer.coins - 10, + }) + } + if (DB_BBplayer) { + updateUserCoins.run({ + id: DB_BBplayer.id, + coins: pokerRooms[roomId].players[DB_BBplayer.id].bank, + }) + insertLog.run({ + id: DB_BBplayer.id + '-' + Date.now(), + user_id: DB_BBplayer.id, + action: 'POKER_BIG_BLIND', + target_user_id: DB_BBplayer.id, + coins_amount: -20, + user_new_amount: DB_BBplayer.coins - 20, + }) + } + io.emit('data-updated', {table: 'users', action: 'update'}); + } + + pokerRooms[roomId].playing = true + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return true +} + +app.post('/poker-room/flop', async (req, res) => { + const { roomId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + + //flop + pokerRooms[roomId].current_turn = 1 + try { + for (let i = 0; i < 3; i++) { + if (pokerRooms[roomId].pioche.length > 0) { + pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) + pokerRooms[roomId].pioche.shift() + } + } + await updatePokerPlayersSolve(roomId) + } catch(e) { + console.log(e) + } + + pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return res.status(200) +}); + +async function handleFlop(roomId) { + if (!pokerRooms[roomId]) return false + + //flop + pokerRooms[roomId].current_turn = 1 + try { + for (let i = 0; i < 3; i++) { + if (pokerRooms[roomId].pioche.length > 0) { + pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) + pokerRooms[roomId].pioche.shift() + } + } + await updatePokerPlayersSolve(roomId) + } catch(e) { + console.log(e) + } + + pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return true +} + +app.post('/poker-room/turn', async (req, res) => { + const { roomId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + + //turn + pokerRooms[roomId].current_turn = 2 + try { + if (pokerRooms[roomId].pioche.length > 0) { + pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) + pokerRooms[roomId].pioche.shift() + } + + await updatePokerPlayersSolve(roomId) + } catch(e) { + console.log(e) + } + + pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return res.status(200) +}); + +async function handleTurn(roomId) { + if (!pokerRooms[roomId]) return false + + //turn + pokerRooms[roomId].current_turn = 2 + try { + if (pokerRooms[roomId].pioche.length > 0) { + pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) + pokerRooms[roomId].pioche.shift() + } + + await updatePokerPlayersSolve(roomId) + } catch(e) { + console.log(e) + } + + pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return true +} + +app.post('/poker-room/river', async (req, res) => { + const { roomId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + + //river + pokerRooms[roomId].current_turn = 3 + try { + if (pokerRooms[roomId].pioche.length > 0) { + pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) + pokerRooms[roomId].pioche.shift() + } + + await updatePokerPlayersSolve(roomId) + } catch(e) { + console.log(e) + } + + pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return res.status(200) +}); + +async function handleRiver(roomId) { + if (!pokerRooms[roomId]) return false + + //river + pokerRooms[roomId].current_turn = 3 + try { + if (pokerRooms[roomId].pioche.length > 0) { + pokerRooms[roomId].tapis.push(pokerRooms[roomId].pioche[0]) + pokerRooms[roomId].pioche.shift() + } + + await updatePokerPlayersSolve(roomId) + } catch(e) { + console.log(e) + } + + pokerRooms[roomId].current_player = getFirstActivePlayerAfterDealer(pokerRooms[roomId]) + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return true +} + +app.post('/poker-room/showdown', async (req, res) => { + const { roomId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + + //showdown + pokerRooms[roomId].current_turn = 4 + pokerRooms[roomId].current_player = null + + await updatePokerPlayersSolve(roomId) + + pokerRooms[roomId].winners = checkRoomWinners(pokerRooms[roomId]) + + try { + const url = (process.env.DEV_SITE === 'true' ? process.env.API_URL_DEV : process.env.API_URL) + '/poker-room/winner' + const response = await axios.post(url, { roomId: roomId, winnerIds: pokerRooms[roomId].winners }) + } catch (e) { + console.log(e) + } + + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return res.status(200) +}) + +async function handleShowdown(roomId) { + if (!pokerRooms[roomId]) return false + + //showdown + pokerRooms[roomId].current_turn = 4 + pokerRooms[roomId].current_player = null + + await updatePokerPlayersSolve(roomId) + + pokerRooms[roomId].winners = checkRoomWinners(pokerRooms[roomId]) + + try { + await handleWinner(roomId, pokerRooms[roomId].winners) + } catch (e) { + console.log(e) + } + + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') + return true +} + +app.post('/poker-room/progressive-showdown', async (req, res) => { + const { roomId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + + while(pokerRooms[roomId].current_turn < 4) { + let allGood = true + switch (pokerRooms[roomId].current_turn) { + case 0: + allGood = await handleFlop(roomId) + break; + case 1: + allGood = await handleTurn(roomId) + break; + case 2: + allGood = await handleRiver(roomId) + break; + case 3: + allGood = await handleShowdown(roomId) + break; + default: + allGood = false + break; + } + + if (!allGood) console.log('error in progressive showdown') + + await sleep(1000) + } + + return res.status(200) +}) + +app.post('/poker-room/winner', async (req, res) => { + const { roomId, winnerIds } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + //if (!pokerRooms[roomId].players[winnerIds]) return res.status(404).send({ message: 'Joueur introuvable' }) + + pokerRooms[roomId].current_player = null + pokerRooms[roomId].current_turn = 4 + + let pool = 0; + for (const playerId in pokerRooms[roomId].players) { + const player = pokerRooms[roomId].players[playerId] + pool += player?.bet ?? 0 + player.bet = 0 + if (player.bank === 0 && !pokerRooms[roomId].winners.includes(player.id)) { + try { + delete pokerRooms[roomId].players[player.id] + + if (player.id === pokerRooms[roomId].host_id) { + const newHostId = Object.keys(pokerRooms[roomId].players).find(id => id !== player.id) + if (!newHostId) { + delete pokerRooms[roomId] + } else { + pokerRooms[roomId].host_id = newHostId + } + } + } catch (e) { + // + } + } + } + + pokerRooms[roomId].winners.forEach((winner) => { + pokerRooms[roomId].players[winner].bank += Math.floor(pool / winnerIds.length) + if (!pokerRooms[roomId].fakeMoney) { + const DBplayer = getUser.get(winner) + if (DBplayer) { + updateUserCoins.run({ + id: winner, + coins: pokerRooms[roomId].players[winner].bank, + }) + insertLog.run({ + id: winner + '-' + Date.now(), + user_id: winner, + action: 'POKER_WIN', + target_user_id: winner, + coins_amount: Math.floor(pool / winnerIds.length), + user_new_amount: pokerRooms[roomId].players[winner].bank, + }) + } + io.emit('data-updated', {table: 'users', action: 'update'}); + } + }); + + pokerRooms[roomId].waiting_for_restart = true + + io.emit('player-winner', { roomId: roomId, playerIds: winnerIds, amount: Math.floor(pool / winnerIds.length) }) + + await pokerEloHandler(pokerRooms[roomId]) + + io.emit('new-poker-room') + return res.status(200) +}) + +async function handleWinner(roomId, winnerIds) { + if (!pokerRooms[roomId]) return false + + pokerRooms[roomId].current_player = null + pokerRooms[roomId].current_turn = 4 + + let pool = 0; + for (const playerId in pokerRooms[roomId].players) { + const player = pokerRooms[roomId].players[playerId] + pool += player?.bet ?? 0 + player.bet = 0 + if (player.bank === 0 && !pokerRooms[roomId].winners.includes(player.id)) { + try { + delete pokerRooms[roomId].players[player.id] + } catch (e) { + // + } + } + } + + pokerRooms[roomId].winners = checkRoomWinners(pokerRooms[roomId]) + + pokerRooms[roomId]?.winners.forEach((winner) => { + pokerRooms[roomId].players[winner].bank += Math.floor(pool / winnerIds.length) + }); + + pokerRooms[roomId].waiting_for_restart = true + + io.emit('player-winner', { roomId: roomId, playerIds: pokerRooms[roomId].winners, amount: Math.floor(pool / winnerIds.length) }) + + await pokerEloHandler(pokerRooms[roomId]) + + io.emit('new-poker-room') + return true +} + +app.post('/poker-room/next-round', async (req, res) => { + const { roomId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + + const dealerId = Object.keys(pokerRooms[roomId].players).findIndex(p => p === pokerRooms[roomId].dealer) + console.log('dealer id', dealerId) + + pokerRooms[roomId].waiting_for_restart = false + pokerRooms[roomId].winners = [] + pokerRooms[roomId].pioche = initialShuffledCards() + pokerRooms[roomId].tapis = [] + pokerRooms[roomId].dealer = null + pokerRooms[roomId].sb = null + pokerRooms[roomId].bb = null + pokerRooms[roomId].highest_bet = null + pokerRooms[roomId].current_player = null + pokerRooms[roomId].current_turn = null + + for (const playerId in pokerRooms[roomId].afk) { + try { + delete pokerRooms[roomId].players[playerId] + } catch (e) { console.log(e) } + try { + delete pokerRooms[roomId].afk[playerId] + } catch (e) { console.log(e) } + } + + for (const playerId in pokerRooms[roomId].players) { + const player = pokerRooms[roomId].players[playerId] + player.hand = [] + player.bet = null + player.solve = null + player.folded = false + player.allin = false + player.last_played_turn = null + player.is_last_raiser = false + } + + try { + await handleRoomStart(roomId, dealerId) + } catch (e) { + console.log(e) + } + + io.emit('new-poker-room') + return res.status(200) +}) + +app.post('/poker-room/action/fold', async (req, res) => { + const { roomId, playerId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + if (!pokerRooms[roomId].players[playerId]) return res.status(404).send({ message: 'Joueur introuvable' }) + + if (pokerRooms[roomId].current_player !== playerId) return res.status(403).send({ message: 'Ce n\'est pas ton tour' }); + + try { + pokerRooms[roomId].players[playerId].folded = true + pokerRooms[roomId].players[playerId].last_played_turn = pokerRooms[roomId].current_turn + + io.emit('player-fold', { roomId: roomId, playerId: playerId, playerName: pokerRooms[roomId].players[playerId].globalName }) + + await checksAfterPokerAction(roomId) + + io.emit('new-poker-room') + } catch(e) { + console.log(e) + return res.status(500).send({ message: e}) + } + + return res.status(200) +}); + +app.post('/poker-room/action/check', async (req, res) => { + const { roomId, playerId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + if (!pokerRooms[roomId].players[playerId]) return res.status(404).send({ message: 'Joueur introuvable' }) + + if (pokerRooms[roomId].current_player !== playerId) return res.status(403).send({ message: 'Ce n\'est pas ton tour' }); + + try { + pokerRooms[roomId].players[playerId].last_played_turn = pokerRooms[roomId].current_turn + + io.emit('player-check', { roomId: roomId, playerId: playerId, playerName: pokerRooms[roomId].players[playerId].globalName }) + + await checksAfterPokerAction(roomId) + + io.emit('new-poker-room') + } catch(e) { + console.log(e) + return res.status(500).send({ message: e}) + } + + return res.status(200) +}); + +app.post('/poker-room/action/call', async (req, res) => { + const { roomId, playerId } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + if (!pokerRooms[roomId].players[playerId]) return res.status(404).send({ message: 'Joueur introuvable' }) + + if (pokerRooms[roomId].current_player !== playerId) return res.status(403).send({ message: 'Ce n\'est pas ton tour' }); + + try { + let diff = pokerRooms[roomId].highest_bet - pokerRooms[roomId].players[playerId].bet + if (diff > pokerRooms[roomId].players[playerId].bank) { + diff = pokerRooms[roomId].players[playerId].bank + pokerRooms[roomId].players[playerId].allin = true + } + pokerRooms[roomId].players[playerId].bet += diff + pokerRooms[roomId].players[playerId].bank -= diff + pokerRooms[roomId].players[playerId].last_played_turn = pokerRooms[roomId].current_turn + + if (Object.values(pokerRooms[roomId].players).find(p => p.allin)) pokerRooms[roomId].players[playerId].allin = true + if (!pokerRooms[roomId].fakeMoney) { + const DBplayer = await getUser.get(playerId) + if (DBplayer) { + updateUserCoins.run({ + id: playerId, + coins: pokerRooms[roomId].players[playerId].bank, + }) + insertLog.run({ + id: playerId + '-' + Date.now(), + user_id: playerId, + action: 'POKER_CALL', + target_user_id: playerId, + coins_amount: -diff, + user_new_amount: pokerRooms[roomId].players[playerId].bank, + }) + } + io.emit('data-updated', { table: 'users', action: 'update' }); + } + + io.emit('player-call', { roomId: roomId, playerId: playerId, playerName: pokerRooms[roomId].players[playerId].globalName }) + + await checksAfterPokerAction(roomId) + + io.emit('new-poker-room') + } catch(e) { + console.log(e) + return res.status(500).send({ message: e}) + } + + return res.status(200) +}); + +app.post('/poker-room/action/raise', async (req, res) => { + const { roomId, playerId, amount } = req.body + + if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) + if (!pokerRooms[roomId].players[playerId]) return res.status(404).send({ message: 'Joueur introuvable' }) + + if (pokerRooms[roomId].current_player !== playerId) return res.status(403).send({ message: 'Ce n\'est pas ton tour' }); + if (amount > pokerRooms[roomId].players[playerId].bank) return res.status(403).send({ message: 'Tu n\as pas assez'}); + + try { + if (amount === pokerRooms[roomId].players[playerId].bank) { + pokerRooms[roomId].players[playerId].allin = true + } + pokerRooms[roomId].players[playerId].bet += amount + pokerRooms[roomId].players[playerId].bank -= amount + pokerRooms[roomId].players[playerId].last_played_turn = pokerRooms[roomId].current_turn + for (let id in pokerRooms[roomId].players) { + pokerRooms[roomId].players[id].is_last_raiser = false + } + pokerRooms[roomId].players[playerId].is_last_raiser = true + pokerRooms[roomId].highest_bet = pokerRooms[roomId].players[playerId].bet + + if (!pokerRooms[roomId].fakeMoney) { + const DBplayer = await getUser.get(playerId) + if (DBplayer) { + updateUserCoins.run({ + id: playerId, + coins: DBplayer.coins - amount, + }) + insertLog.run({ + id: playerId + '-' + Date.now(), + user_id: playerId, + action: 'POKER_RAISE', + target_user_id: playerId, + coins_amount: -amount, + user_new_amount: DBplayer.coins - amount, + }) + } + io.emit('data-updated', { table: 'users', action: 'update' }); + } + + io.emit('player-raise', { roomId: roomId, playerId: playerId, amount: amount, playerName: pokerRooms[roomId].players[playerId].globalName }) + + await checksAfterPokerAction(roomId) + + io.emit('new-poker-room') + } catch(e) { + console.log(e) + return res.status(500).send({ message: e}) + } + + return res.status(200) +}); + +async function checksAfterPokerAction(roomId) { + const data = checkEndOfBettingRound(pokerRooms[roomId]) + + if (data.winner !== null) { + try { + pokerRooms[roomId].winners = [data.winner] + const url = (process.env.DEV_SITE === 'true' ? process.env.API_URL_DEV : process.env.API_URL) + '/poker-room/winner' + const response = await axios.post(url, { roomId: roomId, winnerIds: [data.winner] }) + } catch (e) { + console.log(e) + } + } else if (data.endRound) { + try { + const url = (process.env.DEV_SITE === 'true' ? process.env.API_URL_DEV : process.env.API_URL) + '/poker-room/' + data.nextPhase + const response = await axios.post(url, { roomId: roomId}) + } catch (e) { + console.log(e) + } + } else { + pokerRooms[roomId].current_player = getNextActivePlayer(pokerRooms[roomId]) + } + + pokerRooms[roomId].last_move_at = Date.now() + + io.emit('new-poker-room') +} + +async function updatePokerPlayersSolve(roomId) { + for (const playerId in pokerRooms[roomId].players) { + const player = pokerRooms[roomId].players[playerId] + let fullHand = pokerRooms[roomId].tapis + if (!fullHand && !player.hand) { + player.solve = Hand.solve([], 'standard', false)?.descr + } else if (!fullHand) { + player.solve = Hand.solve(player.hand, 'standard', false)?.descr + } else if (!player.hand) { + player.solve = Hand.solve(fullHand, 'standard', false)?.descr + } else { + player.solve = Hand.solve(fullHand.concat(player.hand), 'standard', false)?.descr + } + } +} + +app.get('/solitaire/sotd/rankings', async (req, res) => { + const rankings = getAllSOTDStats.all() + + return res.json({ rankings }) +}) + +app.post('/solitaire/start', async (req, res) => { + const userId = req.body.userId; + let userSeed = req.body.userSeed; + + if (activeSolitaireGames[userId] && !activeSolitaireGames[userId].isSOTD) { + return res.json({ succes: true, gameState: activeSolitaireGames[userId]}) + } + + if (userSeed) { + let numericSeed = 0 + for (let i = 0; i < userSeed.length; i++) { + numericSeed = (numericSeed + userSeed.charCodeAt(i)) & 0xFFFFFFFF; + } + + const rng = createSeededRNG(numericSeed); + const deck = createDeck() + const shuffledDeck = seededShuffle(deck, rng); + const gameState = deal(shuffledDeck); + gameState.seed = userSeed; + + activeSolitaireGames[userId] = gameState; + + return res.json({ success: true, gameState }); + } else { + const newRandomSeed = Date.now()?.toString(36) + Math.random()?.toString(36).substr(2); + let numericSeed = 0; + for (let i = 0; i < newRandomSeed.length; i++) { + numericSeed = (numericSeed + newRandomSeed.charCodeAt(i)) & 0xFFFFFFFF; + } + + const rng = createSeededRNG(numericSeed); + const deck = createDeck(); + const shuffledDeck = seededShuffle(deck, rng); + const gameState = deal(shuffledDeck); + gameState.seed = newRandomSeed; + + activeSolitaireGames[userId] = gameState; + + return res.json({ success: true, gameState }); + } +}); + +app.post('/solitaire/start/sotd', async (req, res) => { + const userId = req.body.userId + const sotd = getSOTD.get(); + + const user = getUser.get(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + if (activeSolitaireGames[userId] && activeSolitaireGames[userId].isSOTD) { + return res.json({ success: true, gameState: activeSolitaireGames[userId]}) + } + + const gameState = { + tableauPiles: JSON.parse(sotd.tableauPiles), + foundationPiles: JSON.parse(sotd.foundationPiles), + stockPile: JSON.parse(sotd.stockPile), + wastePile: JSON.parse(sotd.wastePile), + isDone: false, + isSOTD: true, + hasFinToday: false, + startTime: Date.now(), + endTime: null, + moves: 0, + score: 0, + seed: sotd.seed, + } + + activeSolitaireGames[userId] = gameState + res.json({ success: true, gameState }); +}) + +app.post('/solitaire/reset', async (req, res) => { + const userId = req.body.userId; + delete activeSolitaireGames[userId] + res.json({ success: true }); +}); + +/!** + * GET /solitaire/state/:userId + * Gets the current game state for a user. If no game exists, creates a new one. + *!/ +app.get('/solitaire/state/:userId', (req, res) => { + const { userId } = req.params; + let gameState = activeSolitaireGames[userId]; + /!*if (!gameState) { + const deck = shuffle(createDeck()); + gameState = deal(deck); + activeSolitaireGames[userId] = gameState; + }*!/ + res.json({ success: true, gameState }); +}); + +/!** + * POST /solitaire/move + * Receives all necessary move data from the frontend. + *!/ +app.post('/solitaire/move', async (req, res) => { + // Destructure the complete move data from the request body + // Frontend must send all these properties. + const { + userId, + sourcePileType, + sourcePileIndex, + sourceCardIndex, + destPileType, + destPileIndex + } = req.body; + + const gameState = activeSolitaireGames[userId]; + + if (!gameState) { + return res.status(404).json({ error: 'Game not found for this user.' }); + } + + // Pass the entire data object to the validation function + if (isValidMove(gameState, req.body)) { + // If valid, mutate the state + await moveCard(gameState, req.body); + const win = checkWinCondition(gameState); + if (win) { + gameState.isDone = true + if (gameState.isSOTD) { + gameState.hasFinToday = true; + gameState.endTime = Date.now(); + const userStats = getUserSOTDStats.get(userId); + if (userStats) { + if ( + (gameState.score > userStats.score) || + (gameState.score === userStats.score && gameState.moves < userStats.moves) || + (gameState.score === userStats.score && gameState.moves === userStats.moves && gameState.time < userStats.time) + ) { + deleteUserSOTDStats.run(userId); + insertSOTDStats.run({ + id: userId, + user_id: userId, + time: gameState.endTime - gameState.startTime, + moves: gameState.moves, + score: gameState.score, + }) + } + } else { + insertSOTDStats.run({ + id: userId, + user_id: userId, + time: gameState.endTime - gameState.startTime, + moves: gameState.moves, + score: gameState.score, + }) + const user = getUser.get(userId) + if (user) { + updateUserCoins.run({ id: userId, coins: user.coins + 1000 }); + insertLog.run({ + id: userId + '-' + Date.now(), + user_id: userId, + action: 'SOTD_WIN', + target_user_id: null, + coins_amount: 1000, + user_new_amount: user.coins + 1000, + }) + } + } + } + } + res.json({ success: true, gameState, win, endTime: win ? Date.now() : null }); + } else { + // If the move is invalid, send a specific error message + res.status(400).json({ error: 'Invalid move' }); + } +}); + +/!** + * POST /solitaire/draw + * Draws a card from the stock pile to the waste pile. + *!/ +app.post('/solitaire/draw', async (req, res) => { + const { userId } = req.body; + const gameState = activeSolitaireGames[userId]; + if (!gameState) { + return res.status(404).json({ error: `Game not found for user ${userId}` }); + } + await drawCard(gameState); + res.json({ success: true, gameState }); +}); + +import http from 'http'; +import { Server } from 'socket.io'; +import * as test from "node:test"; +const server = http.createServer(app); + +const io = new Server(server, { + cors: { + Origin: FLAPI_URL, + methods: ['GET', 'POST', 'PUT', 'OPTIONS'], + } +}); + +let queue = [] +let playingArray = [] + +let connect4Queue = [] +let connect4PlayingArray = [] +export const C4_ROWS = 6 +export const C4_COLS = 7 + +io.on('connection', (socket) => { + + socket.on('user-connected', async (user) => { + const username = getUser.get(user) + + queue = queue.filter(obj => obj !== user) + let names = []; + for (const n of queue) { + let name = await client.users.fetch(n) + names.push(name?.globalName) + } + io.emit('tictactoequeue', { allPlayers: playingArray, queue: names }) + + connect4Queue = connect4Queue.filter(obj => obj !== user) + let C4names = [] + for (const n of connect4Queue) { + let name = await client.users.fetch(n) + C4names.push(name?.globalName) + } + + io.emit('connect4queue', { allPlayers: playingArray, queue: C4names }) + }) + + socket.on('tictactoeconnection', async (e) => { + queue = queue.filter(obj => obj !== e.id) + let names = []; + for (const n of queue) { + let name = await client.users.fetch(n) + names.push(name?.globalName) + } + io.emit('tictactoequeue', { allPlayers: playingArray, queue: names }) + }) + + socket.on('connect4connection', async (e) => { + connect4Queue = connect4Queue.filter(obj => obj !== e.id) + let names = []; + for (const n of connect4Queue) { + let name = await client.users.fetch(n) + names.push(name?.globalName) + } + io.emit('connect4queue', { allPlayers: playingArray, queue: names }) + }) + + socket.on('tictactoequeue', async (e) => { + console.log(`${e.playerId} in tic tac toe queue`); + + if (playingArray.find(obj => obj.p1.id === e.playerId || obj.p2.id === e.playerId)) { + let names = []; + for (const n of queue) { + let name = await client.users.fetch(n) + names.push(name?.globalName) + } + io.emit('tictactoequeue', { allPlayers: playingArray, queue: names }) + return + } + + let msgId; + + if (!queue.find(obj => obj === e.playerId)) { + queue.push(e.playerId) + + if (queue.length === 1) { + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = guild.channels.cache.find( + ch => ch.name === 'général' || ch.name === 'general' + ); + const user = await client.users.fetch(e.playerId) + + const embed = new EmbedBuilder() + .setTitle(`Tic Tac Toe`) + .setDescription(`**${user.username}** est dans la file d'attente`) + .setColor('#5865f2') + .setTimestamp(new Date()); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setLabel(`Jouer contre ${user.username}`) + .setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/tic-tac-toe`) + .setStyle(ButtonStyle.Link) + ) + + await generalChannel.send({ embeds: [embed], components: [row] }); + } catch (e) { + console.log(e) + } + } + } + + if (queue.length >= 2) { + let p1 = await client.users.fetch(queue[0]) + let p2 = await client.users.fetch(queue[1]) + + let msgId + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = guild.channels.cache.find( + ch => ch.name === 'général' || ch.name === 'general' + ); + + const embed = new EmbedBuilder() + .setTitle(`Tic Tac Toe`) + .setDescription(`### **❌ ${p1.globalName}** vs **${p2.globalName} ⭕**\n` + + `🟦🟦🟦\n` + + `🟦🟦🟦\n` + + `🟦🟦🟦\n`) + .setColor('#5865f2') + .setTimestamp(new Date()); + + const msg = await generalChannel.send({ embeds: [embed] }); + msgId = msg.id + } catch (e) { + console.log(e) + } + + let p1obj = { + id: queue[0], + name: p1.globalName, + val: 'X', + move: "", + } + let p2obj = { + id: queue[1], + name: p2.globalName, + val: 'O', + move: "", + } + + let lobby = { + p1: p1obj, + p2: p2obj, + sum: 1, + xs: [], + os: [], + lastmove: Date.now(), + msgId: msgId, + } + + playingArray.push(lobby) + + queue.splice(0, 2) + } + + let names = []; + for (const n of queue) { + let name = await client.users.fetch(n) + names.push(name?.globalName) + } + + io.emit('tictactoequeue', { allPlayers: playingArray, queue: names }) + }) + + socket.on('tictactoeplaying', async (e) => { + console.log('playing', e.value) + let lobbyToChange; + if (e.value === 'X') { + lobbyToChange = playingArray.find(obj => obj.p1.id === e.playerId) + + if (lobbyToChange.sum%2 === 1) { + console.log('yeah', e.value) + lobbyToChange.p2.move = '' + lobbyToChange.p1.move = e.boxId + lobbyToChange.sum++ + lobbyToChange.xs.push(e.boxId) + lobbyToChange.lastmove = Date.now() + } + } + else if (e.value === 'O') { + lobbyToChange = playingArray.find(obj => obj.p2.id === e.playerId) + + if (lobbyToChange.sum%2 === 0) { + console.log('yeah', e.value) + lobbyToChange.p1.move = '' + lobbyToChange.p2.move = e.boxId + lobbyToChange.sum++ + lobbyToChange.os.push(e.boxId) + lobbyToChange.lastmove = Date.now() + } + } + + let gridText = '' + for (let i = 1; i <= 9; i++) { + if (lobbyToChange.os.includes(i)) { + gridText += '⭕' + } else if (lobbyToChange.xs.includes(i)) { + gridText += '❌' + } else { + gridText += '🟦' + } + if (i%3 === 0) { + gridText += '\n' + } + } + + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = await guild.channels.cache.find( + ch => ch.name === 'général' || ch.name === 'general' + ); + + const message = await generalChannel.messages.fetch(lobbyToChange.msgId) + + const embed = new EmbedBuilder() + .setTitle(`Tic Tac Toe`) + .setDescription(`### **❌ ${lobbyToChange.p1.name}** vs **${lobbyToChange.p2.name} ⭕**\n` + gridText) + .setColor('#5865f2') + .setTimestamp(new Date()); + + await message.edit({ embeds: [embed] }); + } catch (e) { + console.log(e) + } + + io.emit('tictactoeplaying', { allPlayers: playingArray }) + }) + + socket.on('tictactoegameOver', async (e) => { + const winner = e.winner + const game = playingArray.find(obj => obj.p1.id === e.playerId) + + if (game && game.sum < 100) { + game.sum = 100 + let gridText = '' + for (let i = 1; i <= 9; i++) { + if (game.os.includes(i)) { + gridText += '⭕' + } else if (game.xs.includes(i)) { + gridText += '❌' + } else { + gridText += '🟦' + } + if (i%3 === 0) { + gridText += '\n' + } + } + + if (winner === null) { + await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, 'TICTACTOE') + + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = await guild.channels.cache.find( + ch => ch.name === 'général' || ch.name === 'general' + ); + + const message = await generalChannel.messages.fetch(game.msgId) + + const embed = new EmbedBuilder() + .setTitle(`Tic Tac Toe`) + .setDescription(`### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n` + gridText + `\n### Égalité`) + .setColor('#5865f2') + .setTimestamp(new Date()); + + await message.edit({ embeds: [embed] }); + } catch (e) { + console.log(e) + } + } else { + await eloHandler(game.p1.id, game.p2.id, game.p1.id === winner ? 1 : 0, game.p2.id === winner ? 1 : 0, 'TICTACTOE') + + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = await guild.channels.cache.find( + ch => ch.name === 'général' || ch.name === 'general' + ); + + const message = await generalChannel.messages.fetch(game.msgId) + + const embed = new EmbedBuilder() + .setTitle(`Tic Tac Toe`) + .setDescription(`### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n` + gridText + `\n### Victoire de ${game.p1.id === winner ? game.p1.name : game.p2.name}`) + .setColor('#5865f2') + .setTimestamp(new Date()); + + await message.edit({ embeds: [embed] }); + } catch (e) { + console.log(e) + } + + } + } + + playingArray = playingArray.filter(obj => obj.p1.id !== e.playerId) + }) + + socket.on('connect4queue', async (e) => { + console.log(`${e.playerId} in Connect 4 queue`); + + if (connect4PlayingArray.find(obj => obj.p1.id === e.playerId || obj.p2.id === e.playerId)) { + let names = []; + for (const n of connect4Queue) { + let name = await client.users.fetch(n); + names.push(name?.globalName); + } + io.emit('connect4queue', { allPlayers: connect4PlayingArray, queue: names }); + return + } + + if (!connect4Queue.find(obj => obj === e.playerId)) { + connect4Queue.push(e.playerId); + } + + if (connect4Queue.length === 1) { + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = guild.channels.cache.find( + ch => ch.name === 'général' || ch.name === 'general' + ); + const user = await client.users.fetch(e.playerId) + + const embed = new EmbedBuilder() + .setTitle(`Puissance 4`) + .setDescription(`**${user.username}** est dans la file d'attente`) + .setColor('#5865f2') + .setTimestamp(new Date()); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setLabel(`Jouer contre ${user.username}`) + .setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/connect-4`) + .setStyle(ButtonStyle.Link) + ) + + await generalChannel.send({ embeds: [embed], components: [row] }); + } catch (e) { + console.log(e) + } + } + + if (connect4Queue.length >= 2) { + const p1Id = connect4Queue[0]; + const p2Id = connect4Queue[1]; + const p1 = await client.users.fetch(p1Id); + const p2 = await client.users.fetch(p2Id); + let msgId; + + const board = createConnect4Board(); + const boardText = formatConnect4BoardForDiscord(board); + + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = guild.channels.cache.find(ch => ch.name === 'général' || ch.name === 'general'); + const embed = new EmbedBuilder() + .setTitle('Puissance 4') + .setDescription(`**🔴 ${p1.globalName}** vs **${p2.globalName} 🟡**\n\n${boardText}`) + .setColor('#5865f2') + .setTimestamp(new Date()); + const msg = await generalChannel.send({ embeds: [embed] }); + msgId = msg.id; + } catch (err) { + console.error("Error sending Connect 4 start message:", err); + } + + const lobby = { + p1: { id: p1Id, name: p1.globalName, val: 'R' }, + p2: { id: p2Id, name: p2.globalName, val: 'Y' }, + turn: p1Id, + sum: 1, + board: board, + msgId: msgId, + gameOver: false, + lastmove: Date.now(), + winningPieces: [] + }; + + connect4PlayingArray.push(lobby); + connect4Queue.splice(0, 2); + } + + let names = []; + for (const n of connect4Queue) { + let name = await client.users.fetch(n); + names.push(name?.globalName); + } + io.emit('connect4queue', { allPlayers: connect4PlayingArray, queue: names }); + }); + + socket.on('connect4playing', async (e) => { + const lobby = connect4PlayingArray.find(l => (l.p1.id === e.playerId || l.p2.id === e.playerId) && !l.gameOver); + if (!lobby || lobby.turn !== e.playerId) return; + + const player = lobby.p1.id === e.playerId ? lobby.p1 : lobby.p2; + const col = e.col; + + // Drop the piece + lobby.lastmove = Date.now() + lobby.sum++ + let row; + for (row = C4_ROWS - 1; row >= 0; row--) { + if (lobby.board[row][col] === null) { + lobby.board[row][col] = player.val; + break; + } + } + + // Check for win + const winCheck = checkConnect4Win(lobby.board, player.val); + if (winCheck.win) { + lobby.gameOver = true; + lobby.winningPieces = winCheck.pieces; + await eloHandler(lobby.p1.id, lobby.p2.id, lobby.p1.id === player.id ? 1 : 0, lobby.p2.id === player.id ? 1 : 0, 'CONNECT4'); + io.emit('connect4gameOver', { game: lobby, winner: player.id }); + + connect4PlayingArray = connect4PlayingArray.filter(obj => obj.p1.id !== lobby.p1.id) + } + // Check for draw + else if (checkConnect4Draw(lobby.board)) { + lobby.gameOver = true; + await eloHandler(lobby.p1.id, lobby.p2.id, 0.5, 0.5, 'CONNECT4'); + io.emit('connect4gameOver', { game: lobby, winner: 'draw' }); + connect4PlayingArray = connect4PlayingArray.filter(obj => obj.p1.id !== lobby.p1.id) + } + // Switch turns + else { + lobby.turn = lobby.p1.id === player.id ? lobby.p2.id : lobby.p1.id; + } + + // Update Discord message + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = guild.channels.cache.find(ch => ch.name === 'général' || ch.name === 'general'); + const message = await generalChannel.messages.fetch(lobby.msgId); + let description = `**🔴 ${lobby.p1.name}** vs **${lobby.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(lobby.board)}`; + if (lobby.gameOver) { + if(winCheck.win) { + description += `\n\n### Victoire de ${player.name}!`; + } else { + description += `\n\n### Match Nul!`; + } + } + const embed = new EmbedBuilder() + .setTitle('Puissance 4') + .setDescription(description) + .setColor(lobby.gameOver ? '#2ade2a' : '#5865f2') + .setTimestamp(new Date()); + await message.edit({ embeds: [embed] }); + } catch (err) { + console.error("Error updating Connect 4 message:", err); + } + + if (!winCheck.win && !checkConnect4Draw(lobby.board)) { + io.emit('connect4playing', { allPlayers: connect4PlayingArray }); + } + }); + + socket.on('connect4NoTime', async (e) => { + const lobby = connect4PlayingArray.find(l => (l.p1.id === e.playerId || l.p2.id === e.playerId) && !l.gameOver); + const winner = e.winner + + if (lobby) { + lobby.gameOver = true; + await eloHandler(lobby.p1?.id, lobby.p2?.id, lobby.p1?.id === winner ? 1 : 0, lobby.p2?.id === winner ? 1 : 0, 'CONNECT4'); + + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const generalChannel = guild.channels.cache.find(ch => ch.name === 'général' || ch.name === 'general'); + const message = await generalChannel.messages.fetch(lobby.msgId); + let description = `**🔴 ${lobby.p1.name}** vs **${lobby.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(lobby.board)}`; + description += `\n\n### Victoire de ${lobby.p1.id === winner ? lobby.p1.name : lobby.p2.name}! (temps écoulé)`; + const embed = new EmbedBuilder() + .setTitle('Puissance 4') + .setDescription(description) + .setColor(lobby.gameOver ? '#2ade2a' : '#5865f2') + .setTimestamp(new Date()); + await message.edit({ embeds: [embed] }); + } catch (err) { + console.error("Error updating Connect 4 message:", err); + } + + try { + connect4PlayingArray = connect4PlayingArray.filter(obj => obj.p1.id !== lobby.p1.id) + } catch (e) { + console.log(e) + } + } + }); +}); + +server.listen(PORT, () => { + console.log(`Express+Socket.IO listening on port ${PORT}`); +}); + +*/ diff --git a/src/api/discord.js b/src/api/discord.js new file mode 100644 index 0000000..18cf8c1 --- /dev/null +++ b/src/api/discord.js @@ -0,0 +1,60 @@ +import 'dotenv/config'; + +/** + * A generic function for making requests to the Discord API. + * It handles URL construction, authentication, and basic error handling. + * + * @param {string} endpoint - The API endpoint to request (e.g., 'channels/123/messages'). + * @param {object} [options] - Optional fetch options (method, body, etc.). + * @returns {Promise} The raw fetch response object. + * @throws Will throw an error if the API request is not successful. + */ +export async function DiscordRequest(endpoint, options) { + // Construct the full API URL + const url = 'https://discord.com/api/v10/' + endpoint; + + // Stringify the payload if it exists + if (options && options.body) { + options.body = JSON.stringify(options.body); + } + + // Use fetch to make the request, automatically including required headers + const res = await fetch(url, { + headers: { + 'Authorization': `Bot ${process.env.DISCORD_TOKEN}`, + 'Content-Type': 'application/json; charset=UTF-8', + 'User-Agent': 'DiscordBot (https://github.com/discord/discord-example-app, 1.0.0)', + }, + ...options, // Spread the given options (e.g., method, body) + }); + + // If the request was not successful, throw a detailed error + if (!res.ok) { + const data = await res.json(); + console.error(`Discord API Error on endpoint ${endpoint}:`, res.status, data); + throw new Error(JSON.stringify(data)); + } + + // Return the original response object for further processing + return res; +} + +/** + * Installs or overwrites all global slash commands for the application. + * + * @param {string} appId - The application (client) ID. + * @param {Array} commands - An array of command objects to install. + */ +export async function InstallGlobalCommands(appId, commands) { + // API endpoint for bulk overwriting global commands + const endpoint = `applications/${appId}/commands`; + + console.log('Installing global commands...'); + try { + // This uses the generic DiscordRequest function to make the API call + await DiscordRequest(endpoint, { method: 'PUT', body: commands }); + console.log('Successfully installed global commands.'); + } catch (err) { + console.error('Error installing global commands:', err); + } +} \ No newline at end of file diff --git a/src/api/valorant.js b/src/api/valorant.js new file mode 100644 index 0000000..f88b875 --- /dev/null +++ b/src/api/valorant.js @@ -0,0 +1,11 @@ +export async function getValorantSkins(locale='fr-FR') { + const response = await fetch(`https://valorant-api.com/v1/weapons/skins?language=${locale}`, { method: 'GET' }); + const data = await response.json(); + return data.data +} + +export async function getSkinTiers(locale='fr-FR') { + const response = await fetch(`https://valorant-api.com/v1/contenttiers?language=${locale}`, { method: 'GET'}); + const data = await response.json(); + return data.data +} \ No newline at end of file diff --git a/src/bot/client.js b/src/bot/client.js new file mode 100644 index 0000000..f4c7939 --- /dev/null +++ b/src/bot/client.js @@ -0,0 +1,28 @@ +import { Client, GatewayIntentBits } from 'discord.js'; + +/** + * The single, shared Discord.js Client instance for the entire application. + * It is configured with all the necessary intents to receive the events it needs. + */ +export const client = new Client({ + // Define the events the bot needs to receive from Discord's gateway. + intents: [ + // Required for basic guild information and events. + GatewayIntentBits.Guilds, + + // Required to receive messages in guilds (e.g., in #general). + GatewayIntentBits.GuildMessages, + + // A PRIVILEGED INTENT, required to read the content of messages. + // This is necessary for the AI handler, admin commands, and "quoi/feur". + GatewayIntentBits.MessageContent, + + // Required to receive updates when members join, leave, or are updated. + // Crucial for fetching member details for commands like /timeout or /info. + GatewayIntentBits.GuildMembers, + + // Required to receive member presence updates (online, idle, offline). + // Necessary for features like `getOnlineUsersWithRole`. + GatewayIntentBits.GuildPresences, + ], +}); \ No newline at end of file diff --git a/src/bot/commands/floposite.js b/src/bot/commands/floposite.js new file mode 100644 index 0000000..d640d0d --- /dev/null +++ b/src/bot/commands/floposite.js @@ -0,0 +1,55 @@ +import { + InteractionResponseType, + MessageComponentTypes, + ButtonStyleTypes, +} from 'discord-interactions'; + +/** + * Handles the /floposite slash command. + * This command replies with a simple embed containing a link button to the main website. + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + */ +export async function handleFlopoSiteCommand(req, res) { + // The URL for the link button. Consider moving to .env if it changes. + const siteUrl = process.env.FLOPOSITE_URL || 'https://floposite.com'; + + // The URL for the thumbnail image. + const thumbnailUrl = `${process.env.API_URL}/public/images/flopo.png`; + + // Define the components (the link button) + const components = [ + { + type: MessageComponentTypes.ACTION_ROW, + components: [ + { + type: MessageComponentTypes.BUTTON, + label: 'Aller sur FlopoSite', + style: ButtonStyleTypes.LINK, + url: siteUrl, + }, + ], + }, + ]; + + // Define the embed message + const embeds = [ + { + title: 'FlopoSite', + description: "L'officiel et très goatesque site de FlopoBot.", + color: 0x6571F3, // A custom blue color + thumbnail: { + url: thumbnailUrl, + }, + }, + ]; + + // Send the response to Discord + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: embeds, + components: components, + }, + }); +} \ No newline at end of file diff --git a/src/bot/commands/info.js b/src/bot/commands/info.js new file mode 100644 index 0000000..b125ecd --- /dev/null +++ b/src/bot/commands/info.js @@ -0,0 +1,71 @@ +import { InteractionResponseType } from 'discord-interactions'; + +/** + * Handles the /info slash command. + * Fetches and displays a list of all members who are currently timed out in the guild. + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {object} client - The Discord.js client instance. + */ +export async function handleInfoCommand(req, res, client) { + const { guild_id } = req.body; + + try { + // Fetch the guild object from the client + const guild = await client.guilds.fetch(guild_id); + + // Fetch all members to ensure the cache is up to date + await guild.members.fetch(); + + // Filter the cached members to find those who are timed out + // A member is timed out if their `communicationDisabledUntil` property is a future date. + const timedOutMembers = guild.members.cache.filter( + (member) => + member.communicationDisabledUntilTimestamp && + member.communicationDisabledUntilTimestamp > Date.now() + ); + + // --- Case 1: No members are timed out --- + if (timedOutMembers.size === 0) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: 'Membres Timeout', + description: "Aucun membre n'est actuellement timeout.", + color: 0x4F545C, // Discord's gray color + }, + ], + }, + }); + } + + // --- Case 2: At least one member is timed out --- + // Format the list of timed-out members for the embed + const memberList = timedOutMembers + .map((member) => { + // toLocaleString provides a user-friendly date and time format + const expiration = new Date(member.communicationDisabledUntilTimestamp).toLocaleString('fr-FR'); + return `▫️ **${member.user.globalName || member.user.username}** (jusqu'au ${expiration})`; + }) + .join('\n'); + + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: 'Membres Actuellement Timeout', + description: memberList, + color: 0xED4245, // Discord's red color + }, + ], + }, + }); + + } catch (error) { + console.error('Error handling /info command:', error); + return res.status(500).json({ error: 'Failed to fetch timeout information.' }); + } +} \ No newline at end of file diff --git a/src/bot/commands/inventory.js b/src/bot/commands/inventory.js new file mode 100644 index 0000000..60ea5d6 --- /dev/null +++ b/src/bot/commands/inventory.js @@ -0,0 +1,131 @@ +import { + InteractionResponseType, + MessageComponentTypes, + ButtonStyleTypes, + InteractionResponseFlags, +} from 'discord-interactions'; +import { activeInventories, skins } from '../../game/state.js'; +import { getUserInventory } from '../../database/index.js'; + +/** + * Handles the /inventory slash command. + * Displays a paginated, interactive embed of a user's Valorant skin inventory. + * + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {object} client - The Discord.js client instance. + * @param {string} interactionId - The unique ID of the interaction. + */ +export async function handleInventoryCommand(req, res, client, interactionId) { + const { member, guild_id, token, data } = req.body; + const commandUserId = member.user.id; + // User can specify another member, otherwise it defaults to themself + const targetUserId = data.options && data.options.length > 0 ? data.options[0].value : commandUserId; + + try { + // --- 1. Fetch Data --- + const guild = await client.guilds.fetch(guild_id); + const targetMember = await guild.members.fetch(targetUserId); + const inventorySkins = getUserInventory.all({ user_id: targetUserId }); + + // --- 2. Handle Empty Inventory --- + if (inventorySkins.length === 0) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [{ + title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`, + description: "Cet inventaire est vide.", + color: 0x4F545C, // Discord Gray + }], + }, + }); + } + + // --- 3. Store Interactive Session State --- + // This state is crucial for the component handlers to know which inventory to update. + activeInventories[interactionId] = { + akhyId: targetUserId, // The inventory owner + userId: commandUserId, // The user who ran the command + page: 0, + amount: inventorySkins.length, + endpoint: `webhooks/${process.env.APP_ID}/${token}/messages/@original`, + timestamp: Date.now(), + inventorySkins: inventorySkins, // Cache the skins to avoid re-querying the DB on each page turn + }; + + // --- 4. Prepare Embed Content --- + const currentSkin = inventorySkins[0]; + const skinData = skins.find((s) => s.uuid === currentSkin.uuid); + if (!skinData) { + throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`); + } + const totalPrice = inventorySkins.reduce((sum, skin) => sum + (skin.currentPrice || 0), 0); + + // --- Helper functions for formatting --- + const getChromaText = (skin, skinInfo) => { + let result = ""; + for (let i = 1; i <= skinInfo.chromas.length; i++) { + result += skin.currentChroma === i ? '💠 ' : '◾ '; + } + return result || 'N/A'; + }; + + const getChromaName = (skin, skinInfo) => { + if (skin.currentChroma > 1) { + const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName.replace(/[\r\n]+/g, ' ').replace(skinInfo.displayName, '').trim(); + const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i); + return match ? match[1].trim() : name; + } + return 'Base'; + }; + + const getImageUrl = (skin, skinInfo) => { + if (skin.currentLvl === skinInfo.levels.length) { + const chroma = skinInfo.chromas[skin.currentChroma - 1]; + return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon; + } + const level = skinInfo.levels[skin.currentLvl - 1]; + return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender; + }; + + // --- 5. Build Initial Components (Buttons) --- + const components = [ + { type: MessageComponentTypes.BUTTON, custom_id: `prev_page_${interactionId}`, label: '⏮️ Préc.', style: ButtonStyleTypes.SECONDARY }, + { type: MessageComponentTypes.BUTTON, custom_id: `next_page_${interactionId}`, label: 'Suiv. ⏭️', style: ButtonStyleTypes.SECONDARY }, + ]; + + const isUpgradable = currentSkin.currentLvl < skinData.levels.length || currentSkin.currentChroma < skinData.chromas.length; + // Only show upgrade button if the skin is upgradable AND the command user owns the inventory + if (isUpgradable && targetUserId === commandUserId) { + components.push({ + type: MessageComponentTypes.BUTTON, + custom_id: `upgrade_${interactionId}`, + label: `Upgrade ⏫ (${process.env.VALO_UPGRADE_PRICE || (currentSkin.maxPrice/10).toFixed(0)}€)`, + style: ButtonStyleTypes.PRIMARY, + }); + } + + // --- 6. Send Final Response --- + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [{ + title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`, + color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3, + footer: { text: `Page 1/${inventorySkins.length} | Valeur Totale : ${totalPrice.toFixed(2)}€` }, + fields: [{ + name: `${currentSkin.displayName} | ${currentSkin.currentPrice.toFixed(2)}€`, + value: `${currentSkin.tierText}\nChroma : ${getChromaText(currentSkin, skinData)} | ${getChromaName(currentSkin, skinData)}\nLvl : **${currentSkin.currentLvl}**/${skinData.levels.length}`, + }], + image: { url: getImageUrl(currentSkin, skinData) }, + }], + components: [{ type: MessageComponentTypes.ACTION_ROW, components: components }], + }, + }); + + } catch (error) { + console.error('Error handling /inventory command:', error); + return res.status(500).json({ error: 'Failed to generate inventory.' }); + } +} \ No newline at end of file diff --git a/src/bot/commands/search.js b/src/bot/commands/search.js new file mode 100644 index 0000000..6f51c05 --- /dev/null +++ b/src/bot/commands/search.js @@ -0,0 +1,122 @@ +import { + InteractionResponseType, + InteractionResponseFlags, + MessageComponentTypes, + ButtonStyleTypes, +} from 'discord-interactions'; +import { activeSearchs, skins } from '../../game/state.js'; +import { getAllSkins } from '../../database/index.js'; + +/** + * Handles the /search slash command. + * Searches for skins by name or tier and displays them in a paginated embed. + * + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {object} client - The Discord.js client instance. + * @param {string} interactionId - The unique ID of the interaction. + */ +export async function handleSearchCommand(req, res, client, interactionId) { + const { member, guild_id, token, data } = req.body; + const userId = member.user.id; + const searchValue = data.options[0].value.toLowerCase(); + + try { + // --- 1. Fetch and Filter Data --- + const allDbSkins = getAllSkins.all(); + const resultSkins = allDbSkins.filter((skin) => + skin.displayName.toLowerCase().includes(searchValue) || + skin.tierText.toLowerCase().includes(searchValue) + ); + + // --- 2. Handle No Results --- + if (resultSkins.length === 0) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Aucun skin ne correspond à votre recherche.', + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // --- 3. Store Interactive Session State --- + activeSearchs[interactionId] = { + userId: userId, + page: 0, + amount: resultSkins.length, + resultSkins: resultSkins, + endpoint: `webhooks/${process.env.APP_ID}/${token}/messages/@original`, + timestamp: Date.now(), + searchValue: searchValue, + }; + + // --- 4. Prepare Initial Embed Content --- + const guild = await client.guilds.fetch(guild_id); + const currentSkin = resultSkins[0]; + const skinData = skins.find((s) => s.uuid === currentSkin.uuid); + if (!skinData) { + throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`); + } + + // Fetch owner details if the skin is owned + let ownerText = ''; + if (currentSkin.user_id) { + try { + const owner = await guild.members.fetch(currentSkin.user_id); + ownerText = `| **@${owner.user.globalName || owner.user.username}** ✅`; + } catch (e) { + console.warn(`Could not fetch owner for user ID: ${currentSkin.user_id}`); + ownerText = '| Appartenant à un utilisateur inconnu'; + } + } + + // Helper to get the best possible image for the skin + const getImageUrl = (skinInfo) => { + const lastChroma = skinInfo.chromas[skinInfo.chromas.length - 1]; + if (lastChroma?.fullRender) return lastChroma.fullRender; + if (lastChroma?.displayIcon) return lastChroma.displayIcon; + + const lastLevel = skinInfo.levels[skinInfo.levels.length - 1]; + if (lastLevel?.displayIcon) return lastLevel.displayIcon; + + return skinInfo.displayIcon; // Fallback to base icon + }; + + // --- 5. Build Initial Components & Embed --- + const components = [ + { + type: MessageComponentTypes.ACTION_ROW, + components: [ + { type: MessageComponentTypes.BUTTON, custom_id: `prev_search_page_${interactionId}`, label: '⏮️ Préc.', style: ButtonStyleTypes.SECONDARY }, + { type: MessageComponentTypes.BUTTON, custom_id: `next_search_page_${interactionId}`, label: 'Suiv. ⏭️', style: ButtonStyleTypes.SECONDARY }, + ], + }, + ]; + + const embed = { + title: 'Résultats de la recherche', + description: `🔎 _"${searchValue}"_`, + color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3, + fields: [{ + name: `**${currentSkin.displayName}**`, + value: `${currentSkin.tierText}\nValeur Max: **${currentSkin.maxPrice}€** ${ownerText}`, + }], + image: { url: getImageUrl(skinData) }, + footer: { text: `Résultat 1/${resultSkins.length}` }, + }; + + // --- 6. Send Final Response --- + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [embed], + components: components, + }, + }); + + } catch (error) { + console.error('Error handling /search command:', error); + return res.status(500).json({ error: 'Failed to execute search.' }); + } +} \ No newline at end of file diff --git a/src/bot/commands/skins.js b/src/bot/commands/skins.js new file mode 100644 index 0000000..ff3eccc --- /dev/null +++ b/src/bot/commands/skins.js @@ -0,0 +1,68 @@ +import { InteractionResponseType } from 'discord-interactions'; +import { getTopSkins } from '../../database/index.js'; + +/** + * Handles the /skins slash command. + * Fetches and displays the top 10 most valuable skins from the database. + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {object} client - The Discord.js client instance. + */ +export async function handleSkinsCommand(req, res, client) { + const { guild_id } = req.body; + + try { + // --- 1. Fetch Data --- + const topSkins = getTopSkins.all(); + const guild = await client.guilds.fetch(guild_id); + const fields = []; + + // --- 2. Build Embed Fields Asynchronously --- + // We use a for...of loop to handle the async fetch for each owner. + for (const [index, skin] of topSkins.entries()) { + let ownerText = 'Libre'; // Default text if the skin has no owner + + // If the skin has an owner, fetch their details + if (skin.user_id) { + try { + const owner = await guild.members.fetch(skin.user_id); + // Use globalName if available, otherwise fallback to username + ownerText = `**@${owner.user.globalName || owner.user.username}** ✅`; + } catch (e) { + // This can happen if the user has left the server + console.warn(`Could not fetch owner for user ID: ${skin.user_id}`); + ownerText = 'Appartient à un utilisateur inconnu'; + } + } + + // Add the formatted skin info to our fields array + fields.push({ + name: `#${index + 1} - **${skin.displayName}**`, + value: `Valeur Max: **${skin.maxPrice}€** | ${ownerText}`, + inline: false, + }); + } + + // --- 3. Send the Response --- + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + title: '🏆 Top 10 des Skins les Plus Chers', + description: 'Classement des skins par leur valeur maximale potentielle.', + fields: fields, + color: 0xFFD700, // Gold color for a leaderboard + footer: { + text: 'Utilisez /inventory pour voir vos propres skins.' + } + }, + ], + }, + }); + + } catch (error) { + console.error('Error handling /skins command:', error); + return res.status(500).json({ error: 'Failed to fetch the skins leaderboard.' }); + } +} \ No newline at end of file diff --git a/src/bot/commands/timeout.js b/src/bot/commands/timeout.js new file mode 100644 index 0000000..9030691 --- /dev/null +++ b/src/bot/commands/timeout.js @@ -0,0 +1,212 @@ +import { + InteractionResponseType, + InteractionResponseFlags, + MessageComponentTypes, + ButtonStyleTypes, +} from 'discord-interactions'; + +import { formatTime, getOnlineUsersWithRole } from '../../utils/index.js'; +import { DiscordRequest } from '../../api/discord.js'; +import { activePolls } from '../../game/state.js'; +import { getSocketIo } from '../../server/socket.js'; +import { getUser } from '../../database/index.js'; + +/** + * Handles the /timeout slash command. + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {object} client - The Discord.js client instance. + */ +export async function handleTimeoutCommand(req, res, client) { + const io = getSocketIo(); + const { id, member, guild_id, channel_id, token, data } = req.body; + const { options } = data; + + // Extract command options + const userId = member.user.id; + const targetUserId = options[0].value; + const time = options[1].value; + + // Fetch member objects from Discord + const guild = await client.guilds.fetch(guild_id); + const fromMember = await guild.members.fetch(userId); + const toMember = await guild.members.fetch(targetUserId); + + // --- Validation Checks --- + // 1. Check if a poll is already running for the target user + const existingPoll = Object.values(activePolls).find(poll => poll.toUserId === targetUserId); + if (existingPoll) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Impossible de lancer un vote pour **${toMember.user.globalName}**, un vote est déjà en cours.`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // 2. Check if the user is already timed out + if (toMember.communicationDisabledUntilTimestamp > Date.now()) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `**${toMember.user.globalName}** est déjà timeout.`, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // --- Poll Initialization --- + const pollId = id; // Use the interaction ID as the unique poll ID + const webhookEndpoint = `webhooks/${process.env.APP_ID}/${token}/messages/@original`; + + // Calculate required votes + const onlineEligibleUsers = await getOnlineUsersWithRole(guild, process.env.VOTING_ROLE_ID); + const requiredMajority = Math.max( + parseInt(process.env.MIN_VOTES, 10), + Math.floor(onlineEligibleUsers.size / (time >= 21600 ? 2 : 3)) + 1 + ); + + // Store poll data in the active state + activePolls[pollId] = { + id: userId, + username: fromMember.user.globalName, + toUserId: targetUserId, + toUsername: toMember.user.globalName, + time: time, + time_display: formatTime(time), + for: 0, + against: 0, + voters: [], + channelId: channel_id, + endpoint: webhookEndpoint, + endTime: Date.now() + parseInt(process.env.POLL_TIME, 10) * 1000, + requiredMajority: requiredMajority, + }; + + // --- Set up Countdown Interval --- + const countdownInterval = setInterval(async () => { + const poll = activePolls[pollId]; + + // If poll no longer exists, clear the interval + if (!poll) { + clearInterval(countdownInterval); + return; + } + + const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000)); + const votesNeeded = Math.max(0, poll.requiredMajority - poll.for); + const countdownText = `**${Math.floor(remaining / 60)}m ${remaining % 60}s** restantes`; + + // --- Poll Expiration Logic --- + if (remaining === 0) { + clearInterval(countdownInterval); + + const votersList = poll.voters.map(voterId => { + const user = getUser.get(voterId); + return `- ${user?.globalName || 'Utilisateur Inconnu'}`; + }).join('\n'); + + try { + await DiscordRequest(poll.endpoint, { + method: 'PATCH', + body: { + embeds: [{ + title: `Le vote pour timeout ${poll.toUsername} a échoué 😔`, + description: `Il manquait **${votesNeeded}** vote(s).`, + fields: [{ + name: 'Pour', + value: `✅ ${poll.for}\n${votersList}`, + inline: true, + }], + color: 0xFF4444, // Red for failure + }], + components: [], // Remove buttons + }, + }); + } catch (err) { + console.error('Error updating failed poll message:', err); + } + + // Clean up the poll from active state + delete activePolls[pollId]; + io.emit('poll-update'); // Notify frontend + return; + } + + // --- Periodic Update Logic --- + // Update the message every second with the new countdown + try { + const votersList = poll.voters.map(voterId => { + const user = getUser.get(voterId); + return `- ${user?.globalName || 'Utilisateur Inconnu'}`; + }).join('\n'); + + await DiscordRequest(poll.endpoint, { + method: 'PATCH', + body: { + embeds: [{ + title: 'Vote de Timeout', + description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}.\nIl manque **${votesNeeded}** vote(s).`, + fields: [{ + name: 'Pour', + value: `✅ ${poll.for}\n${votersList}`, + inline: true, + }, { + name: 'Temps restant', + value: `⏳ ${countdownText}`, + inline: false, + }], + color: 0x5865F2, // Discord Blurple + }], + // Keep the components so people can still vote + components: [{ + type: MessageComponentTypes.ACTION_ROW, + components: [ + { type: MessageComponentTypes.BUTTON, custom_id: `vote_for_${pollId}`, label: 'Oui ✅', style: ButtonStyleTypes.SUCCESS }, + { type: MessageComponentTypes.BUTTON, custom_id: `vote_against_${pollId}`, label: 'Non ❌', style: ButtonStyleTypes.DANGER }, + ], + }], + }, + }); + } catch (err) { + console.error('Error updating countdown:', err); + // If the message was deleted, stop trying to update it. + if (err.message.includes('Unknown Message')) { + clearInterval(countdownInterval); + delete activePolls[pollId]; + io.emit('poll-update'); + } + } + }, 2000); // Update every 2 seconds to avoid rate limits + + // --- Send Initial Response --- + io.emit('poll-update'); // Notify frontend + + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [{ + title: 'Vote de Timeout', + description: `**${activePolls[pollId].username}** propose de timeout **${activePolls[pollId].toUsername}** pendant ${activePolls[pollId].time_display}.\nIl manque **${activePolls[pollId].requiredMajority}** vote(s).`, + fields: [{ + name: 'Pour', + value: '✅ 0', + inline: true, + }, { + name: 'Temps restant', + value: `⏳ **${Math.floor((activePolls[pollId].endTime - Date.now()) / 60000)}m**`, + inline: false, + }], + color: 0x5865F2, + }], + components: [{ + type: MessageComponentTypes.ACTION_ROW, + components: [ + { type: MessageComponentTypes.BUTTON, custom_id: `vote_for_${pollId}`, label: 'Oui ✅', style: ButtonStyleTypes.SUCCESS }, + { type: MessageComponentTypes.BUTTON, custom_id: `vote_against_${pollId}`, label: 'Non ❌', style: ButtonStyleTypes.DANGER }, + ], + }], + }, + }); +} \ No newline at end of file diff --git a/src/bot/commands/valorant.js b/src/bot/commands/valorant.js new file mode 100644 index 0000000..4d6e39d --- /dev/null +++ b/src/bot/commands/valorant.js @@ -0,0 +1,188 @@ +import { + InteractionResponseType, + InteractionResponseFlags, +} from 'discord-interactions'; +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; + +import { postAPOBuy } from '../../utils/index.js'; +import { DiscordRequest } from '../../api/discord.js'; +import { getAllAvailableSkins, updateSkin } from '../../database/index.js'; +import { skins } from '../../game/state.js'; + +/** + * Handles the /valorant slash command for opening a "skin case". + * + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {object} client - The Discord.js client instance. + */ +export async function handleValorantCommand(req, res, client) { + const { member, token } = req.body; + const userId = member.user.id; + const valoPrice = parseInt(process.env.VALO_PRICE, 10) || 150; + + try { + // --- 1. Verify and process payment --- + const buyResponse = await postAPOBuy(userId, valoPrice); + + if (!buyResponse.ok) { + const errorData = await buyResponse.json(); + const errorMessage = errorData.message || `Tu n'as pas assez d'argent... Il te faut ${valoPrice}€.`; + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: errorMessage, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // --- 2. Send Initial "Opening" Response --- + // Acknowledge the interaction immediately with a loading message. + const initialEmbed = new EmbedBuilder() + .setTitle('Ouverture de la caisse...') + .setImage('https://media.tenor.com/gIWab6ojBnYAAAAd/weapon-line-up-valorant.gif') + .setColor('#F2F3F3'); + + await res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { embeds: [initialEmbed] }, + }); + + + // --- 3. Run the skin reveal logic after a delay --- + setTimeout(async () => { + const webhookEndpoint = `webhooks/${process.env.APP_ID}/${token}/messages/@original`; + try { + // --- Skin Selection --- + const availableSkins = getAllAvailableSkins.all(); + if (availableSkins.length === 0) { + throw new Error("No available skins to award."); + } + const dbSkin = availableSkins[Math.floor(Math.random() * availableSkins.length)]; + const randomSkinData = skins.find((skin) => skin.uuid === dbSkin.uuid); + if (!randomSkinData) { + throw new Error(`Could not find skin data for UUID: ${dbSkin.uuid}`); + } + + // --- Randomize Level and Chroma --- + const randomLevel = Math.floor(Math.random() * randomSkinData.levels.length) + 1; + let randomChroma = 1; + if (randomLevel === randomSkinData.levels.length && randomSkinData.chromas.length > 1) { + // Ensure chroma is at least 1 and not greater than the number of chromas + randomChroma = Math.floor(Math.random() * randomSkinData.chromas.length) + 1; + } + + // --- Calculate Price --- + const calculatePrice = () => { + let result = parseFloat(dbSkin.basePrice); + result *= (1 + (randomLevel / Math.max(randomSkinData.levels.length, 2))); + result *= (1 + (randomChroma / 4)); + return parseFloat(result.toFixed(2)); + }; + const finalPrice = calculatePrice(); + + // --- Update Database --- + await updateSkin.run({ + uuid: randomSkinData.uuid, + user_id: userId, + currentLvl: randomLevel, + currentChroma: randomChroma, + currentPrice: finalPrice, + }); + + // --- Prepare Final Embed and Components --- + const finalEmbed = buildFinalEmbed(dbSkin, randomSkinData, randomLevel, randomChroma, finalPrice); + const components = buildComponents(randomSkinData, randomLevel, randomChroma); + + // --- Edit the Original Message with the Result --- + await DiscordRequest(webhookEndpoint, { + method: 'PATCH', + body: { + embeds: [finalEmbed], + components: components, + }, + }); + + } catch (revealError) { + console.error('Error during skin reveal:', revealError); + // Inform the user that something went wrong + await DiscordRequest(webhookEndpoint, { + method: 'PATCH', + body: { + content: "Oups, il y a eu un petit problème lors de l'ouverture de la caisse. L'administrateur a été notifié.", + embeds: [], + }, + }); + } + }, 5000); // 5-second delay for suspense + + } catch (error) { + console.error('Error handling /valorant command:', error); + // This catches errors from the initial interaction, e.g., the payment API call. + return res.status(500).json({ error: 'Failed to initiate the case opening.' }); + } +} + +// --- Helper Functions --- + +/** Builds the final embed to display the won skin. */ +function buildFinalEmbed(dbSkin, skinData, level, chroma, price) { + const selectedChromaData = skinData.chromas[chroma - 1] || {}; + + const getChromaName = () => { + if (chroma > 1) { + const name = selectedChromaData.displayName?.replace(/[\r\n]+/g, ' ').replace(skinData.displayName, '').trim(); + const match = name?.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i); + return match ? match[1].trim() : (name || 'Chroma Inconnu'); + } + return 'Base'; + }; + + const getImageUrl = () => { + if (level === skinData.levels.length) { + return selectedChromaData.fullRender || selectedChromaData.displayIcon || skinData.displayIcon; + } + const levelData = skinData.levels[level - 1]; + return levelData?.displayIcon || skinData.displayIcon; + }; + + const lvlText = '1️⃣'.repeat(level) + '◾'.repeat(skinData.levels.length - level); + const chromaText = '💠'.repeat(chroma) + '◾'.repeat(skinData.chromas.length - chroma); + + return new EmbedBuilder() + .setTitle(`${skinData.displayName} | ${getChromaName()}`) + .setDescription(dbSkin.tierText) + .setColor(`#${dbSkin.tierColor}`) + .setImage(getImageUrl()) + .setFields([ + { name: 'Lvl', value: lvlText || 'N/A', inline: true }, + { name: 'Chroma', value: chromaText || 'N/A', inline: true }, + { name: 'Prix', value: `**${price}** <:vp:1362964205808128122>`, inline: true }, + ]) + .setFooter({ text: 'Skin ajouté à votre inventaire !' }); +} + +/** Builds the action row with a video button if a video is available. */ +function buildComponents(skinData, level, chroma) { + const selectedLevelData = skinData.levels[level - 1] || {}; + const selectedChromaData = skinData.chromas[chroma - 1] || {}; + + let videoUrl = null; + if (level === skinData.levels.length) { + videoUrl = selectedChromaData.streamedVideo; + } + videoUrl = videoUrl || selectedLevelData.streamedVideo; + + if (videoUrl) { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('🎬 Aperçu Vidéo') + .setStyle(ButtonStyle.Link) + .setURL(videoUrl) + ) + ]; + } + return []; // Return an empty array if no video is available +} \ No newline at end of file diff --git a/src/bot/components/inventoryNav.js b/src/bot/components/inventoryNav.js new file mode 100644 index 0000000..ee498cd --- /dev/null +++ b/src/bot/components/inventoryNav.js @@ -0,0 +1,151 @@ +import { + InteractionResponseType, + MessageComponentTypes, + ButtonStyleTypes, + InteractionResponseFlags, +} from 'discord-interactions'; + +import { DiscordRequest } from '../../api/discord.js'; +import { activeInventories, skins } from '../../game/state.js'; + +/** + * Handles navigation button clicks (Previous/Next) for the inventory embed. + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {object} client - The Discord.js client instance. + */ +export async function handleInventoryNav(req, res, client) { + const { member, data, guild_id } = req.body; + const { custom_id } = data; + + // Extract direction ('prev' or 'next') and the original interaction ID from the custom_id + const [direction, page, interactionId] = custom_id.split('_'); + + // --- 1. Retrieve the interactive session --- + const inventorySession = activeInventories[interactionId]; + + // --- 2. Validation Checks --- + if (!inventorySession) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Oups, cet affichage d'inventaire a expiré. Veuillez relancer la commande `/inventory`.", + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // Ensure the user clicking the button is the one who initiated the command + if (inventorySession.userId !== member.user.id) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Vous ne pouvez pas naviguer dans l'inventaire de quelqu'un d'autre.", + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + + // --- 3. Update Page Number --- + const { amount } = inventorySession; + if (direction === 'next') { + inventorySession.page = (inventorySession.page + 1) % amount; + } else if (direction === 'prev') { + inventorySession.page = (inventorySession.page - 1 + amount) % amount; + } + + + try { + // --- 4. Rebuild Embed with New Page Content --- + const { page, inventorySkins } = inventorySession; + const currentSkin = inventorySkins[page]; + const skinData = skins.find((s) => s.uuid === currentSkin.uuid); + if (!skinData) { + throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`); + } + + const guild = await client.guilds.fetch(guild_id); + const targetMember = await guild.members.fetch(inventorySession.akhyId); + const totalPrice = inventorySkins.reduce((sum, skin) => sum + (skin.currentPrice || 0), 0); + + // --- Helper functions for formatting --- + const getChromaText = (skin, skinInfo) => { + let result = ""; + for (let i = 1; i <= skinInfo.chromas.length; i++) { + result += skin.currentChroma === i ? '💠 ' : '◾ '; + } + return result || 'N/A'; + }; + + const getChromaName = (skin, skinInfo) => { + if (skin.currentChroma > 1) { + const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName.replace(/[\r\n]+/g, ' ').replace(skinInfo.displayName, '').trim(); + const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i); + return match ? match[1].trim() : name; + } + return 'Base'; + }; + + const getImageUrl = (skin, skinInfo) => { + if (skin.currentLvl === skinInfo.levels.length) { + const chroma = skinInfo.chromas[skin.currentChroma - 1]; + return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon; + } + const level = skinInfo.levels[skin.currentLvl - 1]; + return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender; + }; + + // --- 5. Rebuild Components (Buttons) --- + let components = [ + { type: MessageComponentTypes.BUTTON, custom_id: `prev_page_${interactionId}`, label: '⏮️ Préc.', style: ButtonStyleTypes.SECONDARY }, + { type: MessageComponentTypes.BUTTON, custom_id: `next_page_${interactionId}`, label: 'Suiv. ⏭️', style: ButtonStyleTypes.SECONDARY }, + ]; + + const isUpgradable = currentSkin.currentLvl < skinData.levels.length || currentSkin.currentChroma < skinData.chromas.length; + // Conditionally add the upgrade button + if (isUpgradable && inventorySession.akhyId === inventorySession.userId) { + components.push({ + type: MessageComponentTypes.BUTTON, + custom_id: `upgrade_${interactionId}`, + label: `Upgrade ⏫ (${process.env.VALO_UPGRADE_PRICE || (currentSkin.maxPrice/10).toFixed(0)}€)`, + style: ButtonStyleTypes.PRIMARY, + }); + } + + // --- 6. Send PATCH Request to Update the Message --- + await DiscordRequest(inventorySession.endpoint, { + method: 'PATCH', + body: { + embeds: [{ + title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`, + color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3, + footer: { text: `Page ${page + 1}/${amount} | Valeur Totale : ${totalPrice.toFixed(2)}€` }, + fields: [{ + name: `${currentSkin.displayName} | ${currentSkin.currentPrice.toFixed(2)}€`, + value: `${currentSkin.tierText}\nChroma : ${getChromaText(currentSkin, skinData)} | ${getChromaName(currentSkin, skinData)}\nLvl : **${currentSkin.currentLvl}**/${skinData.levels.length}`, + }], + image: { url: getImageUrl(currentSkin, skinData) }, + }], + components: [{ type: MessageComponentTypes.ACTION_ROW, components: components }], + }, + }); + + // --- 7. Acknowledge the Interaction --- + // This tells Discord the interaction was received, and since the message is already updated, + // no further action is needed. + return res.send({ type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE }); + + } catch (error) { + console.error('Error handling inventory navigation:', error); + // In case of an error, we should still acknowledge the interaction to prevent it from failing. + // We can send a silent, ephemeral error message. + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Une erreur est survenue lors de la mise à jour de l\'inventaire.', + flags: InteractionResponseFlags.EPHEMERAL, + } + }); + } +} \ No newline at end of file diff --git a/src/bot/components/pollVote.js b/src/bot/components/pollVote.js new file mode 100644 index 0000000..d7cdc0e --- /dev/null +++ b/src/bot/components/pollVote.js @@ -0,0 +1,176 @@ +import { + InteractionResponseType, + InteractionResponseFlags, +} from 'discord-interactions'; +import { DiscordRequest } from '../../api/discord.js'; +import { activePolls } from '../../game/state.js'; +import { getSocketIo } from '../../server/socket.js'; +import { getUser } from '../../database/index.js'; + +/** + * Handles clicks on the 'Yes' or 'No' buttons of a timeout poll. + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + */ +export async function handlePollVote(req, res) { + const io = getSocketIo(); + const { member, data, guild_id } = req.body; + const { custom_id } = data; + + // --- 1. Parse Component ID --- + const [_, voteType, pollId] = custom_id.split('_'); // e.g., ['vote', 'for', '12345...'] + const isVotingFor = voteType === 'for'; + + // --- 2. Retrieve Poll and Validate --- + const poll = activePolls[pollId]; + const voterId = member.user.id; + + if (!poll) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Ce sondage de timeout n'est plus actif.", + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // Check if the voter has the required role + if (!member.roles.includes(process.env.VOTING_ROLE_ID)) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Vous n'avez pas le rôle requis pour participer à ce vote.", + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // Prevent user from voting on themselves + if (poll.toUserId === voterId) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Vous ne pouvez pas voter pour vous-même.", + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // Prevent double voting + if (poll.voters.includes(voterId)) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Vous avez déjà voté pour ce sondage.', + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // --- 3. Record the Vote --- + poll.voters.push(voterId); + if (isVotingFor) { + poll.for++; + } else { + poll.against++; + } + + io.emit('poll-update'); // Notify frontend clients of the change + + const votersList = poll.voters.map(vId => `- ${getUser.get(vId)?.globalName || 'Utilisateur Inconnu'}`).join('\n'); + + + // --- 4. Check for Majority --- + if (isVotingFor && poll.for >= poll.requiredMajority) { + // --- SUCCESS CASE: MAJORITY REACHED --- + + // a. Update the poll message to show success + try { + await DiscordRequest(poll.endpoint, { + method: 'PATCH', + body: { + embeds: [{ + title: 'Vote Terminé - Timeout Appliqué !', + description: `La majorité a été atteinte. **${poll.toUsername}** a été timeout pendant ${poll.time_display}.`, + fields: [{ name: 'Votes Pour', value: `✅ ${poll.for}\n${votersList}`, inline: true }], + color: 0x22A55B, // Green for success + }], + components: [], // Remove buttons + }, + }); + } catch (err) { + console.error('Error updating final poll message:', err); + } + + // b. Execute the timeout via Discord API + try { + const timeoutUntil = new Date(Date.now() + poll.time * 1000).toISOString(); + const endpointTimeout = `guilds/${guild_id}/members/${poll.toUserId}`; + await DiscordRequest(endpointTimeout, { + method: 'PATCH', + body: { communication_disabled_until: timeoutUntil }, + }); + + // c. Send a public confirmation message and clean up + delete activePolls[pollId]; + io.emit('poll-update'); + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `💥 <@${poll.toUserId}> a été timeout pendant **${poll.time_display}** par décision démocratique !`, + }, + }); + + } catch (err) { + console.error('Error timing out user:', err); + delete activePolls[pollId]; + io.emit('poll-update'); + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `La majorité a été atteinte, mais une erreur est survenue lors de l'application du timeout sur <@${poll.toUserId}>.`, + }, + }); + } + } else { + // --- PENDING CASE: NO MAJORITY YET --- + + // a. Send an ephemeral acknowledgment to the voter + res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Votre vote a été enregistré ! ✅', + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + + // b. Update the original poll message asynchronously (no need to await) + // The main countdown interval will also handle this, but this provides a faster update. + const votesNeeded = Math.max(0, poll.requiredMajority - poll.for); + const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000)); + const countdownText = `**${Math.floor(remaining / 60)}m ${remaining % 60}s** restantes`; + + DiscordRequest(poll.endpoint, { + method: 'PATCH', + body: { + embeds: [{ + title: 'Vote de Timeout', + description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}.\nIl manque **${votesNeeded}** vote(s).`, + fields: [{ + name: 'Pour', + value: `✅ ${poll.for}\n${votersList}`, + inline: true, + }, { + name: 'Temps restant', + value: `⏳ ${countdownText}`, + inline: false, + }], + color: 0x5865F2, + }], + // Keep the original components so people can still vote + components: req.body.message.components, + }, + }).catch(err => console.error("Error updating poll after vote:", err)); + } +} \ No newline at end of file diff --git a/src/bot/components/searchNav.js b/src/bot/components/searchNav.js new file mode 100644 index 0000000..e896cbb --- /dev/null +++ b/src/bot/components/searchNav.js @@ -0,0 +1,121 @@ +import { + InteractionResponseType, + InteractionResponseFlags, + MessageComponentTypes, + ButtonStyleTypes, +} from 'discord-interactions'; + +import { DiscordRequest } from '../../api/discord.js'; +import { activeSearchs, skins } from '../../game/state.js'; + +/** + * Handles navigation button clicks (Previous/Next) for the search results embed. + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {object} client - The Discord.js client instance. + */ +export async function handleSearchNav(req, res, client) { + const { member, data, guild_id } = req.body; + const { custom_id } = data; + + // Extract direction and the original interaction ID from the custom_id + const [direction, _, page, interactionId] = custom_id.split('_'); // e.g., ['next', 'search', 'page', '123...'] + + // --- 1. Retrieve the interactive session --- + const searchSession = activeSearchs[interactionId]; + + // --- 2. Validation Checks --- + if (!searchSession) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Oups, cette recherche a expiré. Veuillez relancer la commande `/search`.", + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // Ensure the user clicking the button is the one who initiated the command + if (searchSession.userId !== member.user.id) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Vous ne pouvez pas naviguer dans les résultats de recherche de quelqu'un d'autre.", + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); + } + + // --- 3. Update Page Number --- + const { amount } = searchSession; + if (direction === 'next') { + searchSession.page = (searchSession.page + 1) % amount; + } else if (direction === 'prev') { + searchSession.page = (searchSession.page - 1 + amount) % amount; + } + + try { + // --- 4. Rebuild Embed with New Page Content --- + const { page, resultSkins, searchValue } = searchSession; + const currentSkin = resultSkins[page]; + const skinData = skins.find((s) => s.uuid === currentSkin.uuid); + if (!skinData) { + throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`); + } + + // Fetch owner details if the skin is owned + let ownerText = ''; + if (currentSkin.user_id) { + try { + const owner = await client.users.fetch(currentSkin.user_id); + ownerText = `| **@${owner.globalName || owner.username}** ✅`; + } catch (e) { + console.warn(`Could not fetch owner for user ID: ${currentSkin.user_id}`); + ownerText = '| Appartenant à un utilisateur inconnu'; + } + } + + // Helper to get the best possible image for the skin + const getImageUrl = (skinInfo) => { + const lastChroma = skinInfo.chromas[skinInfo.chromas.length - 1]; + if (lastChroma?.fullRender) return lastChroma.fullRender; + if (lastChroma?.displayIcon) return lastChroma.displayIcon; + const lastLevel = skinInfo.levels[skinInfo.levels.length - 1]; + if (lastLevel?.displayIcon) return lastLevel.displayIcon; + return skinInfo.displayIcon; + }; + + // --- 5. Send PATCH Request to Update the Message --- + // Note: The components (buttons) do not change, so we can reuse them from the original message. + await DiscordRequest(searchSession.endpoint, { + method: 'PATCH', + body: { + embeds: [{ + title: 'Résultats de la recherche', + description: `🔎 _"${searchValue}"_`, + color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3, + fields: [{ + name: `**${currentSkin.displayName}**`, + value: `${currentSkin.tierText}\nValeur Max: **${currentSkin.maxPrice}€** ${ownerText}`, + }], + image: { url: getImageUrl(skinData) }, + footer: { text: `Résultat ${page + 1}/${amount}` }, + }], + components: req.body.message.components, // Reuse existing components + }, + }); + + // --- 6. Acknowledge the Interaction --- + return res.send({ type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE }); + + } catch (error) { + console.error('Error handling search navigation:', error); + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: 'Une erreur est survenue lors de la mise à jour de la recherche.', + flags: InteractionResponseFlags.EPHEMERAL, + } + }); + } +} \ No newline at end of file diff --git a/src/bot/components/upgradeSkin.js b/src/bot/components/upgradeSkin.js new file mode 100644 index 0000000..2909371 --- /dev/null +++ b/src/bot/components/upgradeSkin.js @@ -0,0 +1,196 @@ +import { + InteractionResponseType, + InteractionResponseFlags, + MessageComponentTypes, + ButtonStyleTypes, +} from 'discord-interactions'; +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; + +import { DiscordRequest } from '../../api/discord.js'; +import { postAPOBuy } from '../../utils/index.js'; +import { activeInventories, skins } from '../../game/state.js'; +import { getSkin, updateSkin } from '../../database/index.js'; + +/** + * Handles the click of the 'Upgrade' button on a skin in the inventory. + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + */ +export async function handleUpgradeSkin(req, res) { + const { member, data } = req.body; + const { custom_id } = data; + + const interactionId = custom_id.replace('upgrade_', ''); + const userId = member.user.id; + + // --- 1. Retrieve Session and Validate --- + const inventorySession = activeInventories[interactionId]; + if (!inventorySession) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Cet affichage d'inventaire a expiré.", flags: InteractionResponseFlags.EPHEMERAL }, + }); + } + + // Ensure the user clicking is the inventory owner + if (inventorySession.akhyId !== userId || inventorySession.userId !== userId) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Vous ne pouvez pas améliorer un skin qui ne vous appartient pas.", flags: InteractionResponseFlags.EPHEMERAL }, + }); + } + + const skinToUpgrade = inventorySession.inventorySkins[inventorySession.page]; + const skinData = skins.find((s) => s.uuid === skinToUpgrade.uuid); + + if (!skinData || (skinToUpgrade.currentLvl >= skinData.levels.length && skinToUpgrade.currentChroma >= skinData.chromas.length)) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: "Ce skin est déjà au niveau maximum et ne peut pas être amélioré.", flags: InteractionResponseFlags.EPHEMERAL }, + }); + } + + + // --- 2. Handle Payment --- + const upgradePrice = parseFloat(process.env.VALO_UPGRADE_PRICE) || parseFloat(skinToUpgrade.maxPrice) / 10; + try { + const buyResponse = await postAPOBuy(userId, upgradePrice.toFixed(0)); + if (!buyResponse.ok) { + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: `Il vous faut ${upgradePrice.toFixed(0)}€ pour tenter cette amélioration.`, flags: InteractionResponseFlags.EPHEMERAL }, + }); + } + } catch (paymentError) { + console.error("Payment API error:", paymentError); + return res.status(500).json({ error: "Payment service unavailable."}); + } + + + // --- 3. Show Loading Animation --- + // Acknowledge the click immediately and then edit the message to show a loading state. + await res.send({ type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE }); + + await DiscordRequest(inventorySession.endpoint, { + method: 'PATCH', + body: { + embeds: [{ + title: 'Amélioration en cours...', + image: { url: 'https://media.tenor.com/HD8nVN2QP9MAAAAC/thoughts-think.gif' }, + color: 0x4F545C, + }], + components: [], + }, + }); + + + // --- 4. Perform Upgrade Logic --- + let succeeded = false; + const isLevelUpgrade = skinToUpgrade.currentLvl < skinData.levels.length; + + if (isLevelUpgrade) { + // Upgrading Level + const successProb = 1 - (skinToUpgrade.currentLvl / skinData.levels.length) * (skinToUpgrade.tierRank / 5 + 0.5); + if (Math.random() < successProb) { + succeeded = true; + skinToUpgrade.currentLvl++; + } + } else { + // Upgrading Chroma + const successProb = 1 - (skinToUpgrade.currentChroma / skinData.chromas.length) * (skinToUpgrade.tierRank / 5 + 0.5); + if (Math.random() < successProb) { + succeeded = true; + skinToUpgrade.currentChroma++; + } + } + + // --- 5. Update Database if Successful --- + if (succeeded) { + const calculatePrice = () => { + let result = parseFloat(skinToUpgrade.basePrice); + result *= (1 + (skinToUpgrade.currentLvl / Math.max(skinData.levels.length, 2))); + result *= (1 + (skinToUpgrade.currentChroma / 4)); + return parseFloat(result.toFixed(2)); + }; + skinToUpgrade.currentPrice = calculatePrice(); + + await updateSkin.run({ + uuid: skinToUpgrade.uuid, + user_id: skinToUpgrade.user_id, + currentLvl: skinToUpgrade.currentLvl, + currentChroma: skinToUpgrade.currentChroma, + currentPrice: skinToUpgrade.currentPrice, + }); + // Update the session cache + inventorySession.inventorySkins[inventorySession.page] = skinToUpgrade; + } + + + // --- 6. Send Final Result --- + setTimeout(async () => { + // Fetch the latest state of the skin from the database + const finalSkinState = getSkin.get(skinToUpgrade.uuid); + const finalEmbed = buildFinalEmbed(succeeded, finalSkinState, skinData); + const finalComponents = buildFinalComponents(succeeded, skinData, finalSkinState, interactionId); + + await DiscordRequest(inventorySession.endpoint, { + method: 'PATCH', + body: { + embeds: [finalEmbed], + components: finalComponents, + }, + }); + }, 2000); // Delay for the result to feel more impactful +} + +// --- Helper Functions --- + +/** Builds the result embed (success or failure). */ +function buildFinalEmbed(succeeded, skin, skinData) { + const embed = new EmbedBuilder() + .setTitle(succeeded ? 'Amélioration Réussie ! 🎉' : "L'amélioration a échoué... ❌") + .setDescription(`**${skin.displayName}**`) + .setImage(skin.displayIcon) // A static image is fine here + .setColor(succeeded ? 0x22A55B : 0xED4245); + + if (succeeded) { + embed.addFields( + { name: 'Nouveau Niveau', value: `${skin.currentLvl}/${skinData.levels.length}`, inline: true }, + { name: 'Nouveau Chroma', value: `${skin.currentChroma}/${skinData.chromas.length}`, inline: true }, + { name: 'Nouvelle Valeur', value: `**${skin.currentPrice}€**`, inline: true } + ); + } else { + embed.addFields({ name: 'Statut', value: 'Aucun changement.' }); + } + return embed; +} + +/** Builds the result components (Retry button or Video link). */ +function buildFinalComponents(succeeded, skinData, skin, interactionId) { + const isMaxed = skin.currentLvl >= skinData.levels.length && skin.currentChroma >= skinData.chromas.length; + + if (isMaxed) return []; // No buttons if maxed out + + const row = new ActionRowBuilder(); + if (succeeded) { + // Check for video on the new level/chroma + const levelData = skinData.levels[skin.currentLvl - 1] || {}; + const chromaData = skinData.chromas[skin.currentChroma - 1] || {}; + const videoUrl = levelData.streamedVideo || chromaData.streamedVideo; + + if (videoUrl) { + row.addComponents(new ButtonBuilder().setLabel('🎬 Aperçu Vidéo').setStyle(ButtonStyle.Link).setURL(videoUrl)); + } else { + return []; // No button if no video + } + } else { + // Add a "Retry" button + row.addComponents( + new ButtonBuilder() + .setLabel('Réessayer 🔄️') + .setStyle(ButtonStyle.Primary) + .setCustomId(`upgrade_${interactionId}`) + ); + } + return [row]; +} \ No newline at end of file diff --git a/src/bot/events.js b/src/bot/events.js new file mode 100644 index 0000000..6399282 --- /dev/null +++ b/src/bot/events.js @@ -0,0 +1,56 @@ +import { handleMessageCreate } from './handlers/messageCreate.js'; +import { getAkhys, setupCronJobs } from '../utils/index.js'; + +/** + * Initializes and attaches all necessary event listeners to the Discord client. + * This function should be called once the client is ready. + * + * @param {object} client - The Discord.js client instance. + * @param {object} io - The Socket.IO server instance for real-time communication. + */ +export function initializeEvents(client, io) { + // --- on 'ready' --- + // This event fires once the bot has successfully logged in and is ready to operate. + // It's a good place for setup tasks that require the bot to be online. + client.once('ready', async () => { + console.log(`Bot is ready and logged in as ${client.user.tag}!`); + // You can add any post-login setup tasks here if needed. + // For example, setting the bot's activity: + client.user.setActivity('FlopoSite.com', { type: 'WATCHING' }); + + console.log('[Startup] Bot is ready, performing initial data sync...'); + await getAkhys(client); + console.log('[Startup] Setting up scheduled tasks...'); + setupCronJobs(client, io); + console.log('--- FlopoBOT is fully operational ---'); + }); + + // --- on 'messageCreate' --- + // This event fires every time a message is sent in a channel the bot can see. + // The logic is delegated to its own dedicated handler for cleanliness. + client.on('messageCreate', async (message) => { + // We pass the client and io instances to the handler so it has access to them + // without needing to import them, preventing potential circular dependencies. + await handleMessageCreate(message, client, io); + }); + + // --- on 'interactionCreate' (Alternative Method) --- + // While we handle interactions via the Express endpoint for scalability and statelessness, + // you could also listen for them via the gateway like this. + // It's commented out because our current architecture uses the webhook approach. + /* + client.on('interactionCreate', async (interaction) => { + // Logic to handle interactions would go here if not using a webhook endpoint. + }); + */ + + // You can add more event listeners here as your bot's functionality grows. + // For example, listening for new members joining the server: + // client.on('guildMemberAdd', (member) => { + // console.log(`Welcome to the server, ${member.user.tag}!`); + // const welcomeChannel = member.guild.channels.cache.get('YOUR_WELCOME_CHANNEL_ID'); + // if (welcomeChannel) { + // welcomeChannel.send(`Please welcome <@${member.id}> to the server!`); + // } + // }); +} \ No newline at end of file diff --git a/src/bot/handlers/interactionCreate.js b/src/bot/handlers/interactionCreate.js new file mode 100644 index 0000000..e403607 --- /dev/null +++ b/src/bot/handlers/interactionCreate.js @@ -0,0 +1,89 @@ +import { + InteractionType, + InteractionResponseType, +} from 'discord-interactions'; + +// --- Command Handlers --- +import { handleTimeoutCommand } from '../commands/timeout.js'; +import { handleInventoryCommand } from '../commands/inventory.js'; +import { handleValorantCommand } from '../commands/valorant.js'; +import { handleInfoCommand } from '../commands/info.js'; +import { handleSkinsCommand } from '../commands/skins.js'; +import { handleSearchCommand } from '../commands/search.js'; +import { handleFlopoSiteCommand } from '../commands/floposite.js'; + +// --- Component Handlers --- +import { handlePollVote } from '../components/pollVote.js'; +import { handleInventoryNav } from '../components/inventoryNav.js'; +import { handleUpgradeSkin } from '../components/upgradeSkin.js'; +import { handleSearchNav } from '../components/searchNav.js'; + +/** + * The main handler for all incoming interactions from Discord. + * @param {object} req - The Express request object. + * @param {object} res - The Express response object. + * @param {object} client - The Discord.js client instance. + */ +export async function handleInteraction(req, res, client) { + const { type, data, id } = req.body; + + try { + if (type === InteractionType.PING) { + return res.send({ type: InteractionResponseType.PONG }); + } + + if (type === InteractionType.APPLICATION_COMMAND) { + const { name } = data; + + switch (name) { + case 'timeout': + return await handleTimeoutCommand(req, res, client); + case 'inventory': + return await handleInventoryCommand(req, res, client, id); + case 'valorant': + return await handleValorantCommand(req, res, client); + case 'info': + return await handleInfoCommand(req, res, client); + case 'skins': + return await handleSkinsCommand(req, res, client); + case 'search': + return await handleSearchCommand(req, res, client, id); + case 'floposite': + return await handleFlopoSiteCommand(req, res); + default: + console.error(`Unknown command: ${name}`); + return res.status(400).json({ error: 'Unknown command' }); + } + } + + if (type === InteractionType.MESSAGE_COMPONENT) { + const componentId = data.custom_id; + + if (componentId.startsWith('vote_')) { + return await handlePollVote(req, res, client); + } + if (componentId.startsWith('prev_page') || componentId.startsWith('next_page')) { + return await handleInventoryNav(req, res, client); + } + if (componentId.startsWith('upgrade_')) { + return await handleUpgradeSkin(req, res, client); + } + if (componentId.startsWith('prev_search_page') || componentId.startsWith('next_search_page')) { + return await handleSearchNav(req, res, client); + } + + // Fallback for other potential components + console.error(`Unknown component ID: ${componentId}`); + return res.status(400).json({ error: 'Unknown component' }); + } + + // --- Fallback for Unknown Interaction Types --- + console.error('Unknown interaction type:', type); + return res.status(400).json({ error: 'Unknown interaction type' }); + + } catch (error) { + console.error('Error handling interaction:', error); + // Send a generic error response to Discord if something goes wrong + return res.status(500).json({ error: 'An internal error occurred' }); + } +} \ No newline at end of file diff --git a/src/bot/handlers/messageCreate.js b/src/bot/handlers/messageCreate.js new file mode 100644 index 0000000..3d84eaf --- /dev/null +++ b/src/bot/handlers/messageCreate.js @@ -0,0 +1,191 @@ +import { sleep } from 'openai/core'; +import { gork } from '../../utils/ai.js'; +import { formatTime, postAPOBuy, getAPOUsers } from '../../utils/index.js'; +import { channelPointsHandler, slowmodesHandler, randomSkinPrice, initTodaysSOTD } from '../../game/points.js'; +import { requestTimestamps, activeSlowmodes, activePolls, skins } from '../../game/state.js'; +import { flopoDB, getUser, getAllUsers, updateManyUsers } from '../../database/index.js'; + +// Constants for the AI rate limiter +const MAX_REQUESTS_PER_INTERVAL = parseInt(process.env.MAX_REQUESTS || "5"); +const SPAM_INTERVAL = parseInt(process.env.SPAM_INTERVAL || "60000"); // 60 seconds default + +/** + * Handles all logic for when a message is created. + * @param {object} message - The Discord.js message object. + * @param {object} client - The Discord.js client instance. + * @param {object} io - The Socket.IO server instance. + */ +export async function handleMessageCreate(message, client, io) { + // Ignore all messages from bots to prevent loops + if (message.author.bot) return; + + // --- Specific User Gags --- + if (message.author.id === process.env.PATA_ID) { + if (message.content.toLowerCase().startsWith('feur') || message.content.toLowerCase().startsWith('rati')) { + await sleep(1000); + await message.delete().catch(console.error); + } + } + + // --- Main Guild Features (Points & Slowmode) --- + if (message.guildId === process.env.GUILD_ID) { + // Award points for activity + const pointsAwarded = await channelPointsHandler(message); + if (pointsAwarded) { + io.emit('data-updated', { table: 'users', action: 'update' }); + } + + // Enforce active slowmodes + const wasSlowmoded = await slowmodesHandler(message, activeSlowmodes); + if (wasSlowmoded.deleted) { + io.emit('slowmode-update'); + } + } + + // --- AI Mention Handler --- + if (message.mentions.has(client.user) || message.mentions.repliedUser?.id === client.user.id) { + await handleAiMention(message, client, io); + return; // Stop further processing after AI interaction + } + + // --- "Quoi/Feur" Gag --- + if (message.content.toLowerCase().includes("quoi")) { + const prob = Math.random(); + if (prob < (parseFloat(process.env.FEUR_PROB) || 0.05)) { + message.channel.send('feur').catch(console.error); + } + return; + } + + // --- Admin/Dev Guild Commands --- + if (message.guildId === process.env.DEV_GUILD_ID && message.author.id === process.env.DEV_ID) { + await handleAdminCommands(message); + } +} + + +// --- Sub-handler for AI Logic --- + +async function handleAiMention(message, client, io) { + const authorId = message.author.id; + let authorDB = getUser.get(authorId); + if (!authorDB) return; // Should not happen if user is in DB, but good practice + + // --- Rate Limiting --- + const now = Date.now(); + const timestamps = (requestTimestamps.get(authorId) || []).filter(ts => now - ts < SPAM_INTERVAL); + + if (timestamps.length >= MAX_REQUESTS_PER_INTERVAL) { + console.log(`Rate limit exceeded for ${authorDB.username}`); + if (!authorDB.warned) { + await message.reply(`T'abuses fréro, attends un peu ⏳`).catch(console.error); + } + // Update user's warn status + authorDB.warned = 1; + authorDB.warns += 1; + authorDB.allTimeWarns += 1; + updateManyUsers([authorDB]); + + // Apply timeout if warn count is too high + if (authorDB.warns > (parseInt(process.env.MAX_WARNS) || 10)) { + try { + const member = await message.guild.members.fetch(authorId); + const time = parseInt(process.env.SPAM_TIMEOUT_TIME); + await member.timeout(time, 'Spam excessif du bot AI.'); + message.channel.send(`Ce bouffon de <@${authorId}> a été timeout pendant ${formatTime(time / 1000)}, il me cassait les couilles 🤫`).catch(console.error); + } catch (e) { + console.error('Failed to apply timeout for AI spam:', e); + message.channel.send(`<@${authorId}>, tu as de la chance que je ne puisse pas te timeout...`).catch(console.error); + } + } + return; + } + + timestamps.push(now); + requestTimestamps.set(authorId, timestamps); + + // Reset warns if user is behaving, and increment their request count + authorDB.warned = 0; + authorDB.warns = 0; + authorDB.totalRequests += 1; + updateManyUsers([authorDB]); + + + // --- AI Processing --- + try { + message.channel.sendTyping(); + // Fetch last 20 messages for context + const fetchedMessages = await message.channel.messages.fetch({ limit: 20 }); + const messagesArray = Array.from(fetchedMessages.values()).reverse(); // Oldest to newest + + const requestMessage = message.content.replace(`<@${client.user.id}>`, '').trim(); + + // Format the conversation for the AI + const messageHistory = messagesArray.map(msg => ({ + role: msg.author.id === client.user.id ? 'assistant' : 'user', + content: `<@${msg.author.id}> a dit: ${msg.content}` + })); + + // Add system prompts + messageHistory.unshift( + { role: 'system', content: "Adopte une attitude détendue de membre du serveur. Réponds comme si tu participais à la conversation, pas trop long, pas de retour à la ligne. Utilise les emojis du serveur quand c'est pertinent. Ton id est <@132380758368780288>, ton nom est FlopoBot." }, + { role: 'system', content: `L'utilisateur qui s'adresse à toi est <@${authorId}>. Son message est une réponse à ${message.mentions.repliedUser ? `<@${message.mentions.repliedUser.id}>` : 'personne'}.` } + ); + + const reply = await gork(messageHistory); + await message.reply(reply); + + } catch (err) { + console.error("Error processing AI mention:", err); + await message.reply("Oups, mon cerveau a grillé. Réessaie plus tard.").catch(console.error); + } +} + + +// --- Sub-handler for Admin Commands --- + +async function handleAdminCommands(message) { + const prefix = process.env.DEV_SITE === 'true' ? 'dev' : 'flopo'; + const [command, ...args] = message.content.split(' '); + + switch(command) { + case '?u': + console.log(await getAPOUsers()); + break; + case '?b': + console.log(await postAPOBuy('650338922874011648', args[0])); + break; + case '?v': + console.log('Active Polls:', activePolls); + break; + case '?sv': + const amount = parseInt(args[0], 10); + if (isNaN(amount)) return message.reply('Invalid amount.'); + let sum = 0; + const start_at = Date.now(); + for (let i = 0; i < amount; i++) { + sum += parseFloat(randomSkinPrice()); + } + console.log(`Result for ${amount} skins: Avg: ~${(sum / amount).toFixed(2)}€ | Total: ${sum.toFixed(2)}€ | Elapsed: ${Date.now() - start_at}ms`); + break; + case `${prefix}:sotd`: + initTodaysSOTD(); + message.reply('New Solitaire of the Day initialized.'); + break; + case `${prefix}:users`: + console.log(getAllUsers.all()); + break; + case `${prefix}:sql`: + const sqlCommand = args.join(' '); + try { + const stmt = flopoDB.prepare(sqlCommand); + const result = sqlCommand.trim().toUpperCase().startsWith('SELECT') ? stmt.all() : stmt.run(); + console.log(result); + message.reply('```json\n' + JSON.stringify(result, null, 2).substring(0, 1900) + '\n```'); + } catch (e) { + console.error(e); + message.reply(`SQL Error: ${e.message}`); + } + break; + } +} \ No newline at end of file diff --git a/src/config/commands.js b/src/config/commands.js new file mode 100644 index 0000000..07bb3ee --- /dev/null +++ b/src/config/commands.js @@ -0,0 +1,113 @@ +import 'dotenv/config'; +import { getTimesChoices } from '../game/various.js'; +import { capitalize, InstallGlobalCommands } from '../utils/index.js'; + +function createTimesChoices() { + const choices = getTimesChoices(); + const commandChoices = []; + + for (let choice of choices) { + commandChoices.push({ + name: capitalize(choice.name), + value: choice.value?.toString(), + }); + } + + return commandChoices; +} + +// Timeout vote command +const TIMEOUT_COMMAND = { + name: 'timeout', + description: 'Vote démocratique pour timeout un boug', + options: [ + { + type: 6, + name: 'akhy', + description: 'Qui ?', + required: true, + }, + { + type: 3, + name: 'temps', + description: 'Combien de temps ?', + required: true, + choices: createTimesChoices(), + } + ], + type: 1, + integration_types: [0, 1], + contexts: [0, 2], +} + +// Valorant +const VALORANT_COMMAND = { + name: 'valorant', + description: `Ouvrir une caisse valorant (15€)`, + type: 1, + integration_types: [0, 1], + contexts: [0, 2], +} + +// Own inventory command +const INVENTORY_COMMAND = { + name: 'inventory', + description: 'Voir inventaire', + options: [ + { + type: 6, + name: 'akhy', + description: 'Qui ?', + required: false, + }, + ], + type: 1, + integration_types: [0, 1], + contexts: [0, 2], +} + +const INFO_COMMAND = { + name: 'info', + description: 'Qui est time out ?', + type: 1, + integration_types: [0, 1], + contexts: [0, 2], +} + +const SKINS_COMMAND = { + name: 'skins', + description: 'Le top 10 des skins les plus chers.', + type: 1, + integration_types: [0, 1], + contexts: [0, 2], +} + +const SITE_COMMAND = { + name: 'floposite', + description: 'Lien vers FlopoSite', + type: 1, + integration_types: [0, 1], + contexts: [0, 2], +} + +const SEARCH_SKIN_COMMAND = { + name: 'search', + description: 'Chercher un skin', + options: [ + { + type: 3, + name: 'recherche', + description: 'Tu cherches quoi ?', + required: true, + }, + ], + type: 1, + integration_types: [0, 1], + contexts: [0, 2], +} + +const ALL_COMMANDS = [TIMEOUT_COMMAND, INVENTORY_COMMAND, VALORANT_COMMAND, INFO_COMMAND, SKINS_COMMAND, SEARCH_SKIN_COMMAND, SITE_COMMAND]; + +export function registerCommands() { + InstallGlobalCommands(process.env.APP_ID, ALL_COMMANDS); +} \ No newline at end of file diff --git a/src/database/index.js b/src/database/index.js new file mode 100644 index 0000000..c635ddb --- /dev/null +++ b/src/database/index.js @@ -0,0 +1,185 @@ +import Database from "better-sqlite3"; + + +export const flopoDB = new Database('flopobot.db'); + +export const stmtUsers = flopoDB.prepare(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + globalName TEXT, + warned BOOLEAN DEFAULT 0, + warns INTEGER DEFAULT 0, + allTimeWarns INTEGER DEFAULT 0, + totalRequests INTEGER DEFAULT 0, + coins INTEGER DEFAULT 0, + dailyQueried BOOLEAN DEFAULT 0 + ) +`); +stmtUsers.run(); +export const stmtSkins = flopoDB.prepare(` + CREATE TABLE IF NOT EXISTS skins ( + uuid TEXT PRIMARY KEY, + displayName TEXT, + contentTierUuid TEXT, + displayIcon TEXT, + user_id TEXT REFERENCES users, + tierRank TEXT, + tierColor TEXT, + tierText TEXT, + basePrice TEXT, + currentLvl INTEGER DEFAULT NULL, + currentChroma INTEGER DEFAULT NULL, + currentPrice INTEGER DEFAULT NULL, + maxPrice INTEGER DEFAULT NULL + ) +`); +stmtSkins.run() + +export const insertUser = flopoDB.prepare('INSERT INTO users (id, username, globalName, warned, warns, allTimeWarns, totalRequests) VALUES (@id, @username, @globalName, @warned, @warns, @allTimeWarns, @totalRequests)'); +export const updateUser = flopoDB.prepare('UPDATE users SET warned = @warned, warns = @warns, allTimeWarns = @allTimeWarns, totalRequests = @totalRequests WHERE id = @id'); +export const queryDailyReward = flopoDB.prepare(`UPDATE users SET dailyQueried = 1 WHERE id = ?`); +export const resetDailyReward = flopoDB.prepare(`UPDATE users SET dailyQueried = 0 WHERE id = ?`); +export const updateUserCoins = flopoDB.prepare('UPDATE users SET coins = @coins WHERE id = @id'); +export const getUser = flopoDB.prepare('SELECT users.*,elos.elo FROM users LEFT JOIN elos ON elos.id = users.id WHERE users.id = ?'); +export const getAllUsers = flopoDB.prepare('SELECT users.*,elos.elo FROM users LEFT JOIN elos ON elos.id = users.id ORDER BY coins DESC'); + +export const insertSkin = flopoDB.prepare('INSERT INTO skins (uuid, displayName, contentTierUuid, displayIcon, user_id, tierRank, tierColor, tierText, basePrice, currentLvl, currentChroma, currentPrice, maxPrice) VALUES (@uuid, @displayName, @contentTierUuid, @displayIcon, @user_id, @tierRank, @tierColor, @tierText, @basePrice, @currentLvl, @currentChroma, @currentPrice, @maxPrice)'); +export const updateSkin = flopoDB.prepare('UPDATE skins SET user_id = @user_id, currentLvl = @currentLvl, currentChroma = @currentChroma, currentPrice = @currentPrice WHERE uuid = @uuid'); +export const getSkin = flopoDB.prepare('SELECT * FROM skins WHERE uuid = ?'); +export const getAllSkins = flopoDB.prepare('SELECT * FROM skins ORDER BY maxPrice DESC'); +export const getAllAvailableSkins = flopoDB.prepare('SELECT * FROM skins WHERE user_id IS NULL'); +export const getUserInventory = flopoDB.prepare('SELECT * FROM skins WHERE user_id = @user_id ORDER BY currentPrice DESC'); +export const getTopSkins = flopoDB.prepare('SELECT * FROM skins ORDER BY maxPrice DESC LIMIT 10'); + +export const insertManyUsers = flopoDB.transaction(async (users) => { + for (const user of users) try { await insertUser.run(user) } catch (e) { /**/ } +}); +export const updateManyUsers = flopoDB.transaction(async (users) => { + for (const user of users) try { await updateUser.run(user) } catch (e) { console.log('user update failed') } +}); + +export const insertManySkins = flopoDB.transaction(async (skins) => { + for (const skin of skins) try { await insertSkin.run(skin) } catch (e) {} +}); +export const updateManySkins = flopoDB.transaction(async (skins) => { + for (const skin of skins) try { await updateSkin.run(skin) } catch (e) {} +}); + + +export const stmtLogs = flopoDB.prepare(` + CREATE TABLE IF NOT EXISTS logs ( + id PRIMARY KEY, + user_id TEXT REFERENCES users, + action TEXT, + target_user_id TEXT REFERENCES users, + coins_amount INTEGER, + user_new_amount INTEGER + ) +`); +stmtLogs.run() + +export const insertLog = flopoDB.prepare('INSERT INTO logs (id, user_id, action, target_user_id, coins_amount, user_new_amount) VALUES (@id, @user_id, @action, @target_user_id, @coins_amount, @user_new_amount)'); +export const getLogs = flopoDB.prepare('SELECT * FROM logs'); +export const getUserLogs = flopoDB.prepare('SELECT * FROM logs WHERE user_id = @user_id'); + + +export const stmtGames = flopoDB.prepare(` + CREATE TABLE IF NOT EXISTS games ( + id PRIMARY KEY, + p1 TEXT REFERENCES users, + p2 TEXT REFERENCES users, + p1_score INTEGER, + p2_score INTEGER, + p1_elo INTEGER, + p2_elo INTEGER, + p1_new_elo INTEGER, + p2_new_elo INTEGER, + type TEXT, + timestamp TIMESTAMP + ) +`); +stmtGames.run() + +export const insertGame = flopoDB.prepare('INSERT INTO games (id, p1, p2, p1_score, p2_score, p1_elo, p2_elo, p1_new_elo, p2_new_elo, type, timestamp) VALUES (@id, @p1, @p2, @p1_score, @p2_score, @p1_elo, @p2_elo, @p1_new_elo, @p2_new_elo, @type, @timestamp)'); +export const getGames = flopoDB.prepare('SELECT * FROM games'); +export const getUserGames = flopoDB.prepare('SELECT * FROM games WHERE p1 = @user_id OR p2 = @user_id'); + + +export const stmtElos = flopoDB.prepare(` + CREATE TABLE IF NOT EXISTS elos ( + id PRIMARY KEY REFERENCES users, + elo INTEGER + ) +`); +stmtElos.run() + +export const insertElos = flopoDB.prepare(`INSERT INTO elos (id, elo) VALUES (@id, @elo)`); +export const getElos = flopoDB.prepare(`SELECT * FROM elos`); +export const getUserElo = flopoDB.prepare(`SELECT * FROM elos WHERE id = @id`); +export const updateElo = flopoDB.prepare('UPDATE elos SET elo = @elo WHERE id = @id'); + + +export const getUsersByElo = flopoDB.prepare('SELECT * FROM users JOIN elos ON elos.id = users.id ORDER BY elos.elo DESC') + +export const stmtSOTD = flopoDB.prepare(` + CREATE TABLE IF NOT EXISTS sotd ( + id INT PRIMARY KEY, + tableauPiles TEXT, + foundationPiles TEXT, + stockPile TEXT, + wastePile TEXT, + isDone BOOLEAN DEFAULT false, + seed TEXT + ) +`); +stmtSOTD.run() + +export const getSOTD = flopoDB.prepare(`SELECT * FROM sotd WHERE id = '0'`) +export const insertSOTD = flopoDB.prepare(`INSERT INTO sotd (id, tableauPiles, foundationPiles, stockPile, wastePile, seed) VALUES (@id, @tableauPiles, @foundationPiles, @stockPile, @wastePile, @seed)`) +export const deleteSOTD = flopoDB.prepare(`DELETE FROM sotd WHERE id = '0'`) + +export const stmtSOTDStats = flopoDB.prepare(` + CREATE TABLE IF NOT EXISTS sotd_stats ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users, + time INTEGER, + moves INTEGER, + score INTEGER + ) +`); +stmtSOTDStats.run() + +export const getAllSOTDStats = flopoDB.prepare(`SELECT sotd_stats.*, users.globalName FROM sotd_stats JOIN users ON users.id = sotd_stats.user_id ORDER BY score DESC, moves ASC, time ASC`); +export const getUserSOTDStats = flopoDB.prepare(`SELECT * FROM sotd_stats WHERE user_id = ?`); +export const insertSOTDStats = flopoDB.prepare(`INSERT INTO sotd_stats (id, user_id, time, moves, score) VALUES (@id, @user_id, @time, @moves, @score)`); +export const clearSOTDStats = flopoDB.prepare(`DELETE FROM sotd_stats`); +export const deleteUserSOTDStats = flopoDB.prepare(`DELETE FROM sotd_stats WHERE user_id = ?`); + +export async function pruneOldLogs() { + const users = flopoDB.prepare(` + SELECT user_id + FROM logs + GROUP BY user_id + HAVING COUNT(*) > ${process.env.LOGS_BY_USER} + `).all(); + + const transaction = flopoDB.transaction(() => { + for (const { user_id } of users) { + flopoDB.prepare(` + DELETE FROM logs + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER (ORDER BY id DESC) AS rn + FROM logs + WHERE user_id = ? + ) + WHERE rn > ${process.env.LOGS_BY_USER} + ) + `).run(user_id); + } + }); + + transaction() +} \ No newline at end of file diff --git a/src/game/elo.js b/src/game/elo.js new file mode 100644 index 0000000..cddc8ae --- /dev/null +++ b/src/game/elo.js @@ -0,0 +1,144 @@ +import { + getUser, + getUserElo, + insertElos, + updateElo, + insertGame, +} from '../database/index.js'; + +/** + * Handles Elo calculation for a standard 1v1 game. + * @param {string} p1Id - The ID of player 1. + * @param {string} p2Id - The ID of player 2. + * @param {number} p1Score - The score for player 1 (1 for win, 0.5 for draw, 0 for loss). + * @param {number} p2Score - The score for player 2. + * @param {string} type - The type of game being played (e.g., 'TICTACTOE', 'CONNECT4'). + */ +export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type) { + // --- 1. Fetch Player Data --- + const p1DB = getUser.get(p1Id); + const p2DB = getUser.get(p2Id); + if (!p1DB || !p2DB) { + console.error(`Elo Handler: Could not find user data for ${p1Id} or ${p2Id}.`); + return; + } + + let p1EloData = getUserElo.get({ id: p1Id }); + let p2EloData = getUserElo.get({ id: p2Id }); + + // --- 2. Initialize Elo if it doesn't exist --- + if (!p1EloData) { + await insertElos.run({ id: p1Id, elo: 1000 }); + p1EloData = { id: p1Id, elo: 1000 }; + } + if (!p2EloData) { + await insertElos.run({ id: p2Id, elo: 1000 }); + p2EloData = { id: p2Id, elo: 1000 }; + } + + const p1CurrentElo = p1EloData.elo; + const p2CurrentElo = p2EloData.elo; + + // --- 3. Calculate Elo Change --- + // The K-factor determines how much the Elo rating changes after a game. + const K_FACTOR = 32; + + // Calculate expected scores + const expectedP1 = 1 / (1 + Math.pow(10, (p2CurrentElo - p1CurrentElo) / 400)); + const expectedP2 = 1 / (1 + Math.pow(10, (p1CurrentElo - p2CurrentElo) / 400)); + + // Calculate new Elo ratings + const p1NewElo = Math.round(p1CurrentElo + K_FACTOR * (p1Score - expectedP1)); + const p2NewElo = Math.round(p2CurrentElo + K_FACTOR * (p2Score - expectedP2)); + + // Ensure Elo doesn't drop below a certain threshold (e.g., 100) + const finalP1Elo = Math.max(100, p1NewElo); + const finalP2Elo = Math.max(100, p2NewElo); + + console.log(`Elo Update (${type}) for ${p1DB.globalName}: ${p1CurrentElo} -> ${finalP1Elo}`); + console.log(`Elo Update (${type}) for ${p2DB.globalName}: ${p2CurrentElo} -> ${finalP2Elo}`); + + // --- 4. Update Database --- + updateElo.run({ id: p1Id, elo: finalP1Elo }); + updateElo.run({ id: p2Id, elo: finalP2Elo }); + + insertGame.run({ + id: `${p1Id}-${p2Id}-${Date.now()}`, + p1: p1Id, + p2: p2Id, + p1_score: p1Score, + p2_score: p2Score, + p1_elo: p1CurrentElo, + p2_elo: p2CurrentElo, + p1_new_elo: finalP1Elo, + p2_new_elo: finalP2Elo, + type: type, + timestamp: Date.now(), + }); +} + +/** + * Handles Elo calculation for a multi-player poker game. + * @param {object} room - The poker room object containing player and winner info. + */ +export async function pokerEloHandler(room) { + if (room.fakeMoney) { + console.log("Skipping Elo update for fake money poker game."); + return; + } + + const playerIds = Object.keys(room.players); + if (playerIds.length < 2) return; // Not enough players to calculate Elo + + // Fetch all players' Elo data at once + const dbPlayers = playerIds.map(id => { + const user = getUser.get(id); + const elo = getUserElo.get({ id })?.elo || 1000; + return { ...user, elo }; + }); + + const winnerIds = new Set(room.winners); + const playerCount = dbPlayers.length; + const K_BASE = 16; // A lower K-factor is often used for multi-player games + + const averageElo = dbPlayers.reduce((sum, p) => sum + p.elo, 0) / playerCount; + + dbPlayers.forEach(player => { + // Expected score is the chance of winning against an "average" player from the field + const expectedScore = 1 / (1 + Math.pow(10, (averageElo - player.elo) / 400)); + + // Determine actual score + let actualScore; + if (winnerIds.has(player.id)) { + // Winners share the "win" points + actualScore = 1 / winnerIds.size; + } else { + actualScore = 0; + } + + // Dynamic K-factor: higher impact for more significant results + const kFactor = K_BASE * playerCount; + const eloChange = kFactor * (actualScore - expectedScore); + const newElo = Math.max(100, Math.round(player.elo + eloChange)); + + if (!isNaN(newElo)) { + console.log(`Elo Update (POKER) for ${player.globalName}: ${player.elo} -> ${newElo} (Δ: ${eloChange.toFixed(2)})`); + updateElo.run({ id: player.id, elo: newElo }); + + insertGame.run({ + id: `${player.id}-poker-${Date.now()}`, + p1: player.id, + p2: null, // No single opponent + p1_score: actualScore, + p2_score: null, + p1_elo: player.elo, + p2_elo: Math.round(averageElo), // Log the average opponent Elo for context + p1_new_elo: newElo, + p2_new_elo: null, + type: 'POKER_ROUND', + }); + } else { + console.error(`Error calculating new Elo for ${player.globalName}.`); + } + }); +} \ No newline at end of file diff --git a/src/game/points.js b/src/game/points.js new file mode 100644 index 0000000..29638e6 --- /dev/null +++ b/src/game/points.js @@ -0,0 +1,191 @@ +import { + getUser, + updateUserCoins, + insertLog, + getAllSkins, + insertSOTD, + clearSOTDStats, + getAllSOTDStats, +} from '../database/index.js'; +import { messagesTimestamps, activeSlowmodes, skins } from './state.js'; +import { deal, createSeededRNG, seededShuffle, createDeck } from './solitaire.js'; + +/** + * Handles awarding points (coins) to users for their message activity. + * Limits points to 10 messages within a 15-minute window. + * @param {object} message - The Discord.js message object. + * @returns {boolean} True if points were awarded, false otherwise. + */ +export async function channelPointsHandler(message) { + const author = message.author; + const authorDB = getUser.get(author.id); + + if (!authorDB) { + // User not in our database, do nothing. + return false; + } + + // Ignore short messages or commands that might be spammed + if (message.content.length < 3 || message.content.startsWith('?')) { + return false; + } + + const now = Date.now(); + const userTimestamps = messagesTimestamps.get(author.id) || []; + + // Filter out timestamps older than 15 minutes (900,000 ms) + const recentTimestamps = userTimestamps.filter(ts => now - ts < 900000); + + // If the user has already sent 10 messages in the last 15 mins, do nothing + if (recentTimestamps.length >= 10) { + return false; + } + + // Add the new message timestamp + recentTimestamps.push(now); + messagesTimestamps.set(author.id, recentTimestamps); + + // Award 50 coins for the 10th message, 10 for others + const coinsToAdd = recentTimestamps.length === 10 ? 50 : 10; + const newCoinTotal = authorDB.coins + coinsToAdd; + + updateUserCoins.run({ + id: author.id, + coins: newCoinTotal, + }); + + insertLog.run({ + id: `${author.id}-${now}`, + user_id: author.id, + action: 'AUTO_COINS', + target_user_id: null, + coins_amount: coinsToAdd, + user_new_amount: newCoinTotal, + }); + + return true; // Indicate that points were awarded +} + +/** + * Handles message deletion for users currently under a slowmode effect. + * @param {object} message - The Discord.js message object. + * @returns {object} An object indicating if a message was deleted or a slowmode expired. + */ +export async function slowmodesHandler(message) { + const author = message.author; + const authorSlowmode = activeSlowmodes[author.id]; + + if (!authorSlowmode) { + return { deleted: false, expired: false }; + } + + const now = Date.now(); + + // Check if the slowmode duration has passed + if (now > authorSlowmode.endAt) { + console.log(`Slowmode for ${author.username} has expired.`); + delete activeSlowmodes[author.id]; + return { deleted: false, expired: true }; + } + + // Check if the user is messaging too quickly (less than 1 minute between messages) + if (authorSlowmode.lastMessage && (now - authorSlowmode.lastMessage < 60 * 1000)) { + try { + await message.delete(); + console.log(`Deleted a message from slowmoded user: ${author.username}`); + return { deleted: true, expired: false }; + } catch (err) { + console.error(`Failed to delete slowmode message:`, err); + return { deleted: false, expired: false }; + } + } else { + // Update the last message timestamp for the user + authorSlowmode.lastMessage = now; + return { deleted: false, expired: false }; + } +} + +/** + * Calculates a random price for a skin based on its properties. + * Used for testing and simulations. + * @returns {string} The calculated random price as a string. + */ +export function randomSkinPrice() { + const dbSkins = getAllSkins.all(); + if (dbSkins.length === 0) return '0.00'; + + const randomDbSkin = dbSkins[Math.floor(Math.random() * dbSkins.length)]; + const randomSkinData = skins.find((skin) => skin.uuid === randomDbSkin.uuid); + + if (!randomSkinData) return '0.00'; + + // Generate random level and chroma + const randomLevel = Math.floor(Math.random() * randomSkinData.levels.length) + 1; + let randomChroma = 1; + if (randomLevel === randomSkinData.levels.length && randomSkinData.chromas.length > 1) { + randomChroma = Math.floor(Math.random() * randomSkinData.chromas.length) + 1; + } + + // Calculate price based on these random values + let result = parseFloat(randomDbSkin.basePrice); + result *= (1 + (randomLevel / Math.max(randomSkinData.levels.length, 2))); + result *= (1 + (randomChroma / 4)); + + return result.toFixed(2); +} + +/** + * Initializes the Solitaire of the Day. + * This function clears previous stats, awards the winner, and generates a new daily seed. + */ +export function initTodaysSOTD() { + console.log('Initializing new Solitaire of the Day...'); + + // 1. Award previous day's winner + const rankings = getAllSOTDStats.all(); + if (rankings.length > 0) { + const winnerId = rankings[0].user_id; + const winnerUser = getUser.get(winnerId); + + if (winnerUser) { + const reward = 1000; + const newCoinTotal = winnerUser.coins + reward; + updateUserCoins.run({ id: winnerId, coins: newCoinTotal }); + insertLog.run({ + id: `${winnerId}-sotd-win-${Date.now()}`, + user_id: winnerId, + action: 'SOTD_FIRST_PLACE', + coins_amount: reward, + user_new_amount: newCoinTotal, + }); + console.log(`${winnerUser.globalName || winnerUser.username} won the previous SOTD and received ${reward} coins.`); + } + } + + // 2. Generate a new seeded deck for today + const newRandomSeed = Date.now().toString(36) + Math.random().toString(36).substr(2); + let numericSeed = 0; + for (let i = 0; i < newRandomSeed.length; i++) { + numericSeed = (numericSeed + newRandomSeed.charCodeAt(i)) & 0xFFFFFFFF; + } + + const rng = createSeededRNG(numericSeed); + const deck = createDeck(); + const shuffledDeck = seededShuffle(deck, rng); + const todaysSOTD = deal(shuffledDeck); + + // 3. Clear old stats and save the new game state to the database + try { + clearSOTDStats.run(); + insertSOTD.run({ + tableauPiles: JSON.stringify(todaysSOTD.tableauPiles), + foundationPiles: JSON.stringify(todaysSOTD.foundationPiles), + stockPile: JSON.stringify(todaysSOTD.stockPile), + wastePile: JSON.stringify(todaysSOTD.wastePile), + seed: newRandomSeed, + }); + console.log("Today's SOTD is ready with a new seed."); + } catch(e) { + console.error("Error saving new SOTD to database:", e); + } +} \ No newline at end of file diff --git a/src/game/poker.js b/src/game/poker.js new file mode 100644 index 0000000..1a26263 --- /dev/null +++ b/src/game/poker.js @@ -0,0 +1,139 @@ +import pkg from 'pokersolver'; +const { Hand } = pkg; + +// An array of all 52 standard playing cards. +export const initialCards = [ + 'Ad', '2d', '3d', '4d', '5d', '6d', '7d', '8d', '9d', 'Td', 'Jd', 'Qd', 'Kd', + 'As', '2s', '3s', '4s', '5s', '6s', '7s', '8s', '9s', 'Ts', 'Js', 'Qs', 'Ks', + 'Ac', '2c', '3c', '4c', '5c', '6c', '7c', '8c', '9c', 'Tc', 'Jc', 'Qc', 'Kc', + 'Ah', '2h', '3h', '4h', '5h', '6h', '7h', '8h', '9h', 'Th', 'Jh', 'Qh', 'Kh', +]; + +/** + * Creates a shuffled copy of the initial card deck. + * @returns {Array} A new array containing all 52 cards in a random order. + */ +export function initialShuffledCards() { + // Create a copy and sort it randomly + return [...initialCards].sort(() => 0.5 - Math.random()); +} + +/** + * Finds the first active player to act after the dealer. + * This is used to start betting rounds after the flop, turn, and river. + * @param {object} room - The poker room object. + * @returns {string|null} The ID of the next player, or null if none is found. + */ +export function getFirstActivePlayerAfterDealer(room) { + const players = Object.values(room.players); + const dealerPosition = players.findIndex((p) => p.id === room.dealer); + + // Loop through players starting from the one after the dealer + for (let i = 1; i <= players.length; i++) { + const nextPos = (dealerPosition + i) % players.length; + const nextPlayer = players[nextPos]; + // Player must not be folded or all-in to be able to act + if (nextPlayer && !nextPlayer.folded && !nextPlayer.allin) { + return nextPlayer.id; + } + } + return null; // Should not happen in a normal game +} + +/** + * Finds the next active player in turn order. + * @param {object} room - The poker room object. + * @returns {string|null} The ID of the next player, or null if none is found. + */ +export function getNextActivePlayer(room) { + const players = Object.values(room.players); + const currentPlayerPosition = players.findIndex((p) => p.id === room.current_player); + + // Loop through players starting from the one after the current player + for (let i = 1; i <= players.length; i++) { + const nextPos = (currentPlayerPosition + i) % players.length; + const nextPlayer = players[nextPos]; + if (nextPlayer && !nextPlayer.folded && !nextPlayer.allin) { + return nextPlayer.id; + } + } + return null; +} + +/** + * Checks if the current betting round should end and what the next phase should be. + * @param {object} room - The poker room object. + * @returns {object} An object with `endRound`, `winner`, and `nextPhase` properties. + */ +export function checkEndOfBettingRound(room) { + const activePlayers = Object.values(room.players).filter((p) => !p.folded); + + // --- Scenario 1: Only one player left (everyone else folded) --- + if (activePlayers.length === 1) { + return { endRound: true, winner: activePlayers[0].id, nextPhase: 'showdown' }; + } + + // --- Scenario 2: All remaining players are all-in --- + // The hand goes immediately to a "progressive showdown". + const allInPlayers = activePlayers.filter(p => p.allin); + if (allInPlayers.length >= 2 && allInPlayers.length === activePlayers.length) { + return { endRound: true, winner: null, nextPhase: 'progressive-showdown' }; + } + + // --- Scenario 3: All active players have acted and bets are equal --- + const allBetsMatched = activePlayers.every(p => + p.allin || // Player is all-in + (p.bet === room.highest_bet && p.last_played_turn === room.current_turn) // Or their bet matches the highest and they've acted this turn + ); + + if (allBetsMatched) { + let nextPhase; + switch (room.current_turn) { + case 0: nextPhase = 'flop'; break; + case 1: nextPhase = 'turn'; break; + case 2: nextPhase = 'river'; break; + case 3: nextPhase = 'showdown'; break; + default: nextPhase = null; // Should not happen + } + return { endRound: true, winner: null, nextPhase: nextPhase }; + } + + // --- Default: The round continues --- + return { endRound: false, winner: null, nextPhase: null }; +} + +/** + * Determines the winner(s) of the hand at showdown. + * @param {object} room - The poker room object. + * @returns {Array} An array of winner IDs. Can contain multiple IDs in case of a split pot. + */ +export function checkRoomWinners(room) { + const communityCards = room.tapis; + const activePlayers = Object.values(room.players).filter(p => !p.folded); + + // Solve each player's hand to find the best possible 5-card combination + const playerSolutions = activePlayers.map(player => ({ + id: player.id, + solution: Hand.solve([...communityCards, ...player.hand]), + })); + + if (playerSolutions.length === 0) return []; + + // Use pokersolver's `Hand.winners()` to find the best hand(s) + const winningSolutions = Hand.winners(playerSolutions.map(ps => ps.solution)); + + // Find the player IDs that correspond to the winning hand solutions + const winnerIds = []; + for (const winningHand of winningSolutions) { + for (const playerSol of playerSolutions) { + // Compare description and card pool to uniquely identify the hand + if (playerSol.solution.descr === winningHand.descr && playerSol.solution.cardPool.toString() === winningHand.cardPool.toString()) { + if (!winnerIds.includes(playerSol.id)) { + winnerIds.push(playerSol.id); + } + } + } + } + + return winnerIds; +} \ No newline at end of file diff --git a/src/game/solitaire.js b/src/game/solitaire.js new file mode 100644 index 0000000..50e8250 --- /dev/null +++ b/src/game/solitaire.js @@ -0,0 +1,246 @@ +// --- Constants for Deck Creation --- +const SUITS = ['h', 'd', 's', 'c']; // Hearts, Diamonds, Spades, Clubs +const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K']; + +// --- Helper Functions for Card Logic --- + +/** + * Gets the numerical value of a card's rank for comparison. + * @param {string} rank - e.g., 'A', 'K', '7' + * @returns {number} The numeric value (Ace=1, King=13). + */ +function getRankValue(rank) { + if (rank === 'A') return 1; + if (rank === 'T') return 10; + if (rank === 'J') return 11; + if (rank === 'Q') return 12; + if (rank === 'K') return 13; + return parseInt(rank, 10); +} + +/** + * Gets the color ('red' or 'black') of a card's suit. + * @param {string} suit - e.g., 'h', 's' + * @returns {string} 'red' or 'black'. + */ +function getCardColor(suit) { + return (suit === 'h' || suit === 'd') ? 'red' : 'black'; +} + + +// --- Core Game Logic Functions --- + +/** + * Creates a standard 52-card deck. Each card is an object. + * @returns {Array} The unshuffled deck of cards. + */ +export function createDeck() { + const deck = []; + for (const suit of SUITS) { + for (const rank of RANKS) { + deck.push({ suit, rank, faceUp: false }); + } + } + return deck; +} + +/** + * Shuffles an array in place using the Fisher-Yates algorithm. + * @param {Array} array - The array to shuffle. + * @returns {Array} The shuffled array (mutated in place). + */ +export function shuffle(array) { + let currentIndex = array.length; + // While there remain elements to shuffle. + while (currentIndex !== 0) { + // Pick a remaining element. + const randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]]; + } + return array; +} + +/** + * Creates a seedable pseudorandom number generator (PRNG) using Mulberry32. + * @param {number} seed - An initial number to seed the generator. + * @returns {function} A function that returns a pseudorandom number between 0 and 1. + */ +export function createSeededRNG(seed) { + return function() { + let t = seed += 0x6D2B79F5; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +/** + * Shuffles an array using a seedable PRNG via the Fisher-Yates algorithm. + * @param {Array} array - The array to shuffle. + * @param {function} rng - A seedable random number generator function. + * @returns {Array} The shuffled array (mutated in place). + */ +export function seededShuffle(array, rng) { + let currentIndex = array.length; + // While there remain elements to shuffle. + while (currentIndex !== 0) { + // Pick a remaining element using the seeded RNG. + const randomIndex = Math.floor(rng() * currentIndex); + currentIndex--; + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]]; + } + return array; +} + +/** + * Deals a shuffled deck into the initial Solitaire game state. + * @param {Array} deck - A shuffled deck of cards. + * @returns {Object} The initial gameState object for Klondike Solitaire. + */ +export function deal(deck) { + const gameState = { + tableauPiles: [[], [], [], [], [], [], []], + foundationPiles: [[], [], [], []], + stockPile: [], + wastePile: [], + }; + + // Deal cards to the 7 tableau piles + for (let i = 0; i < 7; i++) { + for (let j = i; j < 7; j++) { + gameState.tableauPiles[j].push(deck.shift()); + } + } + + // Flip the top card of each tableau pile + gameState.tableauPiles.forEach(pile => { + if (pile.length > 0) { + pile[pile.length - 1].faceUp = true; + } + }); + + // The rest of the deck becomes the stock + gameState.stockPile = deck; + + return gameState; +} + +/** + * Checks if a proposed move is valid according to the rules of Klondike Solitaire. + * @param {Object} gameState - The current state of the game. + * @param {Object} moveData - The details of the move to be validated. + * @returns {boolean} True if the move is valid, false otherwise. + */ +export function isValidMove(gameState, moveData) { + const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex } = moveData; + + // --- Get Source Pile and Card --- + let sourcePile; + if (sourcePileType === 'tableauPiles') sourcePile = gameState.tableauPiles[sourcePileIndex]; + else if (sourcePileType === 'wastePile') sourcePile = gameState.wastePile; + else if (sourcePileType === 'foundationPiles') sourcePile = gameState.foundationPiles[sourcePileIndex]; + else return false; // Invalid source type + + const sourceCard = sourcePile?.[sourceCardIndex]; + if (!sourceCard || !sourceCard.faceUp) { + return false; // Cannot move a card that doesn't exist or is face-down + } + + // --- Validate Move TO a Tableau Pile --- + if (destPileType === 'tableauPiles') { + const destinationPile = gameState.tableauPiles[destPileIndex]; + const topCard = destinationPile[destinationPile.length - 1]; + + if (!topCard) { + // If the destination tableau is empty, only a King can be moved there. + return sourceCard.rank === 'K'; + } + + // Card must be opposite color and one rank lower than the destination top card. + const sourceColor = getCardColor(sourceCard.suit); + const destColor = getCardColor(topCard.suit); + const sourceValue = getRankValue(sourceCard.rank); + const destValue = getRankValue(topCard.rank); + return sourceColor !== destColor && destValue - sourceValue === 1; + } + + // --- Validate Move TO a Foundation Pile --- + if (destPileType === 'foundationPiles') { + // You can only move one card at a time to a foundation pile. + const stackBeingMoved = sourcePile.slice(sourceCardIndex); + if (stackBeingMoved.length > 1) return false; + + const destinationPile = gameState.foundationPiles[destPileIndex]; + const topCard = destinationPile[destinationPile.length - 1]; + + if (!topCard) { + // If the foundation is empty, only an Ace of any suit can be moved there. + return sourceCard.rank === 'A'; + } + + // Card must be the same suit and one rank higher. + const sourceValue = getRankValue(sourceCard.rank); + const destValue = getRankValue(topCard.rank); + return sourceCard.suit === topCard.suit && sourceValue - destValue === 1; + } + + return false; // Invalid destination type +} + +/** + * Mutates the game state by performing a valid card move. + * @param {Object} gameState - The current state of the game. + * @param {Object} moveData - The details of the move. + */ +export function moveCard(gameState, moveData) { + const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex } = moveData; + + let sourcePile; + if (sourcePileType === 'tableauPiles') sourcePile = gameState.tableauPiles[sourcePileIndex]; + else if (sourcePileType === 'wastePile') sourcePile = gameState.wastePile; + else if (sourcePileType === 'foundationPiles') sourcePile = gameState.foundationPiles[sourcePileIndex]; + + let destPile; + if (destPileType === 'tableauPiles') destPile = gameState.tableauPiles[destPileIndex]; + else if (destPileType === 'foundationPiles') destPile = gameState.foundationPiles[destPileIndex]; + + // Cut the entire stack of cards to be moved from the source pile. + const cardsToMove = sourcePile.splice(sourceCardIndex); + // Add the stack to the destination pile. + destPile.push(...cardsToMove); + + // If the source was a tableau pile and there are cards left, flip the new top card. + if (sourcePileType === 'tableauPiles' && sourcePile.length > 0) { + sourcePile[sourcePile.length - 1].faceUp = true; + } +} + +/** + * Moves a card from the stock to the waste. If stock is empty, resets it from the waste. + * @param {Object} gameState - The current state of the game. + */ +export function drawCard(gameState) { + if (gameState.stockPile.length > 0) { + const card = gameState.stockPile.pop(); + card.faceUp = true; + gameState.wastePile.push(card); + } else if (gameState.wastePile.length > 0) { + // When stock is empty, move the entire waste pile back to stock, face down. + gameState.stockPile = gameState.wastePile.reverse(); + gameState.stockPile.forEach(card => (card.faceUp = false)); + gameState.wastePile = []; + } +} + +/** + * Checks if the game has been won (all 52 cards are in the foundation piles). + * @param {Object} gameState - The current state of the game. + * @returns {boolean} True if the game is won. + */ +export function checkWinCondition(gameState) { + const foundationCardCount = gameState.foundationPiles.reduce((acc, pile) => acc + pile.length, 0); + return foundationCardCount === 52; +} \ No newline at end of file diff --git a/src/game/state.js b/src/game/state.js new file mode 100644 index 0000000..23df3e2 --- /dev/null +++ b/src/game/state.js @@ -0,0 +1,68 @@ +/** + * This file acts as a simple in-memory store for the application's live state. + * By centralizing state here, we avoid global variables and make data flow more predictable. + */ + +// --- Game and Interaction State --- + +// Stores active Connect 4 games, keyed by a unique game ID. +export let activeConnect4Games = {}; + +// Stores active Tic-Tac-Toe games, keyed by a unique game ID. +export let activeTicTacToeGames = {}; + +// Stores active Solitaire games, keyed by user ID. +export let activeSolitaireGames = {}; + +// Stores active Poker rooms, keyed by a unique room ID (uuidv4). +export let pokerRooms = {}; + +// --- User and Session State --- + +// Stores active user inventories for paginated embeds, keyed by the interaction ID. +// Format: { [interactionId]: { userId, page, amount, endpoint, timestamp, inventorySkins } } +export let activeInventories = {}; + +// Stores active user skin searches for paginated embeds, keyed by the interaction ID. +// Format: { [interactionId]: { userId, page, amount, endpoint, timestamp, resultSkins, searchValue } } +export let activeSearchs = {}; + +// --- Feature-Specific State --- + +// Stores active timeout polls, keyed by the interaction ID. +// Format: { [interactionId]: { toUserId, time, for, against, voters, endTime, ... } } +export let activePolls = {}; + +// Stores active predictions, keyed by a unique prediction ID. +// Format: { [prediId]: { creatorId, label, options, endTime, closed, ... } } +export let activePredis = {}; + +// Stores users who are currently under a slowmode effect, keyed by user ID. +// Format: { [userId]: { endAt, lastMessage } } +export let activeSlowmodes = {}; + + +// --- Queues for Matchmaking --- + +// Stores user IDs waiting to play Tic-Tac-Toe. +export let tictactoeQueue = []; + +// Stores user IDs waiting to play Connect 4. +export let connect4Queue = []; + + +// --- Rate Limiting and Caching --- + +// Tracks message timestamps for the channel points system, keyed by user ID. +// Used to limit points earned over a 15-minute window. +// Format: Map +export let messagesTimestamps = new Map(); + +// Tracks recent AI mention requests for rate limiting, keyed by user ID. +// Used to prevent spamming the AI. +// Format: Map +export let requestTimestamps = new Map(); + +// In-memory cache for Valorant skin data fetched from the API. +// This prevents re-fetching the same data on every command use. +export let skins = []; \ No newline at end of file diff --git a/src/game/various.js b/src/game/various.js new file mode 100644 index 0000000..c7d4e6a --- /dev/null +++ b/src/game/various.js @@ -0,0 +1,109 @@ +// --- Constants for Games --- +export const C4_ROWS = 6; +export const C4_COLS = 7; + +// A predefined list of choices for the /timeout command's duration option. +const TimesChoices = [ + { name: '1 minute', value: 60 }, + { name: '5 minutes', value: 300 }, + { name: '10 minutes', value: 600 }, + { name: '15 minutes', value: 900 }, + { name: '30 minutes', value: 1800 }, + { name: '1 heure', value: 3600 }, + { name: '2 heures', value: 7200 }, + { name: '3 heures', value: 10800 }, + { name: '6 heures', value: 21600 }, + { name: '9 heures', value: 32400 }, + { name: '12 heures', value: 43200 }, + { name: '16 heures', value: 57600 }, + { name: '1 jour', value: 86400 }, +]; + +/** + * Returns the array of time choices for use in command definitions. + * @returns {Array} The array of time choices. + */ +export function getTimesChoices() { + return TimesChoices; +} + + +// --- Connect 4 Logic --- + +/** + * Creates a new, empty Connect 4 game board. + * @returns {Array>} A 2D array representing the board. + */ +export function createConnect4Board() { + return Array(C4_ROWS).fill(null).map(() => Array(C4_COLS).fill(null)); +} + +/** + * Checks if a player has won the Connect 4 game. + * @param {Array>} board - The game board. + * @param {string} player - The player's symbol ('R' or 'Y'). + * @returns {object} An object with `win` (boolean) and `pieces` (array of winning piece coordinates). + */ +export function checkConnect4Win(board, player) { + // Check horizontal + for (let r = 0; r < C4_ROWS; r++) { + for (let c = 0; c <= C4_COLS - 4; c++) { + if (board[r][c] === player && board[r][c+1] === player && board[r][c+2] === player && board[r][c+3] === player) { + return { win: true, pieces: [{row:r, col:c}, {row:r, col:c+1}, {row:r, col:c+2}, {row:r, col:c+3}] }; + } + } + } + + // Check vertical + for (let r = 0; r <= C4_ROWS - 4; r++) { + for (let c = 0; c < C4_COLS; c++) { + if (board[r][c] === player && board[r+1][c] === player && board[r+2][c] === player && board[r+3][c] === player) { + return { win: true, pieces: [{row:r, col:c}, {row:r+1, col:c}, {row:r+2, col:c}, {row:r+3, col:c}] }; + } + } + } + + // Check diagonal (down-right) + for (let r = 0; r <= C4_ROWS - 4; r++) { + for (let c = 0; c <= C4_COLS - 4; c++) { + if (board[r][c] === player && board[r+1][c+1] === player && board[r+2][c+2] === player && board[r+3][c+3] === player) { + return { win: true, pieces: [{row:r, col:c}, {row:r+1, col:c+1}, {row:r+2, col:c+2}, {row:r+3, col:c+3}] }; + } + } + } + + // Check diagonal (up-right) + for (let r = 3; r < C4_ROWS; r++) { + for (let c = 0; c <= C4_COLS - 4; c++) { + if (board[r][c] === player && board[r-1][c+1] === player && board[r-2][c+2] === player && board[r-3][c+3] === player) { + return { win: true, pieces: [{row:r, col:c}, {row:r-1, col:c+1}, {row:r-2, col:c+2}, {row:r-3, col:c+3}] }; + } + } + } + + return { win: false, pieces: [] }; +} + +/** + * Checks if the Connect 4 game is a draw (the board is full). + * @param {Array>} board - The game board. + * @returns {boolean} True if the game is a draw. + */ +export function checkConnect4Draw(board) { + // A draw occurs if the top row is completely full. + return board[0].every(cell => cell !== null); +} + +/** + * Formats a Connect 4 board into a string with emojis for Discord display. + * @param {Array>} board - The game board. + * @returns {string} The formatted string representation of the board. + */ +export function formatConnect4BoardForDiscord(board) { + const symbols = { + 'R': '🔴', + 'Y': '🟡', + null: '⚪' // Using a white circle for empty slots + }; + return board.map(row => row.map(cell => symbols[cell]).join('')).join('\n'); +} \ No newline at end of file diff --git a/src/server/app.js b/src/server/app.js new file mode 100644 index 0000000..13bd90a --- /dev/null +++ b/src/server/app.js @@ -0,0 +1,51 @@ +import 'dotenv/config'; +import express from 'express'; +import { verifyKeyMiddleware } from 'discord-interactions'; +import { handleInteraction } from '../bot/handlers/interactionCreate.js'; +import { client } from '../bot/client.js'; + +// Import route handlers +import { apiRoutes } from './routes/api.js'; +import { pokerRoutes } from './routes/poker.js'; +import { solitaireRoutes } from './routes/solitaire.js'; + +// --- EXPRESS APP INITIALIZATION --- +const app = express(); +const FLAPI_URL = process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL; + +// --- GLOBAL MIDDLEWARE --- + +// CORS Middleware +app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', FLAPI_URL); + res.header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, ngrok-skip-browser-warning'); + next(); +}); + +// --- PRIMARY DISCORD INTERACTION ENDPOINT --- +// This endpoint handles all interactions sent from Discord (slash commands, buttons, etc.) +app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), async (req, res) => { + // The actual logic is delegated to a dedicated handler for better organization + await handleInteraction(req, res, client); +}); + +// JSON Body Parser Middleware +app.use(express.json()); + +// --- STATIC ASSETS --- +app.use('/public', express.static('public')); + + +// --- API ROUTES --- + +// General API routes (users, polls, etc.) +app.use('/', apiRoutes(client)); + +// Poker-specific routes +app.use('/poker-room', pokerRoutes(client)); + +// Solitaire-specific routes +app.use('/solitaire', solitaireRoutes(client)); + + +export { app }; \ No newline at end of file diff --git a/src/server/routes/api.js b/src/server/routes/api.js new file mode 100644 index 0000000..5d13b60 --- /dev/null +++ b/src/server/routes/api.js @@ -0,0 +1,250 @@ +import express from 'express'; +import { sleep } from 'openai/core'; + +// --- Database Imports --- +import { + getAllUsers, getUsersByElo, pruneOldLogs, getLogs, getUser, + getUserLogs, getUserElo, getUserGames, getUserInventory, + queryDailyReward, updateUserCoins, insertLog, +} from '../../database/index.js'; + +// --- Game State Imports --- +import { activePolls, activeSlowmodes, activePredis } from '../../game/state.js'; + +// --- Utility and API Imports --- +import { getOnlineUsersWithRole } from '../../utils/index.js'; +import { DiscordRequest } from '../../api/discord.js'; + +// --- Discord.js Builder Imports --- +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; + +// Create a new router instance +const router = express.Router(); + +/** + * Factory function to create and configure the main API routes. + * @param {object} client - The Discord.js client instance. + * @param {object} io - The Socket.IO server instance. + * @returns {object} The configured Express router. + */ +export function apiRoutes(client, io) { + // --- Server Health & Basic Data --- + + router.get('/check', (req, res) => { + res.status(200).json({ status: 'OK', message: 'FlopoBot API is running.' }); + }); + + router.get('/users', (req, res) => { + try { + const users = getAllUsers.all(); + res.json(users); + } catch (error) { + console.error("Error fetching users:", error); + res.status(500).json({ error: 'Failed to fetch users.' }); + } + }); + + router.get('/users/by-elo', (req, res) => { + try { + const users = getUsersByElo.all(); + res.json(users); + } catch (error) { + console.error("Error fetching users by Elo:", error); + res.status(500).json({ error: 'Failed to fetch users by Elo.' }); + } + }); + + router.get('/logs', async (req, res) => { + try { + await pruneOldLogs(); + const logs = getLogs.all(); + res.status(200).json(logs); + } catch (error) { + console.error("Error fetching logs:", error); + res.status(500).json({ error: 'Failed to fetch logs.' }); + } + }); + + // --- User-Specific Routes --- + + router.get('/user/:id/avatar', async (req, res) => { + try { + const user = await client.users.fetch(req.params.id); + const avatarUrl = user.displayAvatarURL({ format: 'png', size: 256 }); + res.json({ avatarUrl }); + } catch (error) { + res.status(404).json({ error: 'User not found or failed to fetch avatar.' }); + } + }); + + router.get('/user/:id/username', async (req, res) => { + try { + const user = await client.users.fetch(req.params.id); + res.json({ user }); + } catch (error) { + res.status(404).json({ error: 'User not found.' }); + } + }); + + router.get('/user/:id/sparkline', (req, res) => { + try { + const logs = getUserLogs.all({ user_id: req.params.id }); + res.json({ sparkline: logs }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch logs for sparkline.' }); + } + }); + + router.get('/user/:id/elo', (req, res) => { + try { + const eloData = getUserElo.get({ id: req.params.id }); + res.json({ elo: eloData?.elo || null }); + } catch(e) { + res.status(500).json({ error: 'Failed to fetch Elo data.' }); + } + }); + + router.get('/user/:id/elo-graph', (req, res) => { + try { + const games = getUserGames.all({ user_id: req.params.id }); + const eloHistory = games.map(game => game.p1 === req.params.id ? game.p1_new_elo : game.p2_new_elo); + res.json({ elo_graph: eloHistory }); + } catch (e) { + res.status(500).json({ error: 'Failed to generate Elo graph.' }); + } + }); + + router.get('/user/:id/inventory', (req, res) => { + try { + const inventory = getUserInventory.all({ user_id: req.params.id }); + res.json({ inventory }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch inventory.' }); + } + }); + + router.post('/user/:id/daily', (req, res) => { + const { id } = req.params; + try { + const akhy = getUser.get(id); + if (!akhy) return res.status(404).json({ message: 'Utilisateur introuvable' }); + if (akhy.dailyQueried) return res.status(403).json({ message: 'Récompense journalière déjà récupérée.' }); + + const amount = 200; + const newCoins = akhy.coins + amount; + queryDailyReward.run(id); + updateUserCoins.run({ id, coins: newCoins }); + insertLog.run({ + id: `${id}-daily-${Date.now()}`, user_id: id, action: 'DAILY_REWARD', + coins_amount: amount, user_new_amount: newCoins, + }); + + io.emit('data-updated', { table: 'users' }); + res.status(200).json({ message: `+${amount} FlopoCoins! Récompense récupérée !` }); + } catch (error) { + res.status(500).json({ error: "Failed to process daily reward." }); + } + }); + + // --- Poll & Timeout Routes --- + + router.get('/polls', (req, res) => { + res.json({ activePolls }); + }); + + router.post('/timedout', async (req, res) => { + try { + const { userId } = req.body; + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const member = await guild.members.fetch(userId); + res.status(200).json({ isTimedOut: member?.isCommunicationDisabled() || false }); + } catch (e) { + res.status(404).send({ message: 'Member not found or guild unavailable.' }); + } + }); + + // --- Shop & Interaction Routes --- + + router.post('/change-nickname', async (req, res) => { + const { userId, nickname, commandUserId } = req.body; + const commandUser = getUser.get(commandUserId); + if (!commandUser) return res.status(404).json({ message: 'Command user not found.' }); + if (commandUser.coins < 1000) return res.status(403).json({ message: 'Pas assez de FlopoCoins (1000 requis).' }); + + try { + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const member = await guild.members.fetch(userId); + await member.setNickname(nickname); + + const newCoins = commandUser.coins - 1000; + updateUserCoins.run({ id: commandUserId, coins: newCoins }); + insertLog.run({ + id: `${commandUserId}-changenick-${Date.now()}`, user_id: commandUserId, action: 'CHANGE_NICKNAME', + target_user_id: userId, coins_amount: -1000, user_new_amount: newCoins, + }); + + io.emit('data-updated', { table: 'users' }); + res.status(200).json({ message: `Le pseudo de ${member.user.username} a été changé.` }); + } catch (error) { + res.status(500).json({ message: `Erreur: Impossible de changer le pseudo.` }); + } + }); + + router.post('/spam-ping', async (req, res) => { + // Implement spam-ping logic here... + res.status(501).json({ message: "Not Implemented" }); + }); + + // --- Slowmode Routes --- + + router.get('/slowmodes', (req, res) => { + res.status(200).json({ slowmodes: activeSlowmodes }); + }); + + router.post('/slowmode', (req, res) => { + // Implement slowmode logic here... + res.status(501).json({ message: "Not Implemented" }); + }); + + // --- Prediction Routes --- + + router.get('/predis', (req, res) => { + const reversedPredis = Object.fromEntries(Object.entries(activePredis).reverse()); + res.status(200).json({ predis: reversedPredis }); + }); + + router.post('/start-predi', async (req, res) => { + // Implement prediction start logic here... + res.status(501).json({ message: "Not Implemented" }); + }); + + router.post('/vote-predi', (req, res) => { + // Implement prediction vote logic here... + res.status(501).json({ message: "Not Implemented" }); + }); + + router.post('/end-predi', (req, res) => { + // Implement prediction end logic here... + res.status(501).json({ message: "Not Implemented" }); + }); + + // --- Admin Routes --- + + router.post('/buy-coins', (req, res) => { + const { commandUserId, coins } = req.body; + const user = getUser.get(commandUserId); + if (!user) return res.status(404).json({ error: 'User not found' }); + + const newCoins = user.coins + coins; + updateUserCoins.run({ id: commandUserId, coins: newCoins }); + insertLog.run({ + id: `${commandUserId}-buycoins-${Date.now()}`, user_id: commandUserId, action: 'BUY_COINS_ADMIN', + coins_amount: coins, user_new_amount: newCoins + }); + + io.emit('data-updated', { table: 'users' }); + res.status(200).json({ message: `Added ${coins} coins.` }); + }); + + return router; +} \ No newline at end of file diff --git a/src/server/routes/poker.js b/src/server/routes/poker.js new file mode 100644 index 0000000..c10f0b4 --- /dev/null +++ b/src/server/routes/poker.js @@ -0,0 +1,316 @@ +import express from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { uniqueNamesGenerator, adjectives } from 'unique-names-generator'; + +import { pokerRooms } from '../../game/state.js'; +import { initialShuffledCards, getFirstActivePlayerAfterDealer, getNextActivePlayer, checkEndOfBettingRound, checkRoomWinners } from '../../game/poker.js'; +import { pokerEloHandler } from '../../game/elo.js'; +import { getUser, updateUserCoins, insertLog } from '../../database/index.js'; +import {sleep} from "openai/core"; + +// Create a new router instance +const router = express.Router(); + +/** + * Factory function to create and configure the poker API routes. + * @param {object} client - The Discord.js client instance. + * @param {object} io - The Socket.IO server instance. + * @returns {object} The configured Express router. + */ +export function pokerRoutes(client, io) { + + // --- Room Management Endpoints --- + + router.get('/', (req, res) => { + res.status(200).json({ rooms: pokerRooms }); + }); + + router.get('/:id', (req, res) => { + const room = pokerRooms[req.params.id]; + if (room) { + res.status(200).json({ room }); + } else { + res.status(404).json({ message: 'Poker room not found.' }); + } + }); + + router.post('/create', async (req, res) => { + const { creatorId } = req.body; + if (!creatorId) return res.status(400).json({ message: 'Creator ID is required.' }); + + if (Object.values(pokerRooms).some(room => room.host_id === creatorId || room.players[creatorId])) { + return res.status(403).json({ message: 'You are already in a poker room.' }); + } + + const creator = await client.users.fetch(creatorId); + const id = uuidv4(); + const name = uniqueNamesGenerator({ dictionaries: [adjectives, ['Poker']], separator: ' ', style: 'capital' }); + + pokerRooms[id] = { + id, + host_id: creatorId, + host_name: creator.globalName || creator.username, + name, + created_at: Date.now(), + last_move_at: Date.now(), + players: {}, + queue: {}, + pioche: initialShuffledCards(), + tapis: [], + dealer: null, + sb: null, + bb: null, + highest_bet: 0, + current_player: null, + current_turn: null, // 0: pre-flop, 1: flop, 2: turn, 3: river, 4: showdown + playing: false, + winners: [], + waiting_for_restart: false, + fakeMoney: false, + }; + + // Auto-join the creator to their own room + await joinRoom(id, creatorId, io); + + io.emit('poker-update', { type: 'room-created', roomId: id }); + res.status(201).json({ roomId: id }); + }); + + router.post('/join', async (req, res) => { + const { userId, roomId } = req.body; + if (!userId || !roomId) return res.status(400).json({ message: 'User ID and Room ID are required.' }); + if (!pokerRooms[roomId]) return res.status(404).json({ message: 'Room not found.' }); + + if (Object.values(pokerRooms).some(r => r.players[userId])) { + return res.status(403).json({ message: 'You are already in a room.' }); + } + + await joinRoom(roomId, userId, io); + res.status(200).json({ message: 'Successfully joined room.' }); + }); + + router.post('/leave', (req, res) => { + // Implement leave logic... + res.status(501).json({ message: "Not Implemented" }); + }); + + // --- Game Action Endpoints --- + + router.post('/:roomId/start', async (req, res) => { + const { roomId } = req.params; + const room = pokerRooms[roomId]; + if (!room) return res.status(404).json({ message: 'Room not found.' }); + if (Object.keys(room.players).length < 2) return res.status(400).json({ message: 'Not enough players to start.' }); + + await startNewHand(room, io); + res.status(200).json({ message: 'Game started.' }); + }); + + router.post('/:roomId/action', async (req, res) => { + const { roomId } = req.params; + const { playerId, action, amount } = req.body; + const room = pokerRooms[roomId]; + + if (!room || !room.players[playerId] || room.current_player !== playerId) { + return res.status(403).json({ message: "It's not your turn or you are not in this game." }); + } + + const player = room.players[playerId]; + + switch(action) { + case 'fold': + player.folded = true; + io.emit('poker-update', { type: 'player-action', roomId, playerId, action, globalName: player.globalName }); + break; + case 'check': + if (player.bet < room.highest_bet) return res.status(400).json({ message: 'Cannot check, you must call or raise.' }); + io.emit('poker-update', { type: 'player-action', roomId, playerId, action, globalName: player.globalName }); + break; + case 'call': + const callAmount = room.highest_bet - player.bet; + if (callAmount > player.bank) { // All-in call + player.bet += player.bank; + player.bank = 0; + player.allin = true; + } else { + player.bet += callAmount; + player.bank -= callAmount; + } + updatePlayerCoins(player, -callAmount, room.fakeMoney); + io.emit('poker-update', { type: 'player-action', roomId, playerId, action, globalName: player.globalName }); + break; + case 'raise': + const totalBet = player.bet + amount; + if (amount > player.bank || totalBet <= room.highest_bet) return res.status(400).json({ message: 'Invalid raise amount.' }); + + player.bet = totalBet; + player.bank -= amount; + if(player.bank === 0) player.allin = true; + room.highest_bet = totalBet; + updatePlayerCoins(player, -amount, room.fakeMoney); + io.emit('poker-update', { type: 'player-action', roomId, playerId, action, amount, globalName: player.globalName }); + break; + default: + return res.status(400).json({ message: 'Invalid action.' }); + } + + player.last_played_turn = room.current_turn; + await checkRoundCompletion(room, io); + res.status(200).json({ message: `Action '${action}' successful.` }); + }); + + return router; +} + + +// --- Helper Functions --- + +async function joinRoom(roomId, userId, io) { + const user = await client.users.fetch(userId); + const userDB = getUser.get(userId); + const bank = userDB?.coins >= 1000 ? userDB.coins : 1000; + const isFake = userDB?.coins < 1000; + + pokerRooms[roomId].players[userId] = { + id: userId, + globalName: user.globalName || user.username, + hand: [], + bank: bank, + bet: 0, + folded: false, + allin: false, + last_played_turn: null, + }; + + if(isFake) pokerRooms[roomId].fakeMoney = true; + + io.emit('poker-update', { type: 'player-join', roomId, player: pokerRooms[roomId].players[userId] }); +} + +async function startNewHand(room, io) { + room.playing = true; + room.current_turn = 0; // Pre-flop + room.pioche = initialShuffledCards(); + room.tapis = []; + room.winners = []; + room.waiting_for_restart = false; + room.highest_bet = 20; + + // Reset players for the new hand + Object.values(room.players).forEach(p => { + p.hand = [room.pioche.pop(), room.pioche.pop()]; + p.bet = 0; + p.folded = false; + p.allin = false; + p.last_played_turn = null; + }); + + // Handle blinds + const playerIds = Object.keys(room.players); + const sbPlayer = room.players[playerIds[0]]; + const bbPlayer = room.players[playerIds[1]]; + + sbPlayer.bet = 10; + sbPlayer.bank -= 10; + updatePlayerCoins(sbPlayer, -10, room.fakeMoney); + + bbPlayer.bet = 20; + bbPlayer.bank -= 20; + updatePlayerCoins(bbPlayer, -20, room.fakeMoney); + + bbPlayer.last_played_turn = 0; + room.current_player = playerIds[2 % playerIds.length]; + + io.emit('poker-update', { type: 'new-hand', room }); +} + +async function checkRoundCompletion(room, io) { + room.last_move_at = Date.now(); + const roundResult = checkEndOfBettingRound(room); + + if (roundResult.endRound) { + if (roundResult.winner) { + // Handle single winner case (everyone else folded) + await handleShowdown(room, io, [roundResult.winner]); + } else { + // Proceed to the next phase + await advanceToNextPhase(room, io, roundResult.nextPhase); + } + } else { + // Continue the round + room.current_player = getNextActivePlayer(room); + io.emit('poker-update', { type: 'next-player', room }); + } +} + +async function advanceToNextPhase(room, io, phase) { + // Reset player turn markers for the new betting round + Object.values(room.players).forEach(p => p.last_played_turn = null); + + switch(phase) { + case 'flop': + room.current_turn = 1; + room.tapis = [room.pioche.pop(), room.pioche.pop(), room.pioche.pop()]; + break; + case 'turn': + room.current_turn = 2; + room.tapis.push(room.pioche.pop()); + break; + case 'river': + room.current_turn = 3; + room.tapis.push(room.pioche.pop()); + break; + case 'showdown': + const winners = checkRoomWinners(room); + await handleShowdown(room, io, winners); + return; + case 'progressive-showdown': + // Show cards and deal remaining community cards one by one + io.emit('poker-update', { type: 'show-cards', room }); + while(room.tapis.length < 5) { + await sleep(1500); + room.tapis.push(room.pioche.pop()); + io.emit('poker-update', { type: 'community-card-deal', room }); + } + const finalWinners = checkRoomWinners(room); + await handleShowdown(room, io, finalWinners); + return; + } + room.current_player = getFirstActivePlayerAfterDealer(room); + io.emit('poker-update', { type: 'phase-change', room }); +} + +async function handleShowdown(room, io, winners) { + room.current_turn = 4; + room.playing = false; + room.waiting_for_restart = true; + room.winners = winners; + + const totalPot = Object.values(room.players).reduce((sum, p) => sum + p.bet, 0); + const winAmount = Math.floor(totalPot / winners.length); + + winners.forEach(winnerId => { + const winnerPlayer = room.players[winnerId]; + winnerPlayer.bank += winAmount; + updatePlayerCoins(winnerPlayer, winAmount, room.fakeMoney); + }); + + await pokerEloHandler(room); + io.emit('poker-update', { type: 'showdown', room, winners, winAmount }); +} + +function updatePlayerCoins(player, amount, isFake) { + if (isFake) return; + const user = getUser.get(player.id); + if (!user) return; + + const newCoins = user.coins + amount; + updateUserCoins.run({ id: player.id, coins: newCoins }); + insertLog.run({ + id: `${player.id}-poker-${Date.now()}`, + user_id: player.id, + action: `POKER_${amount > 0 ? 'WIN' : 'BET'}`, + coins_amount: amount, + user_new_amount: newCoins, + }); +} \ No newline at end of file diff --git a/src/server/routes/solitaire.js b/src/server/routes/solitaire.js new file mode 100644 index 0000000..226cbea --- /dev/null +++ b/src/server/routes/solitaire.js @@ -0,0 +1,221 @@ +import express from 'express'; + +// --- Game Logic Imports --- +import { + createDeck, shuffle, deal, isValidMove, moveCard, drawCard, + checkWinCondition, createSeededRNG, seededShuffle +} from '../../game/solitaire.js'; + +// --- Game State & Database Imports --- +import { activeSolitaireGames } from '../../game/state.js'; +import { + getSOTD, getUser, insertSOTDStats, deleteUserSOTDStats, + getUserSOTDStats, updateUserCoins, insertLog, getAllSOTDStats +} from '../../database/index.js'; + +// Create a new router instance +const router = express.Router(); + +/** + * Factory function to create and configure the solitaire API routes. + * @param {object} client - The Discord.js client instance. + * @param {object} io - The Socket.IO server instance. + * @returns {object} The configured Express router. + */ +export function solitaireRoutes(client, io) { + + // --- Game Initialization Endpoints --- + + router.post('/start', (req, res) => { + const { userId, userSeed } = req.body; + if (!userId) return res.status(400).json({ error: 'User ID is required.' }); + + // If a game already exists for the user, return it instead of creating a new one. + if (activeSolitaireGames[userId] && !activeSolitaireGames[userId].isSOTD) { + return res.json({ success: true, gameState: activeSolitaireGames[userId] }); + } + + let deck, seed; + if (userSeed) { + // Use the provided seed to create a deterministic game + seed = userSeed; + let numericSeed = 0; + for (let i = 0; i < seed.length; i++) { + numericSeed = (numericSeed + seed.charCodeAt(i)) & 0xFFFFFFFF; + } + const rng = createSeededRNG(numericSeed); + deck = seededShuffle(createDeck(), rng); + } else { + // Create a standard random game + seed = Date.now().toString(36) + Math.random().toString(36).substr(2); + deck = shuffle(createDeck()); + } + + const gameState = deal(deck); + gameState.seed = seed; + gameState.isSOTD = false; + activeSolitaireGames[userId] = gameState; + + res.json({ success: true, gameState }); + }); + + router.post('/start/sotd', (req, res) => { + const { userId } = req.body; + if (!userId || !getUser.get(userId)) { + return res.status(404).json({ error: 'User not found.' }); + } + + if (activeSolitaireGames[userId]?.isSOTD) { + return res.json({ success: true, gameState: activeSolitaireGames[userId] }); + } + + const sotd = getSOTD.get(); + if (!sotd) { + return res.status(500).json({ error: 'Solitaire of the Day is not configured.'}); + } + + const gameState = { + tableauPiles: JSON.parse(sotd.tableauPiles), + foundationPiles: JSON.parse(sotd.foundationPiles), + stockPile: JSON.parse(sotd.stockPile), + wastePile: JSON.parse(sotd.wastePile), + isDone: false, + isSOTD: true, + startTime: Date.now(), + endTime: null, + moves: 0, + score: 0, + seed: sotd.seed, + }; + + activeSolitaireGames[userId] = gameState; + res.json({ success: true, gameState }); + }); + + // --- Game State & Action Endpoints --- + + router.get('/sotd/rankings', (req, res) => { + try { + const rankings = getAllSOTDStats.all(); + res.json({ rankings }); + } catch(e) { + res.status(500).json({ error: "Failed to fetch SOTD rankings."}); + } + }); + + router.get('/state/:userId', (req, res) => { + const { userId } = req.params; + const gameState = activeSolitaireGames[userId]; + if (gameState) { + res.json({ success: true, gameState }); + } else { + res.status(404).json({ error: 'No active game found for this user.' }); + } + }); + + router.post('/reset', (req, res) => { + const { userId } = req.body; + if (activeSolitaireGames[userId]) { + delete activeSolitaireGames[userId]; + } + res.json({ success: true, message: "Game reset."}); + }); + + router.post('/move', (req, res) => { + const { userId, ...moveData } = req.body; + const gameState = activeSolitaireGames[userId]; + + if (!gameState) return res.status(404).json({ error: 'Game not found.' }); + if (gameState.isDone) return res.status(400).json({ error: 'This game is already completed.'}); + + if (isValidMove(gameState, moveData)) { + moveCard(gameState, moveData); + updateGameStats(gameState, 'move', moveData); + + const win = checkWinCondition(gameState); + if (win) { + gameState.isDone = true; + handleWin(userId, gameState, io); + } + res.json({ success: true, gameState, win }); + } else { + res.status(400).json({ error: 'Invalid move' }); + } + }); + + router.post('/draw', (req, res) => { + const { userId } = req.body; + const gameState = activeSolitaireGames[userId]; + + if (!gameState) return res.status(404).json({ error: 'Game not found.' }); + if (gameState.isDone) return res.status(400).json({ error: 'This game is already completed.'}); + + drawCard(gameState); + updateGameStats(gameState, 'draw'); + res.json({ success: true, gameState }); + }); + + return router; +} + + +// --- Helper Functions --- + +/** Updates game stats like moves and score after an action. */ +function updateGameStats(gameState, actionType, moveData = {}) { + if (!gameState.isSOTD) return; // Only track stats for SOTD + + gameState.moves++; + if (actionType === 'move') { + if (moveData.destPileType === 'foundationPiles') { + gameState.score += 10; // Move card to foundation + } + if (moveData.sourcePileType === 'foundationPiles') { + gameState.score -= 15; // Move card from foundation (penalty) + } + } + if(actionType === 'draw' && gameState.wastePile.length === 0) { + // Penalty for cycling through an empty stock pile + gameState.score -= 100; + } +} + +/** Handles the logic when a game is won. */ +function handleWin(userId, gameState, io) { + if (!gameState.isSOTD) return; + + gameState.endTime = Date.now(); + const timeTaken = gameState.endTime - gameState.startTime; + + const currentUser = getUser.get(userId); + const existingStats = getUserSOTDStats.get(userId); + + if (!existingStats) { + // First time completing the SOTD, grant bonus coins + const bonus = 1000; + const newCoins = currentUser.coins + bonus; + updateUserCoins.run({ id: userId, coins: newCoins }); + insertLog.run({ + id: `${userId}-sotd-complete-${Date.now()}`, user_id: userId, action: 'SOTD_WIN', + coins_amount: bonus, user_new_amount: newCoins, + }); + io.emit('data-updated', { table: 'users' }); + } + + // Save the score if it's better than the previous one + const isNewBest = !existingStats || + gameState.score > existingStats.score || + (gameState.score === existingStats.score && gameState.moves < existingStats.moves) || + (gameState.score === existingStats.score && gameState.moves === existingStats.moves && timeTaken < existingStats.time); + + if (isNewBest) { + insertSOTDStats.run({ + id: userId, user_id: userId, + time: timeTaken, + moves: gameState.moves, + score: gameState.score, + }); + io.emit('sotd-update'); // Notify frontend of new rankings + console.log(`New SOTD high score for ${currentUser.globalName}: ${gameState.score} points.`); + } +} \ No newline at end of file diff --git a/src/server/socket.js b/src/server/socket.js new file mode 100644 index 0000000..1424b55 --- /dev/null +++ b/src/server/socket.js @@ -0,0 +1,258 @@ +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { activeTicTacToeGames, tictactoeQueue, activeConnect4Games, connect4Queue } from '../game/state.js'; +import { createConnect4Board, formatConnect4BoardForDiscord, checkConnect4Win, checkConnect4Draw, C4_ROWS } from '../game/various.js'; +import { eloHandler } from '../game/elo.js'; +import { getUser } from "../database/index.js"; + +// --- Module-level State --- +let io; + +// --- Main Initialization Function --- + +export function initializeSocket(server, client) { + io = server; + + io.on('connection', (socket) => { + console.log(`[Socket.IO] User connected: ${socket.id}`); + + socket.on('user-connected', async (userId) => { + if (!userId) return; + await refreshQueuesForUser(userId, client); + }); + + registerTicTacToeEvents(socket, client); + registerConnect4Events(socket, client); + + socket.on('disconnect', () => { + console.log(`[Socket.IO] User disconnected: ${socket.id}`); + }); + }); + + setInterval(cleanupStaleGames, 5 * 60 * 1000); +} + +export function getSocketIo() { + return io; +} + +// --- Event Registration --- + +function registerTicTacToeEvents(socket, client) { + socket.on('tictactoeconnection', (e) => refreshQueuesForUser(e.id, client)); + socket.on('tictactoequeue', (e) => onQueueJoin(client, 'tictactoe', e.playerId)); + socket.on('tictactoeplaying', (e) => onTicTacToeMove(client, e)); + socket.on('tictactoegameOver', (e) => onGameOver(client, 'tictactoe', e.playerId, e.winner)); +} + +function registerConnect4Events(socket, client) { + socket.on('connect4connection', (e) => refreshQueuesForUser(e.id, client)); + socket.on('connect4queue', (e) => onQueueJoin(client, 'connect4', e.playerId)); + socket.on('connect4playing', (e) => onConnect4Move(client, e)); + socket.on('connect4NoTime', (e) => onGameOver(client, 'connect4', e.playerId, e.winner, '(temps écoulé)')); +} + +// --- Core Handlers (Preserving Original Logic) --- + +async function onQueueJoin(client, gameType, playerId) { + if (!playerId) return; + const { queue, activeGames, title, url } = getGameAssets(gameType); + + if (queue.includes(playerId) || Object.values(activeGames).some(g => g.p1.id === playerId || g.p2.id === playerId)) { + return; + } + + queue.push(playerId); + console.log(`[${title}] Player ${playerId} joined the queue.`); + + if (queue.length === 1) await postQueueToDiscord(client, playerId, title, url); + if (queue.length >= 2) await createGame(client, gameType); + + await emitQueueUpdate(client, gameType); +} + +async function onTicTacToeMove(client, eventData) { + const { playerId, value, boxId } = eventData; + const lobby = Object.values(activeTicTacToeGames).find(g => (g.p1.id === playerId || g.p2.id === playerId) && !g.gameOver); + if (!lobby) return; + + const isP1Turn = lobby.sum % 2 === 1 && value === 'X' && lobby.p1.id === playerId; + const isP2Turn = lobby.sum % 2 === 0 && value === 'O' && lobby.p2.id === playerId; + + if (isP1Turn || isP2Turn) { + (isP1Turn) ? lobby.xs.push(boxId) : lobby.os.push(boxId); + lobby.sum++; + lobby.lastmove = Date.now(); + await updateDiscordMessage(client, lobby, 'Tic Tac Toe'); + io.emit('tictactoeplaying', { allPlayers: Object.values(activeTicTacToeGames) }); + } + await emitQueueUpdate(client, 'tictactoe'); +} + +async function onConnect4Move(client, eventData) { + const { playerId, col } = eventData; + const lobby = Object.values(activeConnect4Games).find(l => (l.p1.id === playerId || l.p2.id === playerId) && !l.gameOver); + if (!lobby || lobby.turn !== playerId) return; + + const player = lobby.p1.id === playerId ? lobby.p1 : lobby.p2; + let row; + for (row = C4_ROWS - 1; row >= 0; row--) { + if (lobby.board[row][col] === null) { + lobby.board[row][col] = player.val; + break; + } + } + if (row < 0) return; + + lobby.lastmove = Date.now(); + const winCheck = checkConnect4Win(lobby.board, player.val); + + let winnerId = null; + if (winCheck.win) { + lobby.winningPieces = winCheck.pieces; + winnerId = player.id; + } else if (checkConnect4Draw(lobby.board)) { + winnerId = null; // Represents a draw + } else { + lobby.turn = lobby.p1.id === playerId ? lobby.p2.id : lobby.p1.id; + io.emit('connect4playing', { allPlayers: Object.values(activeConnect4Games) }); + await emitQueueUpdate(client, 'connact4'); + await updateDiscordMessage(client, lobby, 'Puissance 4'); + return; + } + await onGameOver(client, 'connect4', playerId, winnerId); +} + +async function onGameOver(client, gameType, playerId, winnerId, reason = '') { + const { activeGames, title } = getGameAssets(gameType); + const gameKey = Object.keys(activeGames).find(key => key.includes(playerId)); + const game = gameKey ? activeGames[gameKey] : undefined; + if (!game || game.gameOver) return; + + game.gameOver = true; + let resultText; + if (winnerId === null) { + await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, title.toUpperCase()); + resultText = 'Égalité'; + } else { + await eloHandler(game.p1.id, game.p2.id, game.p1.id === winnerId ? 1 : 0, game.p2.id === winnerId ? 1 : 0, title.toUpperCase()); + const winnerName = game.p1.id === winnerId ? game.p1.name : game.p2.name; + resultText = `Victoire de ${winnerName}`; + } + + await updateDiscordMessage(client, game, title, `${resultText} ${reason}`); + + if(gameType === 'tictactoe') io.emit('tictactoegameOver', { game, winner: winnerId }); + if(gameType === 'connect4') io.emit('connect4gameOver', { game, winner: winnerId }); + + if (gameKey) { + setTimeout(() => delete activeGames[gameKey], 10000); + } + await emitQueueUpdate(client, gameType); +} + +// --- Game Lifecycle & Discord Helpers --- + +async function createGame(client, gameType) { + const { queue, activeGames, title } = getGameAssets(gameType); + const p1Id = queue.shift(); + const p2Id = queue.shift(); + const [p1, p2] = await Promise.all([client.users.fetch(p1Id), client.users.fetch(p2Id)]); + + let lobby; + if (gameType === 'tictactoe') { + lobby = { p1: { id: p1Id, name: p1.globalName, val: 'X' }, p2: { id: p2Id, name: p2.globalName, val: 'O' }, sum: 1, xs: [], os: [], gameOver: false, lastmove: Date.now() }; + } else { // connect4 + lobby = { p1: { id: p1Id, name: p1.globalName, val: 'R' }, p2: { id: p2Id, name: p2.globalName, val: 'Y' }, turn: p1Id, board: createConnect4Board(), gameOver: false, lastmove: Date.now(), winningPieces: [] }; + } + + const msgId = await updateDiscordMessage(client, lobby, title); + lobby.msgId = msgId; + + const gameKey = `${p1Id}-${p2Id}`; + activeGames[gameKey] = lobby; + + io.emit(`${gameType}playing`, { allPlayers: Object.values(activeGames) }); + await emitQueueUpdate(client, gameType); +} + +// --- Utility Functions --- + +async function refreshQueuesForUser(userId, client) { + // FIX: Mutate the array instead of reassigning it. + let index = tictactoeQueue.indexOf(userId); + if (index > -1) tictactoeQueue.splice(index, 1); + + index = connect4Queue.indexOf(userId); + if (index > -1) connect4Queue.splice(index, 1); + + await emitQueueUpdate(client, 'tictactoe'); + await emitQueueUpdate(client, 'connect4'); +} + +async function emitQueueUpdate(client, gameType) { + const { queue, activeGames } = getGameAssets(gameType); + const names = await Promise.all(queue.map(async (id) => { + const user = await client.users.fetch(id).catch(() => null); + return user?.globalName || user?.username; + })); + io.emit(`${gameType}queue`, { allPlayers: Object.values(activeGames), queue: names.filter(Boolean) }); +} + +function getGameAssets(gameType) { + if (gameType === 'tictactoe') return { queue: tictactoeQueue, activeGames: activeTicTacToeGames, title: 'Tic Tac Toe', url: '/tic-tac-toe' }; + if (gameType === 'connect4') return { queue: connect4Queue, activeGames: activeConnect4Games, title: 'Puissance 4', url: '/connect-4' }; + return { queue: [], activeGames: {} }; +} + +async function postQueueToDiscord(client, playerId, title, url) { + try { + const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID); + const user = await client.users.fetch(playerId); + const embed = new EmbedBuilder().setTitle(title).setDescription(`**${user.globalName || user.username}** est dans la file d'attente.`).setColor('#5865F2'); + const row = new ActionRowBuilder().addComponents(new ButtonBuilder().setLabel(`Jouer contre ${user.username}`).setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}${url}`).setStyle(ButtonStyle.Link)); + await generalChannel.send({ embeds: [embed], components: [row] }); + } catch (e) { console.error(`Failed to post queue message for ${title}:`, e); } +} + +async function updateDiscordMessage(client, game, title, resultText = '') { + const channel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID).catch(() => null); + if (!channel) return null; + + let description; + if (title === 'Tic Tac Toe') { + let gridText = ''; + for (let i = 1; i <= 9; i++) { gridText += game.xs.includes(i) ? '❌' : game.os.includes(i) ? '⭕' : '🟦'; if (i % 3 === 0) gridText += '\n'; } + description = `### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n${gridText}`; + } else { + description = `**🔴 ${game.p1.name}** vs **${game.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(game.board)}`; + } + if (resultText) description += `\n### ${resultText}`; + + const embed = new EmbedBuilder().setTitle(title).setDescription(description).setColor(game.gameOver ? '#2ade2a' : '#5865f2'); + + try { + if (game.msgId) { + const message = await channel.messages.fetch(game.msgId); + await message.edit({ embeds: [embed] }); + return game.msgId; + } else { + const message = await channel.send({ embeds: [embed] }); + return message.id; + } + } catch (e) { return null; } +} + +function cleanupStaleGames() { + const now = Date.now(); + const STALE_TIMEOUT = 30 * 60 * 1000; + const cleanup = (games, name) => { + Object.keys(games).forEach(key => { + if (now - games[key].lastmove > STALE_TIMEOUT) { + console.log(`[Cleanup] Removing stale ${name} game: ${key}`); + delete games[key]; + } + }); + }; + cleanup(activeTicTacToeGames, 'TicTacToe'); + cleanup(activeConnect4Games, 'Connect4'); +} \ No newline at end of file diff --git a/src/utils/ai.js b/src/utils/ai.js new file mode 100644 index 0000000..45b1a70 --- /dev/null +++ b/src/utils/ai.js @@ -0,0 +1,82 @@ +import 'dotenv/config'; +import OpenAI from "openai"; +import {GoogleGenAI} from "@google/genai"; +import {Mistral} from '@mistralai/mistralai'; + +// --- AI Client Initialization --- +// Initialize clients for each AI service. This is done once when the module is loaded. + +let openai; +if (process.env.OPENAI_API_KEY) { + openai = new OpenAI(); +} + +let gemini; +if (process.env.GEMINI_KEY) { + gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_KEY}) +} + +let mistral; +if (process.env.MISTRAL_KEY) { + mistral = new Mistral({apiKey: process.env.MISTRAL_KEY}); +} + + +/** + * Gets a response from the configured AI model. + * It dynamically chooses the provider based on the MODEL environment variable. + * @param {Array} messageHistory - The conversation history in a standardized format. + * @returns {Promise} The content of the AI's response message. + */ +export async function gork(messageHistory) { + const modelProvider = process.env.MODEL; + + console.log(`[AI] Requesting completion from ${modelProvider}...`); + + try { + // --- OpenAI Provider --- + if (modelProvider === 'OpenAI' && openai) { + const completion = await openai.chat.completions.create({ + model: "gpt-4o-mini", // Using a modern, cost-effective model + messages: messageHistory, + }); + return completion.choices[0].message.content; + } + + // --- Google Gemini Provider --- + else if (modelProvider === 'Gemini' && gemini) { + // Gemini requires a slightly different history format. + const contents = messageHistory.map(msg => ({ + role: msg.role === 'assistant' ? 'model' : msg.role, // Gemini uses 'model' for assistant role + parts: [{ text: msg.content }], + })); + + // The last message should not be from the model + if (contents[contents.length - 1].role === 'model') { + contents.pop(); + } + + const result = await gemini.generateContent({ contents }); + const response = await result.response; + return response.text(); + } + + // --- Mistral Provider --- + else if (modelProvider === 'Mistral' && mistral) { + const chatResponse = await mistral.chat({ + model: 'mistral-large-latest', + messages: messageHistory, + }); + return chatResponse.choices[0].message.content; + } + + // --- Fallback Case --- + else { + console.warn(`[AI] No valid AI provider configured or API key missing for MODEL=${modelProvider}.`); + return "Le service d'IA n'est pas correctement configuré. Veuillez contacter l'administrateur."; + } + } catch(error) { + console.error(`[AI] Error with ${modelProvider} API:`, error); + return "Oups, une erreur est survenue en contactant le service d'IA."; + } +} \ No newline at end of file diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 0000000..d16695a --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,254 @@ +import 'dotenv/config'; +import cron from 'node-cron'; +import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generator'; + +// --- Local Imports --- +import { getValorantSkins, getSkinTiers } from '../api/valorant.js'; +import { DiscordRequest } from '../api/discord.js'; +import { initTodaysSOTD } from '../game/points.js'; +import { + insertManyUsers, insertManySkins, resetDailyReward, + pruneOldLogs, getAllUsers as dbGetAllUsers, getSOTD, +} from '../database/index.js'; +import { activeInventories, activeSearchs, activePredis, pokerRooms, skins } from '../game/state.js'; + +export async function InstallGlobalCommands(appId, commands) { + // API endpoint to overwrite global commands + const endpoint = `applications/${appId}/commands`; + + try { + // This is calling the bulk overwrite endpoint: https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands + await DiscordRequest(endpoint, { method: 'PUT', body: commands }); + } catch (err) { + console.error(err); + } +} + +// --- Data Fetching & Initialization --- + +/** + * Fetches all members with the 'Akhy' role and all Valorant skins, + * then syncs them with the database. + * @param {object} client - The Discord.js client instance. + */ +export async function getAkhys(client) { + try { + // 1. Fetch Discord Members + const guild = await client.guilds.fetch(process.env.GUILD_ID); + const members = await guild.members.fetch(); + const akhys = members.filter(m => !m.user.bot && m.roles.cache.has(process.env.AKHY_ROLE_ID)); + + const usersToInsert = akhys.map(akhy => ({ + id: akhy.user.id, + username: akhy.user.username, + globalName: akhy.user.globalName, + })); + + if (usersToInsert.length > 0) { + insertManyUsers(usersToInsert); + } + console.log(`[Sync] Found and synced ${akhys.size} users with the 'Akhy' role.`); + + // 2. Fetch Valorant Skins + const [fetchedSkins, fetchedTiers] = await Promise.all([getValorantSkins(), getSkinTiers()]); + + // Clear and rebuild the in-memory skin cache + skins.length = 0; + fetchedSkins.forEach(skin => skins.push(skin)); + + const skinsToInsert = fetchedSkins + .filter(skin => skin.contentTierUuid) + .map(skin => { + const tier = fetchedTiers.find(t => t.uuid === skin.contentTierUuid) || {}; + const basePrice = calculateBasePrice(skin, tier.rank); + return { + uuid: skin.uuid, + displayName: skin.displayName, + contentTierUuid: skin.contentTierUuid, + displayIcon: skin.displayIcon, + user_id: null, + tierRank: tier.rank, + tierColor: tier.highlightColor?.slice(0, 6) || 'F2F3F3', + tierText: formatTierText(tier.rank, skin.displayName), + basePrice: basePrice.toFixed(2), + maxPrice: calculateMaxPrice(basePrice, skin).toFixed(2), + }; + }); + + if (skinsToInsert.length > 0) { + insertManySkins(skinsToInsert); + } + console.log(`[Sync] Fetched and synced ${skinsToInsert.length} Valorant skins.`); + + } catch (err) { + console.error('Error during initial data sync (getAkhys):', err); + } +} + + +// --- Cron Jobs / Scheduled Tasks --- + +/** + * Sets up all recurring tasks for the application. + * @param {object} client - The Discord.js client instance. + * @param {object} io - The Socket.IO server instance. + */ +export function setupCronJobs(client, io) { + // Every 10 minutes: Clean up expired interactive sessions + cron.schedule('*/10 * * * *', () => { + const now = Date.now(); + const FIVE_MINUTES = 5 * 60 * 1000; + const ONE_DAY = 24 * 60 * 60 * 1000; + + const cleanup = (sessions, name) => { + let cleanedCount = 0; + for (const id in sessions) { + if (now >= (sessions[id].timestamp || 0) + FIVE_MINUTES) { + delete sessions[id]; + cleanedCount++; + } + } + if (cleanedCount > 0) console.log(`[Cron] Cleaned up ${cleanedCount} expired ${name} sessions.`); + }; + + cleanup(activeInventories, 'inventory'); + cleanup(activeSearchs, 'search'); + + // Cleanup for predis and poker rooms... + // ... + }); + + // Daily at midnight: Reset daily rewards and init SOTD + cron.schedule('0 0 * * *', async () => { + console.log('[Cron] Running daily midnight tasks...'); + try { + resetDailyReward.run(); + console.log('[Cron] Daily rewards have been reset for all users.'); + if (!getSOTD.get()) { + initTodaysSOTD(); + } + } catch (e) { + console.error('[Cron] Error during daily reset:', e); + } + }); + + // Daily at 7 AM: Re-sync users and skins + cron.schedule('0 7 * * *', () => { + console.log('[Cron] Running daily 7 AM data sync...'); + getAkhys(client); + }); +} + + +// --- Formatting Helpers --- + +export function capitalize(str) { + if (typeof str !== 'string' || str.length === 0) return ''; + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function formatTime(seconds) { + const d = Math.floor(seconds / (3600*24)); + const h = Math.floor(seconds % (3600*24) / 3600); + const m = Math.floor(seconds % 3600 / 60); + const s = Math.floor(seconds % 60); + + const parts = []; + if (d > 0) parts.push(`**${d}** jour${d > 1 ? 's' : ''}`); + if (h > 0) parts.push(`**${h}** heure${h > 1 ? 's' : ''}`); + if (m > 0) parts.push(`**${m}** minute${m > 1 ? 's' : ''}`); + if (s > 0 || parts.length === 0) parts.push(`**${s}** seconde${s > 1 ? 's' : ''}`); + + return parts.join(', ').replace(/,([^,]*)$/, ' et$1'); +} + +// --- External API Helpers --- + +/** + * Fetches user data from the "APO" service. + */ +export async function getAPOUsers() { + const fetchUrl = `${process.env.APO_BASE_URL}/users?serverId=${process.env.GUILD_ID}`; + try { + const response = await fetch(fetchUrl); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return await response.json(); + } catch (error) { + console.error('Error fetching APO users:', error); + return null; + } +} + +/** + * Sends a "buy" request to the "APO" service. + * @param {string} userId - The Discord user ID. + * @param {number} amount - The amount to "buy". + */ +export async function postAPOBuy(userId, amount) { + const fetchUrl = `${process.env.APO_BASE_URL}/buy?serverId=${process.env.GUILD_ID}&userId=${userId}&amount=${amount}`; + return fetch(fetchUrl, { method: 'POST' }); +} + + +// --- Miscellaneous Helpers --- + +export async function getOnlineUsersWithRole(guild, roleId) { + if (!guild || !roleId) return new Map(); + try { + const members = await guild.members.fetch(); + return members.filter(m => !m.user.bot && m.presence?.status !== 'offline' && m.roles.cache.has(roleId)); + } catch (err) { + console.error('Error fetching online members with role:', err); + return new Map(); + } +} + +export function getRandomEmoji(list = 0) { + const emojiLists = [ + ['😭','😄','😌','🤓','😎','😤','🤖','😶‍🌫️','🌏','📸','💿','👋','🌊','✨'], + ['<:CAUGHT:1323810730155446322>', '<:hinhinhin:1072510144933531758>', '<:o7:1290773422451986533>', '<:zhok:1115221772623683686>', '<:nice:1154049521110765759>', '<:nerd:1087658195603951666>', '<:peepSelfie:1072508131839594597>'], + ]; + const selectedList = emojiLists[list] || ['']; + return selectedList[Math.floor(Math.random() * selectedList.length)]; +} + + +// --- Private Helpers --- + +function calculateBasePrice(skin, tierRank) { + const name = skin.displayName.toLowerCase(); + let price = 6000; // Default for melee + if (name.includes('classic')) price = 150; + else if (name.includes('shorty')) price = 300; + else if (name.includes('frenzy')) price = 450; + else if (name.includes('ghost')) price = 500; + // ... add all other weapon prices ... + else if (name.includes('vandal') || name.includes('phantom')) price = 2900; + + price *= (1 + (tierRank || 0)); + if (name.includes('vct')) price *= 1.25; + if (name.includes('champions')) price *= 2; + + return price / 1111; +} + +function calculateMaxPrice(basePrice, skin) { + let res = basePrice; + res *= (1 + (skin.levels.length / Math.max(skin.levels.length, 2))); + res *= (1 + (skin.chromas.length / 4)); + return res; +} + +function formatTierText(rank, displayName) { + const tiers = { + 0: '**<:select:1362964319498670222> Select**', + 1: '**<:deluxe:1362964308094488797> Deluxe**', + 2: '**<:premium:1362964330349330703> Premium**', + 3: '**<:exclusive:1362964427556651098> Exclusive**', + 4: '**<:ultra:1362964339685986314> Ultra**', + }; + let res = tiers[rank] || 'Pas de tier'; + if (displayName.includes('VCT')) res += ' | Esports'; + if (displayName.toLowerCase().includes('champions')) res += ' | Champions'; + return res; +} \ No newline at end of file