├── .env.example ├── .gitattributes ├── .gitignore ├── LICENSE ├── app ├── assets │ ├── avatar.png │ ├── categories │ │ ├── advanced.svg │ │ ├── data.svg │ │ ├── leaderboard.svg │ │ ├── levelup.svg │ │ ├── multipliers.svg │ │ ├── rankcard.svg │ │ ├── rewardroles.svg │ │ └── xp.svg │ ├── fumo.png │ ├── icons │ │ ├── cog.svg │ │ ├── download.svg │ │ ├── pencil.svg │ │ ├── plus.svg │ │ └── podium.svg │ ├── polaris.png │ └── polaris.svg ├── css │ └── polaris.css ├── html │ ├── 404.html │ ├── config.html │ ├── home.html │ ├── leaderboard.html │ └── servers.html └── js │ └── extras.js ├── classes ├── DatabaseModel.js ├── LevelUpEmbed.js ├── LevelUpMessage.js ├── PageEmbed.js └── Tools.js ├── commands ├── button │ ├── export_xp.js │ ├── list_multipliers.js │ ├── list_reward_roles.js │ ├── settings_edit.js │ ├── settings_list.js │ ├── settings_view.js │ └── toggle_xp.js ├── events │ └── message.js ├── misc │ ├── json_import.js │ └── polaris_transfer.js ├── slash │ ├── addxp.js │ ├── botstatus.js │ ├── calculate.js │ ├── clear.js │ ├── config.js │ ├── dev_db.js │ ├── dev_deploy.js │ ├── dev_run.js │ ├── dev_setactivity.js │ ├── dev_setversion.js │ ├── multiplier.js │ ├── rank.js │ ├── rewardrole.js │ ├── sync.js │ └── top.js └── user_context │ ├── view_on_leaderboard.js │ └── view_xp.js ├── config.json ├── database_schema.js ├── index.js ├── json ├── curve_presets.json ├── default_status.json ├── multiplier_modes.json └── quick_settings.json ├── package.json ├── polaris.code-workspace ├── polaris.js ├── readme.md └── web_app.js /.env.example: -------------------------------------------------------------------------------- 1 | # Discord application 2 | # All these keys can be found in the developer portal 3 | DISCORD_ID=123456789 4 | DISCORD_TOKEN=ABCDEFGHIJKLM 5 | DISCORD_SECRET=ZYXWVUTSRQPON 6 | 7 | # MongoDB database 8 | # For more info on setting this up, see the readme 9 | MONGO_DB_NAME=polaris 10 | MONGO_DB_URI= 11 | 12 | # OPTIONAL: If you left MONGO_DB_URI blank, fill these out in instead 13 | MONGO_DB_IP=7.7.7.7 14 | MONGO_DB_USERNAME=root 15 | MONGO_DB_PASSWORD=ilovegdcologne -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | json/auto 3 | .env 4 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | TLDR: Do whatever you want as long as it's not for profit or commercial use. 2 | 3 | - You are free to use, fork, modify, distribute, and steal code snippets from this project 4 | - You may NOT use this project for commercial purposes. This includes selling the bot, or incorporating monetized features. 5 | - Please credit me (Colon) when you use this project, especially if your bot/fork is public 6 | 7 | Polaris is provided "as-is", without any kind of warranty or guarantees regarding its functionality. If you use this code, you're doing so at your own risk and are responsible for any functionality, data, legal, or other issues that arise. It's your problem, not mine. -------------------------------------------------------------------------------- /app/assets/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GDColon/Polaris-Open/b5a93f0e6d6ed96e26db492c859f221dc22857e0/app/assets/avatar.png -------------------------------------------------------------------------------- /app/assets/categories/advanced.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/categories/data.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/categories/leaderboard.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/categories/levelup.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/categories/multipliers.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/categories/rankcard.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/categories/rewardroles.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/categories/xp.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/fumo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GDColon/Polaris-Open/b5a93f0e6d6ed96e26db492c859f221dc22857e0/app/assets/fumo.png -------------------------------------------------------------------------------- /app/assets/icons/cog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/icons/download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/icons/pencil.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 34 | 35 | 39 | 40 | -------------------------------------------------------------------------------- /app/assets/icons/podium.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 42 | 47 | 55 | 63 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /app/assets/polaris.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GDColon/Polaris-Open/b5a93f0e6d6ed96e26db492c859f221dc22857e0/app/assets/polaris.png -------------------------------------------------------------------------------- /app/assets/polaris.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 59 | -------------------------------------------------------------------------------- /app/css/polaris.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Lato&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@500;700;800&display=swap'); 3 | 4 | html { 5 | --bg: #202020; 6 | --fg: #303030; 7 | --lighterfg: #404040; 8 | --lightererfg: #505050; 9 | --evenlighterfg: #606060; 10 | --waylighterfg: #707070; 11 | --lightestfg: #808080; 12 | 13 | --lato: Lato, Arial, Helvetica, sans-serif; 14 | --opensans: 'Open Sans', --lato; 15 | 16 | --emojired: #DD2E44; 17 | --emojiyellow: #F4900C; 18 | --emojigreen: #77B255; 19 | --emojiblue: #3B88C3; 20 | --emojipurple: #9266CC; 21 | 22 | --defaultrolecol: #dddddd; 23 | 24 | --colon: #ff8000; 25 | --polarisgreen: #00ff80; 26 | 27 | --sliderwidth: 60px; 28 | --sliderheight: 32px; 29 | --sliderpadding: 4px; 30 | 31 | height: 100%; 32 | } 33 | 34 | body { 35 | background-color: var(--bg); 36 | margin: 0px 0px; 37 | } 38 | 39 | p, h1, h2, button { 40 | font-family: var(--lato); 41 | color: white; 42 | } 43 | 44 | p { 45 | font-size: 18px; 46 | margin: 12px 0px; 47 | line-height: 28px; 48 | } 49 | 50 | h1, h2 { 51 | font-family: var(--opensans); 52 | font-weight: 800; 53 | font-size: 36px; 54 | margin-top: 5px; 55 | margin-bottom: 5px; 56 | } 57 | 58 | h2 { 59 | margin-top: 26px; 60 | margin-bottom: 3px; 61 | font-size: 22px; 62 | } 63 | 64 | a { 65 | color: aqua !important; 66 | } 67 | 68 | input, select, textarea { 69 | font-family: var(--lato); 70 | background-color: var(--evenlighterfg); 71 | border: 1px solid black; 72 | border-radius: 4px; 73 | height: 40px; 74 | font-size: 18px; 75 | padding-left: 10px; 76 | color: white; 77 | width: 350px; 78 | } 79 | 80 | input[type="number"] { 81 | width: 125px; 82 | } 83 | 84 | input::placeholder, textarea::placeholder { 85 | color: white; 86 | opacity: 50%; 87 | } 88 | 89 | input[disabled] { 90 | cursor: not-allowed; 91 | background-color: var(--waylighterfg); 92 | } 93 | 94 | .lineinput { 95 | border: none; 96 | background: none; 97 | border-bottom: 1px solid white; 98 | border-bottom-left-radius: 0px; 99 | border-bottom-right-radius: 0px; 100 | padding: 0px 0px; 101 | text-align: center; 102 | background-color: rgba(0, 0, 0, 0.2); 103 | } 104 | 105 | select option:disabled { 106 | color: #999999; 107 | } 108 | 109 | select option { 110 | background-color: var(--bg); 111 | } 112 | 113 | button { 114 | font-family: var(--lato); 115 | min-width: 100px; 116 | height: 40px; 117 | padding: 0px 15px; 118 | font-size: 18px; 119 | font-weight: bold; 120 | border: none; 121 | border-radius: 4px; 122 | cursor: pointer; 123 | background-color: var(--emojiblue); 124 | } 125 | 126 | button:hover { filter: brightness(115%) } 127 | button:active { filter: brightness(125%) } 128 | 129 | button:disabled { 130 | cursor: not-allowed; 131 | opacity: 50%; 132 | filter: saturate(50%); 133 | } 134 | 135 | .fancybutton { 136 | font-family: var(--opensans); 137 | background-color: rgba(0, 0, 0, 0); 138 | border: 2px solid var(--polarisgreen); 139 | transition-duration: 0.25s; 140 | height: 50px; 141 | min-width: 140px; 142 | padding: 5px 20px; 143 | } 144 | 145 | .fancybutton:hover, .fancybutton:focus-visible { 146 | background-color: var(--polarisgreen); 147 | color: black; 148 | } 149 | 150 | .boringbutton { 151 | font-family: var(--opensans); 152 | background-color: rgba(0, 0, 0, 0); 153 | border: none; 154 | font-weight: 400; 155 | opacity: 66%; 156 | } 157 | 158 | .boringbutton:hover, .boringbutton:focus-visible { 159 | opacity: 100%; 160 | } 161 | 162 | textarea { 163 | width: 600px; 164 | height: 200px; 165 | padding-top: 8px; 166 | max-width: 100%; 167 | max-height: 700px; 168 | } 169 | 170 | .codeArea { 171 | font-family: monospace; 172 | font-size: 16px; 173 | } 174 | 175 | .colInputPreview { 176 | height: 40px; 177 | width: 40px; 178 | border: 1px solid black; 179 | border-radius: 4px; 180 | background-color: var(--polarisgreen); 181 | cursor: pointer; 182 | } 183 | 184 | .hiddenColInput { 185 | width: 0px; 186 | margin: 0px 0px; 187 | padding: 0px 0px; 188 | opacity: 0; 189 | position: absolute; 190 | pointer-events: none; 191 | } 192 | 193 | .canfocus:focus-visible, input:focus, button:focus-visible, textarea:focus, select:focus, input[type=range]:focus-visible, 194 | [contenteditable]:focus, #sidebar .category:focus-visible, .flexTable div p:focus-visible, .colInputPreview:focus-visible, 195 | .leaderboardSlot.canManage:focus-visible { 196 | outline: 2px solid var(--colon); 197 | } 198 | 199 | .slider { 200 | position: relative; 201 | display: inline-block; 202 | width: var(--sliderwidth); 203 | height: var(--sliderheight); 204 | } 205 | 206 | .slider:has(> input:disabled) { 207 | opacity: 33%; 208 | } 209 | 210 | .slider input { 211 | opacity: 0; 212 | width: 0; 213 | height: 0; 214 | } 215 | 216 | .slider:focus-within { 217 | outline: 2.5px solid white; 218 | border-radius: 420px; 219 | } 220 | 221 | .popup { 222 | position: fixed; 223 | display: none; 224 | width: 100%; 225 | height: 100%; 226 | top: 0; left: 0; right: 0; bottom: 0; 227 | background-color: rgba(0, 0, 0, 0.66); 228 | z-index: 2; 229 | text-align: center; 230 | } 231 | 232 | .popupbox { 233 | width: 100%; 234 | height: 100%; 235 | display: flex; 236 | align-items: center; 237 | justify-content: center; 238 | } 239 | 240 | .popupbox .box { 241 | width: 650px; 242 | } 243 | 244 | .popupConfirm { 245 | font-size: 22px; 246 | height: 50px; 247 | width: 150px; 248 | margin: 20px 7px 0px 7px; 249 | } 250 | 251 | .sliderspan { 252 | position: absolute; 253 | cursor: pointer; 254 | margin: 0px 0px !important; 255 | top: 0; left: 0; right: 0; bottom: 0; 256 | background-color: #aaaaaa; 257 | transition-duration: 0.25s; 258 | transition-timing-function: ease-in-out; 259 | border-radius: 420px; 260 | } 261 | 262 | .sliderspan:before { 263 | position: absolute; 264 | content: ""; 265 | height: calc(var(--sliderheight) - var(--sliderpadding) ); 266 | width: calc(var(--sliderheight) - var(--sliderpadding) ); 267 | left: var(--sliderpadding); 268 | bottom: calc(var(--sliderpadding) * 0.5); 269 | background-color: white; 270 | transition-duration: 0.25s; 271 | transition-timing-function: ease-in-out; 272 | border-radius: 420px; 273 | } 274 | 275 | input:checked + .sliderspan { 276 | background-color: var(--colon); 277 | } 278 | 279 | input:checked + .sliderspan:before { 280 | transform: translateX(calc(var(--sliderwidth) - var(--sliderheight) - (var(--sliderpadding)))); 281 | } 282 | 283 | .box { 284 | background-color: var(--lighterfg); 285 | border: 1px solid black; 286 | border-radius: 5px; 287 | padding: 10px 20px 30px 20px; 288 | margin: 20px 10px; 289 | } 290 | 291 | .details { 292 | font-size: 16px; 293 | opacity: 70%; 294 | margin-bottom: 5px; 295 | margin-top: 0px; 296 | } 297 | 298 | .settingBox { 299 | width: 600px; 300 | height: fit-content; 301 | } 302 | 303 | .settingBox h2:first-child { 304 | margin-top: 12px; 305 | } 306 | 307 | .categoryBox { 308 | cursor: pointer; 309 | width: 379px; 310 | height: 150px; 311 | padding: 10px 20px; 312 | transition-duration: 0.1s; 313 | } 314 | 315 | .categoryBox:hover, .categoryBox:focus-visible { 316 | background-color: var(--lightererfg); 317 | } 318 | 319 | .categoryBox h2 { 320 | margin-top: 6px !important; 321 | } 322 | 323 | .categoryBox img { 324 | height: 50px; 325 | margin-right: 18px; 326 | } 327 | 328 | .fulllength { 329 | width: 1260px; 330 | } 331 | 332 | .settingBreak { 333 | width: 100%; 334 | } 335 | 336 | .centerflex { 337 | display: flex; 338 | flex-direction: row; 339 | align-items: center; 340 | justify-content: flex-start; 341 | } 342 | 343 | .simpleflex { 344 | display: flex; 345 | flex-direction: row; 346 | align-items: flex-start; 347 | justify-content: flex-start; 348 | } 349 | 350 | .middleflex { 351 | display: flex; 352 | justify-content: center; 353 | align-items: center 354 | } 355 | 356 | .spacedflex * { 357 | margin-right: 10px; 358 | } 359 | 360 | .field { 361 | display: flex; 362 | flex-direction: column; 363 | align-items: flex-start; 364 | justify-content: flex-start; 365 | } 366 | 367 | .bottomBar { 368 | position: absolute; 369 | bottom: 3px; 370 | left: 15px; 371 | } 372 | 373 | .multiplierDescription { 374 | opacity: 100%; 375 | margin-top: 10px; 376 | height: 42px; 377 | width: 420px; 378 | } 379 | 380 | #header { 381 | display: flex; 382 | flex-direction: row; 383 | align-items: center; 384 | width: 100%; 385 | background-color: var(--fg); 386 | height: 50px; 387 | border-bottom: 1px solid black; 388 | z-index: 1; 389 | overflow: hidden; 390 | } 391 | 392 | #header div { 393 | display: flex; 394 | align-items: center; 395 | height: 100%; 396 | margin: 0px 25px; 397 | cursor: pointer; 398 | } 399 | 400 | #header div:focus-visible { 401 | outline: none; 402 | } 403 | 404 | #header h2 { 405 | margin: 0px 0px; 406 | font-size: 18px; 407 | font-weight: 700; 408 | transition-duration: 0.1s; 409 | } 410 | 411 | #header div:hover h2, #header div:focus-visible h2 { 412 | color: var(--polarisgreen); 413 | } 414 | 415 | #header div:focus-visible h2 { 416 | text-decoration: underline; 417 | } 418 | 419 | #header img { 420 | height: 70%; 421 | width: 36px; 422 | margin-right: 12px; 423 | } 424 | 425 | #header a { 426 | display: flex; 427 | align-items: center; 428 | text-decoration: none !important; 429 | width: 100%; 430 | height: 100%; 431 | } 432 | 433 | #sidebar { 434 | position: fixed; 435 | display: flex; 436 | flex-direction: column; 437 | width: 225px; 438 | height: 100%; 439 | background-color: var(--fg); 440 | margin-right: 32px; 441 | border-right: 1px solid black; 442 | overflow: hidden; 443 | white-space: nowrap; 444 | z-index: 1; 445 | } 446 | 447 | #sidebar div { 448 | height: 70px; 449 | display: flex; 450 | flex-direction: row; 451 | align-items: center; 452 | justify-content: flex-start; 453 | padding-left: 15px; 454 | cursor: pointer; 455 | } 456 | 457 | #sidebar div:focus-visible { 458 | background: rgba(255, 255, 255, 0.05); 459 | } 460 | 461 | #sidebar div:hover { 462 | background-color: var(--evenlighterfg) !important; 463 | } 464 | 465 | #sidebar div.current { 466 | color: var(--colon); 467 | font-weight: bold; 468 | background-color: var(--lighterfg); 469 | } 470 | 471 | #sidebar div img { 472 | width: 32px; 473 | height: 32px; 474 | margin-right: 16px; 475 | } 476 | 477 | #unsavedWarning { 478 | position: fixed; 479 | display: flex; 480 | pointer-events: none; 481 | justify-content: center; 482 | z-index: 2; 483 | width: 100%; 484 | height: 50px; 485 | bottom: 10px; 486 | transition-duration: 0.25s; 487 | transition-timing-function: ease-in-out; 488 | transition-property: transform; 489 | transform: translateY(60px); 490 | } 491 | 492 | #unsavedWarning.activeWarning { 493 | transform: translateY(0px); 494 | pointer-events: all; 495 | } 496 | 497 | .unsavedBox { 498 | display: flex; 499 | justify-content: space-between; 500 | align-items: center; 501 | width: 300px; 502 | border: 1px solid black; 503 | border-radius: 8px; 504 | background-color: var(--fg); 505 | padding: 0px 20px; 506 | } 507 | 508 | .unsavedBox button { 509 | height: 34px; 510 | width: 69px; 511 | } 512 | 513 | .configboxes { 514 | display: flex; 515 | flex-wrap: wrap; 516 | justify-content: flex-start; 517 | align-items: flex-start; 518 | padding-bottom: 20px; 519 | margin-left: 240px; 520 | } 521 | 522 | .sideoption { 523 | width: 600px; 524 | } 525 | 526 | .optionRow { 527 | margin-bottom: 16px; 528 | } 529 | 530 | .curvefield p { 531 | margin-left: 4px; 532 | margin-right: 15px; 533 | } 534 | 535 | .desmos { 536 | width: 512px; 537 | height: 512px; 538 | } 539 | 540 | .flexTable { 541 | overflow-y: auto; 542 | width: min-content; 543 | max-height: 750px; 544 | } 545 | 546 | .flexTable div p { 547 | padding: 7px 50px 7px 10px; 548 | margin: 0px 0px; 549 | border: 1px solid black; 550 | background-color: var(--lighterfg); 551 | white-space: nowrap; 552 | overflow: hidden; 553 | } 554 | 555 | .flexTable div p:nth-child(2n) { 556 | background-color: var(--lightererfg); 557 | } 558 | 559 | .longname { 560 | text-overflow: ellipsis; 561 | overflow: hidden; 562 | } 563 | 564 | .deleteRow { 565 | text-align: center; 566 | padding-left: 0px !important; 567 | padding-right: 0px !important; 568 | cursor: pointer; 569 | } 570 | 571 | #rewards div p:hover:not(:first-child) { background-color: var(--waylighterfg); } 572 | 573 | .deleteRow:hover { background-color: var(--emojired) !important } 574 | 575 | .toggleRow:hover { 576 | cursor: pointer; 577 | background-color: var(--emojiblue) !important; 578 | } 579 | 580 | .varList select { 581 | width: 120px; 582 | margin-right: 10px; 583 | } 584 | 585 | .serverOption { 586 | height: 90px; 587 | display: flex; 588 | flex-direction: row !important; 589 | align-items: center; 590 | justify-content: space-between; 591 | width: 80%; 592 | max-width: 850px; 593 | min-width: 500px; 594 | background-color: var(--lighterfg); 595 | border: 1px solid black; 596 | border-radius: 8px; 597 | margin: 0px auto 3px auto; 598 | padding: 0px 10px; 599 | overflow: hidden; 600 | cursor: pointer; 601 | transition-duration: 0.1s; 602 | } 603 | 604 | .serverOption:hover, .serverOption:focus-visible { 605 | background-color: var(--lightererfg); 606 | } 607 | 608 | .serverOption img[sv=icon] { 609 | height: 65px; 610 | border-radius: 420px; 611 | margin-left: 10px; 612 | margin-right: 20px; 613 | } 614 | 615 | .serverOption p { 616 | margin: 4px 0px; 617 | white-space: nowrap; 618 | overflow: hidden; 619 | text-overflow: ellipsis; 620 | max-width: 600px; 621 | } 622 | 623 | .serverOption .serverIcons img { 624 | cursor: pointer; 625 | height: 48px; 626 | } 627 | 628 | .serverOption .serverIcons a { 629 | transition-duration: 0.1s; 630 | border-radius: 5px; 631 | padding: 5px 5px; 632 | margin: 5px 5px; 633 | display: none; 634 | } 635 | 636 | .serverOption .serverIcons a:hover, .serverOption .serverIcons a:focus-visible { 637 | transform: scale(1.1); 638 | } 639 | 640 | .serverBreak { 641 | height: 30px; 642 | } 643 | 644 | .leaderboardBox { 645 | width: 90%; 646 | min-width: 500px; 647 | max-width: 1500px; 648 | background-color: var(--lighterfg); 649 | border: 1px solid black; 650 | border-radius: 8px; 651 | margin: auto; 652 | display: flex; 653 | flex-direction: column; 654 | padding: 0px 10px; 655 | } 656 | 657 | .leaderboardSlot { 658 | height: 100px; 659 | width: 100%; 660 | border-radius: 2px; 661 | display: flex; 662 | justify-content: space-between; 663 | align-items: center; 664 | } 665 | 666 | .leaderboardSlot .mainInfo { 667 | display: flex; 668 | align-items: center; 669 | overflow: hidden; 670 | } 671 | 672 | .leaderboardSlot:not(:last-child) { 673 | border-bottom: 1px solid rgba(255, 255, 255, 0.25); 674 | } 675 | 676 | .leaderboardSlot.canManage { 677 | cursor: pointer; 678 | transition-duration: 0.1s; 679 | transition-property: background-color; 680 | } 681 | 682 | .leaderboardSlot.isSelf { 683 | background-color: rgba(255, 255, 255, 0.2); 684 | } 685 | 686 | .leaderboardSlot.canManage:hover, .leaderboardSlot.canManage:focus-visible { 687 | background-color: rgba(255, 255, 255, 0.15); 688 | } 689 | 690 | .hideFromLeaderboard:hover { 691 | color: yellow !important; 692 | opacity: 100% !important; 693 | font-weight: bold !important; 694 | } 695 | 696 | .highlightedSlot { 697 | background-color: rgba(255, 255, 255, 0.25); 698 | border-bottom: none !important; 699 | } 700 | 701 | .plsLogIn { 702 | display: none; 703 | text-align: center; 704 | margin-bottom: 20px; 705 | color: aqua; 706 | } 707 | 708 | .leaderboardSlot p { 709 | font-size: 20px; 710 | margin: 4px 0px; 711 | white-space: nowrap; 712 | } 713 | 714 | .leaderboardSlot h2[lb=rank] { 715 | margin: 0px 0px; 716 | width: 200px; 717 | text-align: center; 718 | width: 60px; 719 | } 720 | 721 | .leaderboardSlot.notInServer .generalInfo p { 722 | text-decoration: line-through; 723 | font-weight: normal !important; 724 | opacity: 66%; 725 | } 726 | 727 | .progressBar { 728 | width: 100%; 729 | background-color: rgba(0, 0, 0, 0.33); 730 | border-radius: 16px; 731 | height: 60px; 732 | overflow: hidden; 733 | position: relative; 734 | } 735 | 736 | .progressBar .progress { 737 | height: 100%; 738 | } 739 | 740 | .progressBar .xpOverlay { 741 | position: absolute; 742 | display: flex; 743 | justify-content: center; 744 | flex-direction: column; 745 | margin: 0px 0px; 746 | padding-left: 12px; 747 | height: 100%; 748 | top: 0px; 749 | width: 100%; 750 | } 751 | 752 | .progressBar .xpOverlay p { 753 | line-height: 25px; 754 | margin: 0px 0px; 755 | font-size: 18px; 756 | font-weight: bold; 757 | text-shadow: 0.5px 0.5px 3px black, 0.5px 0.5px 3px black; 758 | white-space: nowrap; 759 | } 760 | 761 | .progressBar .xpOverlay .editIcon { 762 | position: absolute; 763 | right: 30px; 764 | height: 30px; 765 | filter: drop-shadow(0.5px 0.5px 3px black); 766 | display: none; 767 | } 768 | 769 | .leaderboardSlot p[lb=multiplier] { 770 | font-size: 15px; 771 | } 772 | 773 | .accountBox p { 774 | font-size: 22px; 775 | } 776 | 777 | .accountBox img[lb=pfp] { 778 | border-radius: 420px; 779 | height: 100px; 780 | margin-right: 25px; 781 | } 782 | 783 | .statBox { 784 | display: flex; 785 | flex-direction: column; 786 | align-items: center; 787 | margin-top: 20px; 788 | max-width: 400px; 789 | } 790 | 791 | .statBox p { 792 | font-weight: bold; 793 | margin: 0px 0px; 794 | } 795 | 796 | .statBox p[lb] { 797 | font-weight: normal; 798 | margin: 7px 0px 40px 0px; 799 | } 800 | 801 | .emptyLbBox { 802 | display: flex; 803 | flex-wrap: wrap; 804 | justify-content: center; 805 | width: 90%; 806 | min-width: 500px; 807 | max-width: 1500px; 808 | margin: auto; 809 | } 810 | 811 | .hiddenMemberSlot { 812 | display: flex; 813 | flex-direction: column; 814 | justify-content: center; 815 | background-color: var(--lighterfg); 816 | border: 1px solid black; 817 | text-align: center; 818 | border-radius: 5px; 819 | width: 275px; 820 | height: 140px; 821 | overflow: hidden; 822 | margin: 10px 10px; 823 | } 824 | 825 | .hiddenMemberSlot p[lb="unhide"] { 826 | width: fit-content; 827 | margin: auto; 828 | padding: 5px 20px; 829 | font-size: 20px; 830 | font-weight: bold; 831 | color: var(--polarisgreen); 832 | cursor: pointer; 833 | text-decoration: underline; 834 | } 835 | 836 | #lbButtons button { 837 | margin: 0px 10px; 838 | width: 170px; 839 | background-color: var(--emojiblue); 840 | } 841 | 842 | #lbButtons button:not(.selectedlb) { 843 | background-color: #20384b; 844 | } 845 | 846 | #uhoh #errorhelp { 847 | display: none; 848 | opacity: 50%; 849 | font-size: 16px; 850 | margin-top: 0px 851 | } 852 | 853 | #uhoh #loginbutton { 854 | display: none; 855 | background-color: var(--emojipurple); 856 | margin-top: 5p 857 | } 858 | 859 | .coolthing { 860 | width: 100%; 861 | display: flex; 862 | align-items: center; 863 | justify-content: center; 864 | min-height: 300px; 865 | margin-top: 30px; 866 | margin-bottom: 50px; 867 | } 868 | 869 | .coolthing div { 870 | width: 450px; 871 | padding: 0px 50px; 872 | margin: 0px 20px; 873 | } 874 | 875 | .coolthing .coolimage img { 876 | background-color: rgba(255, 255, 255, 0.25); 877 | border-radius: 8px; 878 | width: 450px; 879 | transition-duration: 0.2s; 880 | transition-timing-function: ease-in-out; 881 | } 882 | 883 | .coolthing .coolimage img:hover { 884 | transform: scale(1.2); 885 | } 886 | 887 | .coolthing .cooltext { 888 | min-width: 350px; 889 | } 890 | 891 | .coolthing .cooltext p { 892 | opacity: 75%; 893 | } 894 | 895 | .red { color: red } 896 | 897 | /* @media screen and (max-width: 800px) { 898 | #sidebar { width: 62px; min-width: 62px; } 899 | #sidebar p { display: none; } 900 | } */ -------------------------------------------------------------------------------- /app/html/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Nowhere 8 | 9 | 76 | 77 | 78 | 79 | 80 |
81 | 82 | 83 |
84 |

this page does not exist

85 |
86 |
87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 103 | 104 | -------------------------------------------------------------------------------- /app/html/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Polaris 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 18 |
19 |

Polaris Open

20 |

A fully customizable, bullshit-free levelling bot.

21 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 |

Originally created by Colon :

33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 65 | 66 | -------------------------------------------------------------------------------- /app/html/servers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Polaris 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

Loading...

16 | 17 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 172 | 173 | -------------------------------------------------------------------------------- /app/js/extras.js: -------------------------------------------------------------------------------- 1 | function Fetch(url, settings={}) { 2 | return new Promise(function (res, rej) { 3 | fetch(url).then(r => { 4 | if (r.ok) return settings.text ? r.text() : r.json() 5 | else return r.json() 6 | }) 7 | .then(r => { 8 | r.apiError ? rej(r) : res(r) 9 | }) 10 | .catch(rej) 11 | }); 12 | } 13 | 14 | 15 | function timeStr(ms, decimals=0, noS, shortTime) { 16 | if (ms > 3e16) return "Forever" 17 | function timeFormat(amount, str) { 18 | amount = +amount 19 | return `${commafy(amount)} ${str}${noS || amount == 1 ? "" : "s"}` 20 | } 21 | ms = Math.abs(ms) 22 | let seconds = (ms / 1000).toFixed(0) 23 | let minutes = (ms / (1000 * 60)).toFixed(decimals) 24 | let hours = (ms / (1000 * 60 * 60)).toFixed(decimals) 25 | let days = (ms / (1000 * 60 * 60 * 24)).toFixed(decimals) 26 | let years = (ms / (1000 * 60 * 60 * 24 * 365)).toFixed(decimals) 27 | if (seconds < 1) return timeFormat((ms / 1000).toFixed(2), shortTime ? "sec" : "second") 28 | if (seconds < 60) return timeFormat(seconds, shortTime ? "sec" : "second") 29 | else if (minutes < 60) return timeFormat(minutes, shortTime ? "min" : "minute") 30 | else if (hours <= 24) return timeFormat(hours, "hour") 31 | else if (days <= 365) return timeFormat(days, "day") 32 | else return timeFormat(years, "year") 33 | } 34 | 35 | function addUhOh() { 36 | $('body').append(``) 41 | } 42 | 43 | function loginButton() { 44 | localStorage.polaris_url = window.location.pathname 45 | window.location.href = "/discord" 46 | } 47 | 48 | let mobile = ( /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent) ) -------------------------------------------------------------------------------- /classes/DatabaseModel.js: -------------------------------------------------------------------------------- 1 | // for the sake of the open source project, i've moved all the database methods here. maybe it'll help if you wanna switch to a different db 2 | 3 | const mongoose = require("mongoose") 4 | 5 | // connect to database 6 | const uri = process.env.MONGO_DB_URI 7 | const dbName = process.env.MONGO_DB_NAME || "polaris" 8 | const dbSettings = uri ? { dbName } : { dbName, user: process.env.MONGO_DB_USERNAME, pass: process.env.MONGO_DB_PASSWORD } 9 | 10 | mongoose.connect(uri || `mongodb://${process.env.MONGO_DB_IP}`, dbSettings) 11 | .then(() => console.log(`Database connected! (${+process.uptime().toFixed(2)} secs)`)) 12 | .catch(e => { console.error('\x1b[40m\x1b[31m%s\x1b[0m', "!!! Error connecting to the database !!!"); console.error(e) }) 13 | 14 | class Model { 15 | constructor(collectionName, schema) { 16 | this.schema = schema; 17 | this.model = mongoose.model(collectionName, this.schema); 18 | 19 | this.fetch = (id, filter, options) => this.model.findById(id, filter, options); 20 | this.update = (id, data, options) => this.model.findByIdAndUpdate(id, data, options); 21 | this.create = (data, options) => this.model.create(data, options); 22 | this.find = (query, filter, options) => this.model.find(query, filter, options); 23 | this.delete = (query, options) => this.model.deleteMany(query, options); 24 | } 25 | } 26 | 27 | module.exports = Model; -------------------------------------------------------------------------------- /classes/LevelUpEmbed.js: -------------------------------------------------------------------------------- 1 | const Discord = require("discord.js") 2 | const Tools = require("./Tools.js") 3 | 4 | class LevelUpEmbed { 5 | constructor(data) { 6 | 7 | this.extraContent = null 8 | this.messageEmbed = null 9 | 10 | try { 11 | data = JSON.parse(data) 12 | let embed = data?.embeds[0] 13 | if (!embed || Array.isArray(embed) || typeof embed != "object") this.invalid = true 14 | 15 | if (data.content) this.extraContent = data.content 16 | this.messageEmbed = new Discord.EmbedBuilder(embed); // embed builder helps validate things 17 | 18 | } 19 | catch(e) { 20 | console.log(e) 21 | this.invalid = true 22 | } 23 | 24 | } 25 | 26 | json(returnFull=true) { 27 | if (this.invalid || !this.messageEmbed) return null 28 | let jsonData = this.messageEmbed.toJSON() 29 | delete jsonData.type 30 | 31 | // delete null values 32 | for (const [key, val] of Object.entries(jsonData)) { 33 | if (val === null || val === undefined) delete jsonData[key] 34 | } 35 | 36 | let fullData = returnFull ? { content: this.extraContent || undefined, embeds: [ jsonData ] } : jsonData 37 | return fullData 38 | } 39 | } 40 | 41 | module.exports = LevelUpEmbed; -------------------------------------------------------------------------------- /classes/LevelUpMessage.js: -------------------------------------------------------------------------------- 1 | const ordinal = require('ordinal/indicator'); 2 | const LevelUpEmbed = require("./LevelUpEmbed.js") 3 | const Tools = require("./Tools.js") 4 | const tools = Tools.global 5 | 6 | const ifLevelRegex = /\[\[\s*IFLEVEL\s*([=> x.level == data.level).map(x => roleList.find(r => r.id == x.id)).filter(x => x) 19 | 20 | if (settings.levelUp.rewardRolesOnly && !this.rewardRoles.length && !data.example) { 21 | this.invalid = true; 22 | return 23 | } 24 | 25 | this.variables = { 26 | "LEVEL": tools.commafy(data.level), 27 | "OLD_LEVEL": tools.commafy(data.oldLevel ?? data.level - 1), 28 | "XP": tools.commafy(data.userData.xp), 29 | "NEXT_LEVEL": Math.min(data.level + 1, settings.maxLevel), 30 | "NEXT_XP": tools.commafy(tools.xpForLevel(data.level + 1, settings) - data.userData.xp), 31 | "@": `<@${message.author.id}>`, 32 | "USERNAME": message.author.username, 33 | "DISPLAYNAME": message.author.displayName, 34 | "DISCRIM": message.author.discriminator, 35 | "ID": message.author.id, 36 | "NICKNAME": message.member.displayName, 37 | "AVATAR": message.member.avatarLink || message.member.displayAvatarURL({format: "png", dynamic: true}), 38 | "SERVER": message.guild.name, 39 | "SERVER_ID": message.guild.id, 40 | "SERVER_ICON": message.guild.iconLink || message.guild.iconURL({format: "png", dynamic: true}) || "", 41 | "CHANNEL": `<#${message.channel.id}>`, 42 | "CHANNEL_NAME": message.channel.name, 43 | "CHANNEL_ID": message.channel.id, 44 | "ROLE": this.rewardRoles.map(x => `<@&${x.id}>`).join(" "), 45 | "ROLE_NAME": this.rewardRoles.map(x => x.name).join(", "), 46 | "TIMESTAMP": Math.round(Date.now() / 1000), 47 | "EMBEDTIMESTAMP": new Date().toISOString() 48 | } 49 | 50 | if (settings.levelUp.embed) { 51 | let mbed = new LevelUpEmbed(this.msg) 52 | if (mbed.invalid) { 53 | this.msg = "" 54 | this.invalid = true 55 | } 56 | else { 57 | let mbedJSON = mbed.json(false) 58 | 59 | // add vars to all strings 60 | for (const [key, val] of Object.entries(mbedJSON)) { 61 | if (typeof val == "string") mbedJSON[key] = this.subVariables(val) 62 | 63 | // go one extra layer deep lmao 64 | else if (val && typeof val == "object" && !Array.isArray(val)) { 65 | for (const [key2, val2] of Object.entries(val)) { 66 | if (typeof val2 == "string") mbedJSON[key][key2] = this.subVariables(val2) 67 | } 68 | } 69 | } 70 | 71 | // add vars to fields 72 | if (mbedJSON.fields && mbedJSON.fields.length) { 73 | mbedJSON.fields = mbedJSON.fields.map(f => ({ name: this.subVariables(f.name), value: this.subVariables(f.value), inline: f.inline })) 74 | } 75 | 76 | this.msg = { embeds: [ mbedJSON ] } 77 | if (mbed.extraContent) this.msg.content = this.subVariables(mbed.extraContent) 78 | } 79 | } 80 | 81 | else this.msg = { content: this.subVariables(this.msg) } 82 | 83 | if (this.msg) this.msg.reply = { messageReference: message.id } 84 | } 85 | 86 | subVariables(msg) { 87 | 88 | if (!msg) return msg 89 | let newMsg = msg.replace(/\n/g, " ") 90 | let newLevel = this.level 91 | 92 | // simple variables 93 | let vars = this.variables 94 | newMsg = newMsg.replace(/\[\[[A-Z@_ ]+\]\]/g, function(str) { 95 | let v = str.slice(2, -2).trim() 96 | return vars[v] ?? str 97 | }) 98 | 99 | // random choose 100 | newMsg = newMsg.replace(/\[\[\s*CHOOSE.+?\]\]/g, function(str) { 101 | let pool = [] 102 | let totalWeight = 0 103 | let choose = str.slice(2, -2).split(/(? x.trim()).filter(x => x) // split at one | but not more 104 | choose[0] = choose[0].replace(/^\s*CHOOSE\s*/, "") 105 | if (!choose[0]) choose.shift() 106 | 107 | let chooseRegex = /^<([\d.]+)>\s+/ 108 | if (choose.some(x => x.match(chooseRegex))) { // if list has weighting... 109 | choose.forEach(c => { 110 | let weightMatch = c.match(chooseRegex) 111 | let weight = weightMatch ? (Number(weightMatch[1])) || 1 : 1 112 | if (weight > 0) { 113 | weight = tools.clamp(Math.round(weight * 500), 1, 1e6) 114 | pool.push({ msg: c.replace(chooseRegex, ""), weight, index: totalWeight }) 115 | totalWeight += weight 116 | } 117 | }) 118 | 119 | let roll = tools.rng(0, totalWeight) 120 | let finalChoice = pool.reverse().find(x => roll >= x.index) 121 | return finalChoice.msg 122 | } 123 | 124 | else return tools.choose(choose) 125 | }) 126 | 127 | // if level 128 | newMsg = newMsg.replace(new RegExp(ifLevelRegex, "g"), function(str) { 129 | let match = str.match(ifLevelRegex) 130 | let [all, operation, lvl, data] = match 131 | if (!data) return 132 | data = (data).trim() 133 | lvl = Number(lvl) 134 | if (isNaN(lvl)) return "" 135 | 136 | switch (operation.trim()) { 137 | case ">": return (newLevel > lvl ? data : "") 138 | case "<": return (newLevel < lvl ? data : "") 139 | case ">=": case "=>": return (newLevel >= lvl ? data : "") 140 | case "<=": case "=<": return (newLevel <= lvl ? data : "") 141 | case "!=": case "=!": case "=/": case "=/=": return (newLevel != lvl ? data : "") 142 | case "/": case "%": return (newLevel % lvl == 0 ? data : "") 143 | default: return (newLevel == lvl ? data : "") 144 | } 145 | }) 146 | 147 | let rewardRoles = this.rewardRoles 148 | 149 | // if role 150 | newMsg = newMsg.replace(/\[\[\s*IFROLE\s*\|.+?\]\]/g, function(str) { 151 | if (!rewardRoles.length) return "" 152 | else return str.split("|").slice(1).join("|").slice(0, -2) 153 | }) 154 | 155 | // if no role 156 | newMsg = newMsg.replace(/\[\[\s*IFNOROLE\s*\|.+?\]\]/g, function(str) { 157 | if (rewardRoles.length) return "" 158 | else return str.split("|").slice(1).join("|").slice(0, -2) 159 | }) 160 | 161 | // nth 162 | newMsg = newMsg.replace(new RegExp(ordinalRegex, "g"), function(str) { 163 | let match = str.match(ordinalRegex) 164 | if (match) { 165 | let num = (Number(match[1]) || 0) 166 | let spacing = match[2] || "" 167 | return `${num}${spacing}${ordinal(num)}` 168 | } 169 | }).replace(/\[\[\s*NTH\s*\]\]/g, "") 170 | 171 | return newMsg.replace(/ /g, "\n").trim() 172 | 173 | } 174 | 175 | async send() { 176 | if (!this.msg || this.invalid) return 177 | let sendChannel = this.channel 178 | let ch = 179 | (sendChannel == "current") ? this.userMessage.channel 180 | : (sendChannel == "dm") ? this.userMessage.author 181 | : await this.userMessage.guild.channels.fetch(sendChannel).catch(() => {}) 182 | 183 | if (ch && ch.id) ch.send(this.msg).catch((e) => { 184 | ch.send(`**Error sending level up message!**\n\`\`\`${e.message}\`\`\`\n(anyways, congrats on level ${this.variables.LEVEL}!)`).catch(() => {}) 185 | }) 186 | } 187 | } 188 | 189 | module.exports = LevelUpMessage; -------------------------------------------------------------------------------- /classes/PageEmbed.js: -------------------------------------------------------------------------------- 1 | const Tools = require("./Tools.js") 2 | const tools = Tools.global 3 | 4 | const activeCollectors = {} 5 | 6 | class PageEmbed { 7 | constructor(embed, data, config={}) { 8 | 9 | this.fullData = data 10 | 11 | this.embed = embed 12 | this.page = config.page || 1 13 | this.size = config.size || 10 14 | this.extraButtons = config.extraButtons || [] 15 | this.mapFunction = config.mapFunction 16 | this.timeoutSecs = config.timeoutSecs || 30 17 | this.ownerID = config.owner 18 | this.ephemeral = config.ephemeral 19 | 20 | this.suffix = embed.data.description 21 | this.footer = embed.data.footer?.text 22 | 23 | this.pages = Math.floor((this.fullData.length-1) / this.size) + 1 24 | if (this.page < 0) this.page = this.pages + this.page + 1 // if page is negative, start from last page 25 | 26 | this.data = this.paginate() 27 | this.setDesc() 28 | 29 | this.int = null 30 | 31 | if (this.ownerID) { 32 | let foundCollector = activeCollectors[this.ownerID] 33 | if (foundCollector) { 34 | foundCollector.stop() 35 | delete activeCollectors[this.ownerID] 36 | } 37 | } 38 | 39 | return this 40 | } 41 | 42 | paginate(pg=this.page) { 43 | return this.fullData.slice((pg - 1) * this.size, (pg - 1) * this.size + this.size) 44 | } 45 | 46 | setDesc() { 47 | let currentData = this.data 48 | if (typeof this.mapFunction == "function") currentData = currentData.map((x, y) => { 49 | let truePos = y + ((this.page - 1) * this.size) + 1 50 | return this.mapFunction(x, y, truePos) 51 | }) 52 | return this.embed.setDescription(currentData.join("\n") + (this.suffix ? `\n${this.suffix}` : "")) 53 | } 54 | 55 | post(int, msgSettings={}) { 56 | 57 | let firstPage = (this.page == 1) 58 | let lastPage = (this.page >= this.pages) 59 | 60 | let pageOptions = [ 61 | {style: firstPage ? "Secondary" : "Success", label: `<< Page ${firstPage ? this.pages : Math.max((this.page - 1) || 1, 1)}`, customId: 'prev'}, 62 | {style: lastPage ? "Secondary" : "Success", label: `Page ${lastPage ? 1 : Math.min((this.page + 1), this.pages)} >>`, customId: 'next'} 63 | ] 64 | 65 | if (this.pages == 2) { 66 | if (this.page == 1) pageOptions.shift() 67 | else pageOptions.splice(1, 1) 68 | } 69 | 70 | let pageButtons = this.pages <= 1 ? this.extraButtons : tools.button(pageOptions).concat(this.extraButtons) 71 | 72 | let footerText = this.footer || "" 73 | if (this.pages > 1) footerText += `\nPage ${this.page} of ${this.pages}` 74 | if (footerText) this.embed.setFooter({text: footerText}) 75 | 76 | let pgButtonRow = pageButtons[0] ? tools.row(pageButtons) : null 77 | 78 | if (!this.int) return int.reply(Object.assign({ embeds: [this.embed], components: pgButtonRow, fetchReply: true, ephemeral: this.ephemeral }, msgSettings)).then(msg => { 79 | this.int = int 80 | if (this.pages > 1) this.handleButtons(msg, pageButtons) 81 | }).catch(() => {}) 82 | 83 | else return this.int.editReply({embeds: [this.embed], components: pgButtonRow }).then(msg => { 84 | this.handleButtons(msg, pageButtons) 85 | }).catch(() => {}) 86 | } 87 | 88 | handleButtons(msg, buttons) { 89 | let buttonPressed = false 90 | let collector = msg.createMessageComponentCollector({ time: this.timeoutSecs * 1000 }) 91 | if (this.ownerID) activeCollectors[this.ownerID] = collector 92 | collector.on('collect', b => { 93 | if (buttonPressed || !tools.canPressButton(b, [this.ownerID])) return tools.buttonReply(b) 94 | else buttonPressed = true 95 | collector.stop() 96 | 97 | switch (b.customId) { 98 | case "prev": { this.setPage(-1, b); return this.post() } 99 | case "next": { this.setPage(1, b); return this.post() } 100 | } 101 | }) 102 | collector.on('end', b => { 103 | if (!buttonPressed) { 104 | this.int.editReply({ components: tools.disableButtons(buttons) }) 105 | this.destroy() 106 | } 107 | }) 108 | return msg.id 109 | } 110 | 111 | setPage(change, button, exact) { 112 | if (button) button.deferUpdate() 113 | let oldPage = this.page 114 | 115 | this.page = exact ? change : oldPage + change 116 | if (this.page < 1) this.page = this.pages 117 | if (this.page > this.pages) this.page = 1 118 | 119 | if (oldPage == this.page) return 120 | this.data = this.paginate() 121 | this.embed = this.setDesc() 122 | } 123 | 124 | destroy() { 125 | delete activeCollectors[this.ownerID] 126 | this.fullData = null 127 | this.data = null 128 | this.embed = null 129 | } 130 | } 131 | 132 | module.exports = PageEmbed; -------------------------------------------------------------------------------- /classes/Tools.js: -------------------------------------------------------------------------------- 1 | const config = require('../config.json') 2 | const Discord = require('discord.js') 3 | 4 | // this class contains all sorts of misc functions used around the bot 5 | 6 | class Tools { 7 | constructor(client, int) { 8 | 9 | this.WEBSITE = config.siteURL 10 | if (!this.WEBSITE.startsWith("http")) this.WEBSITE = "https://gdcolon.com/polaris" // backup URL or some buttons will break 11 | 12 | this.COLOR = 0x00ff80 // polaris green 13 | 14 | // has manage guild perm 15 | this.canManageServer = function(member=int?.member, nahnvm) { 16 | return nahnvm || (member && member.permissions.has(Discord.PermissionFlagsBits.ManageGuild)) 17 | } 18 | 19 | // has manage roles perm 20 | this.canManageRoles = function(member=int?.member, nahnvm) { 21 | return nahnvm || (member && member.permissions.has(Discord.PermissionFlagsBits.ManageRoles)) 22 | } 23 | 24 | // is in developer list 25 | this.isDev = function(user=int.user) { 26 | return config.developer_ids.includes(user?.id) 27 | } 28 | 29 | // converts a string (e.g. "rank") into a clickable slash command 30 | this.commandTag = function(cmd) { 31 | let foundCmd = client.application.commands.cache.find(x => x.name == cmd && x.type == Discord.ApplicationCommandType.ChatInput) 32 | return foundCmd?.id ? ``: `\`/${cmd}\`` 33 | } 34 | 35 | // some common error messages 36 | this.errors = { 37 | xpDisabled: `XP is not enabled in this server!${this.canManageServer() ? ` (enable with ${this.commandTag("config")})` : ""}`, 38 | noData: "This server doesn't have any data yet!", 39 | noBotXP: "Bots can't earn XP, silly!", 40 | cantManageRoles: "I don't have permission to manage roles!", 41 | notMod: "You don't have permission to use this command!" 42 | } 43 | 44 | // fetch settings from db/cache (+ some xp) 45 | this.fetchSettings = async function(userID, serverID=int.guild.id) { 46 | let data = await client.db.fetch(serverID, ["settings", userID ? `users.${userID}` : null]) 47 | if (!data) { 48 | await client.db.create({ _id: serverID }) 49 | return await this.fetchSettings(userID, serverID) 50 | } 51 | if (!data.users) data.users = {} 52 | return data 53 | } 54 | 55 | // fetch all xp in the server 56 | this.fetchAll = async function(serverID=int.guild.id) { 57 | return await client.db.fetch(serverID).then(data => { 58 | if (!data) return 59 | return data 60 | }) 61 | } 62 | 63 | // calculates current level from xp 64 | this.getLevel = function(xp, settings, returnRequirement) { 65 | let lvl = 0 66 | let previousLevel = 0 67 | let xpRequired = 0 68 | while (xp >= xpRequired && lvl <= settings.maxLevel) { // cubic formula my ass, here's a while loop. could probably binary search this? 69 | lvl++ 70 | previousLevel = xpRequired 71 | xpRequired = this.xpForLevel(lvl, settings) 72 | } 73 | lvl-- 74 | return returnRequirement ? { level: lvl, xpRequired, previousLevel } : lvl 75 | } 76 | 77 | // calculate xp to reach a level 78 | this.xpForLevel = function(lvl, settings) { 79 | if (lvl > settings.maxLevel) lvl = settings.maxLevel 80 | let xpRequired = Object.entries(settings.curve).reduce((total, n) => total + (n[1] * (lvl ** n[0])), 0) 81 | return settings.rounding > 1 ? settings.rounding * Math.round(xpRequired / settings.rounding) : Math.round(xpRequired) 82 | } 83 | 84 | // get expected reward roles for a certain level 85 | this.getRolesForLevel = function(lvl, rewards) { 86 | if (!lvl || !rewards) return [] 87 | 88 | let levelRoles = rewards.filter(x => x.level <= lvl) // get all reward roles less than or equal to level 89 | .sort((a, b) => b.level - a.level) // sort from highest to lowest level 90 | 91 | let topRole = levelRoles[0] // get highest level role 92 | if (topRole) levelRoles = levelRoles.filter(x => x.keep || (x.level == topRole.level)) // remove the rest of the non-keep roles 93 | 94 | return levelRoles 95 | } 96 | 97 | // check which level roles member should and shouldn't have 98 | this.checkLevelRoles = function(allRoles, roles, lvl, rewards, shouldHave, oldLevel) { 99 | rewards = rewards.filter(x => allRoles.some(r => r.id == x.id)) 100 | if (!oldLevel) oldLevel = lvl 101 | if (!shouldHave) shouldHave = this.getRolesForLevel(lvl, rewards) 102 | let currentLevelRoles = rewards.filter(x => roles.some(r => r.id == x.id)) 103 | 104 | let correct = [] 105 | let missing = [] 106 | shouldHave.forEach(x => { 107 | if (currentLevelRoles.some(r => r.id == x.id)) correct.push(x) 108 | else if (!x.noSync || (x.noSync && oldLevel < x.level)) missing.push(x) 109 | }) 110 | let incorrect = currentLevelRoles.filter(x => !x.noSync && !shouldHave.some(r => r.id == x.id)) 111 | 112 | return { current: currentLevelRoles, shouldHave, correct, incorrect, missing } 113 | } 114 | 115 | // adds missing level roles and removes incorrect ones 116 | this.syncLevelRoles = async function(member, list) { 117 | if (!member.guild.members.me.permissions.has(Discord.PermissionFlagsBits.ManageRoles)) return 118 | if (!list.incorrect.length && !list.missing.length) return 119 | let currentRoles = member.roles.cache 120 | let newRoles = currentRoles.map(x => x.id) 121 | .filter(x => !list.incorrect.some(r => r.id == x)) // remove incorrect roles 122 | .concat(list.missing.map(x => x.id)) // add missing roles 123 | return member.roles.set(newRoles) 124 | } 125 | 126 | // get and calculate xp multiplier (for both channels and roles) 127 | this.getMultiplier = function(member, settings, channel=int.channel) { 128 | let obj = { multiplier: 1, role: 1, channel: 1, roleList: [], channelList: [] } 129 | let memberRoles = member.roles.cache 130 | 131 | obj.rolePriority = settings.multipliers.rolePriority 132 | obj.channelStacking = settings.multipliers.channelStacking 133 | 134 | let thread = {} 135 | if (channel && channel.isThread()) { 136 | thread = channel 137 | channel = channel.parent 138 | } 139 | 140 | let channelIDs = [ thread?.id, channel?.id, channel?.parent?.id ] // channel order of priority (thread > channel > category) 141 | let foundChannelBoost = channelIDs.map(x => settings.multipliers.channels.find(c => c.id == x)).find(x => x) 142 | 143 | if (foundChannelBoost) { 144 | obj.channel = foundChannelBoost.boost 145 | obj.channelList = [foundChannelBoost] 146 | } 147 | 148 | let roleBoosts = settings.multipliers.roles.filter(x => memberRoles.has(x.id)) 149 | let foundRoleBoost; 150 | if (roleBoosts.length) { 151 | 152 | let foundXPBan = roleBoosts.find(x => x.boost <= 0) 153 | if (foundXPBan) foundRoleBoost = foundXPBan 154 | 155 | else switch (obj.rolePriority) { 156 | case "smallest": // lowest boost 157 | foundRoleBoost = roleBoosts.sort((a, b) => a.boost - b.boost)[0]; break; 158 | case "highest": // highest role 159 | let foundTopBoost = memberRoles.sort((a, b) => b.position - a.position).find(x => roleBoosts.find(y => y.id == x.id)) 160 | foundRoleBoost = roleBoosts.find(x => x.id == foundTopBoost.id); break; 161 | case "combine": // multiply all, holy shit 162 | let combined = roleBoosts.map(x => x.boost).reduce((a, b) => a * b, 1).toFixed(4) 163 | combined = Math.min(+combined, 1000000) // 1 million max 164 | obj.role = combined; obj.roleList = roleBoosts; break; 165 | case "add": // add (n-1) from each 166 | let filteredBoosts = roleBoosts.filter(x => x.boost != 1) 167 | let summed = filteredBoosts.length == 1 ? filteredBoosts[0].boost : filteredBoosts.map(x => x.boost).reduce((a, b) => a + (b-1), 1) 168 | obj.role = Number(summed.toFixed(4)); obj.roleList = filteredBoosts; break; 169 | default: // largest boost 170 | obj.rolePriority = "largest" 171 | foundRoleBoost = roleBoosts.sort((a, b) => b.boost - a.boost)[0]; break; 172 | } 173 | 174 | if (foundRoleBoost) { 175 | obj.role = foundRoleBoost.boost 176 | obj.roleList = [foundRoleBoost] 177 | } 178 | } 179 | 180 | if (obj.role <= 0 || obj.channel <= 0) obj.multiplier = 0 // 0 always takes priority 181 | else switch (settings.multipliers.channelStacking) { 182 | case "largest": obj.multiplier = Math.max(obj.role, obj.channel); break; // pick largest between channel and role 183 | case "channel": obj.multiplier = foundChannelBoost ? obj.channel : obj.role; break; // channel always takes priority if it exists 184 | case "role": obj.multiplier = foundRoleBoost ? obj.role : obj.channel; break; // role takes priority if it exists 185 | case "add": obj.multiplier = Math.max(0, 1 + (obj.role - 1) + (obj.channel - 1)); break; // add (n-1) from each 186 | default: obj.channelStacking = "multiply"; obj.multiplier = obj.role * obj.channel; break; // just multiply them together 187 | } 188 | 189 | obj.multiplier = Math.round(obj.multiplier * 10000) / 10000 190 | 191 | return obj 192 | } 193 | 194 | // error message if user has no xp 195 | this.noXPYet = function(user) { 196 | return this.warn(user.bot ? "*noBotXP" : user.id != int.user.id ? `${user.displayName} doesn't have any XP yet!` : `You don't have any XP yet!`) 197 | } 198 | 199 | // creates an embed from an object, because i despise how discord.js does it 200 | this.createEmbed = function(options={}) { 201 | let embed = new Discord.EmbedBuilder() 202 | if (options.title) embed.setTitle(options.title) 203 | if (options.description) embed.setDescription(options.description) 204 | if (options.color) embed.setColor(options.color) 205 | if (options.author) embed.setAuthor(typeof options.author == "string" ? {name: options.author, iconURL: int.member.displayAvatarURL()} : options.author) 206 | if (options.footer) embed.setFooter(typeof options.footer == "string" ? {text: options.footer} : options.footer) 207 | if (options.fields) embed.addFields(options.fields) 208 | if (options.timestamp) embed.setTimestamp() 209 | return embed 210 | } 211 | 212 | // creates a button (or multiple) 213 | this.button = function(buttonOptions) { 214 | let isArr = Array.isArray(buttonOptions) 215 | if (!isArr) buttonOptions = [buttonOptions] 216 | buttonOptions = buttonOptions.map(b => { 217 | if (typeof b.style == "string") b.style = Discord.ButtonStyle[b.style] 218 | return b 219 | }) 220 | 221 | if (isArr) return buttonOptions.map(x => new Discord.ButtonBuilder(x)) 222 | else return new Discord.ButtonBuilder(buttonOptions[0]) 223 | } 224 | 225 | // creates two confirmation buttons 226 | this.confirmButtons = function(titleText, titleColor, cancelText, cancelColor) { 227 | return this.button([ 228 | {style: titleColor || "Success", label: titleText || 'Confirm', customId: 'confirm'}, 229 | {style: cancelColor || "Danger", label: cancelText || 'Cancel', customId: 'cancel'} 230 | ]) 231 | } 232 | 233 | // check if user is allowed to interact with a button 234 | this.canPressButton = function(b, allowedUsers) { 235 | return (b.user.id.includes(allowedUsers || [int?.user.id])) 236 | } 237 | 238 | // ignore the press if the user can't press that button 239 | this.buttonReply = function(int, message) { 240 | return message ? int.reply(message) : int.deferUpdate() 241 | } 242 | 243 | // creates a component row without all the bullshit 244 | this.row = function(components) { 245 | if (!components || (Array.isArray(components) && !components[0])) return null 246 | if (!components.length) components = [components] 247 | return [new Discord.ActionRowBuilder({components})] 248 | } 249 | 250 | // disables all clickable buttons, optionally hide all except clicked 251 | this.disableButtons = function(btns, selected) { 252 | let disabledBtns = btns.map(x => x.data.style == Discord.ButtonStyle.Link ? x : x.setDisabled()) 253 | if (selected) { 254 | selected.deferUpdate() 255 | disabledBtns = disabledBtns.filter(x => x.customId == selected.customId) 256 | } 257 | return this.row(disabledBtns) 258 | } 259 | 260 | // creates a timed yes/no confirmation (options: secs, buttons, message, timeoutMessage, onClick, onTimeout) 261 | this.createConfirmationButtons = function(options={}) { 262 | 263 | let secs = options.secs || 20 264 | let buttonData = options.buttons || [] 265 | if (typeof buttonData == "string") buttonData = [buttonData] 266 | let confirmBtns = this.confirmButtons(...buttonData) 267 | 268 | let messageData = options.message || {} 269 | messageData.components = this.row(confirmBtns) 270 | messageData.fetchReply = true 271 | 272 | let activeConfirmation = true 273 | return int.reply(messageData).then(msg => { 274 | 275 | let collector = msg.createMessageComponentCollector({ time: secs * 1000 }) 276 | 277 | collector.on('collect', (b) => { 278 | if (!activeConfirmation || !this.canPressButton(b)) return this.buttonReply() 279 | 280 | else { 281 | activeConfirmation = false 282 | msg.edit({components: this.disableButtons(confirmBtns, b)}) 283 | if (options.onClick) return options.onClick(b.customId == "confirm", msg, b) 284 | } 285 | 286 | }) 287 | collector.on('end', () => { 288 | if (activeConfirmation) { 289 | msg.edit({content: `~~${messageData.content}~~\n${options.timeoutMessage}`, components: this.disableButtons(confirmBtns)}).catch(() => {}) 290 | if (options.onTimeout) return options.onTimeout(msg) 291 | } 292 | }) 293 | }) 294 | } 295 | 296 | // edit the message if possible, otherwise post as reply 297 | this.editOrReply = function(data, forceReply) { 298 | if (forceReply) int.reply(data).catch(() => null) 299 | 300 | else int.message.edit(data).catch(() => { 301 | int.reply(data).catch(() => null) 302 | }).then(() => int.deferUpdate()) 303 | } 304 | 305 | // xp is stored as an object, convert to array 306 | this.xpObjToArray = function(users) { 307 | return Object.entries(users).map(x => Object.assign({id: x[0]}, x[1])) 308 | } 309 | 310 | // sends an ephemeral reply, usually when the user did something wrong 311 | this.warn = function(msg) { 312 | if (msg.startsWith("*")) msg = this.errors[msg.slice(1)] || msg 313 | return int.reply({content: this.errors[msg] || msg, ephemeral: true}) 314 | } 315 | 316 | // get detailed position info on a channel, for sorting 317 | this.getTrueChannelPos = function(c, ChannelType=Discord.ChannelType) { 318 | let isThread = c.isThread() 319 | let channel = isThread ? c.parent : c 320 | let isCategory = channel?.type == ChannelType.GuildCategory 321 | return { 322 | group: isCategory ? channel.position : channel?.parent?.position ?? -1, 323 | section: channel && channel.isVoiceBased() ? 1 : 0, 324 | position: isCategory ? -1 : channel?.position + (isThread ? 0.5 : 0) 325 | } 326 | } 327 | 328 | // get setting from an id, e.g. "levelUp.multiple" 329 | this.getSettingFromID = function(id, settings) { 330 | let val = settings 331 | id.split(".").forEach(x => { val = val[x] }) 332 | return val; 333 | } 334 | 335 | // random number between min and max, inclusive 336 | this.rng = function(min, max) { 337 | if (max == undefined && +min) { max = min; min = 1 } // rng(5) is the same as rng(1, 5) 338 | return Math.floor(Math.random() * (max - min + 1)) + min 339 | } 340 | 341 | // randomly pick from array 342 | this.choose = function(arr) { 343 | return arr[Math.floor(Math.random() * arr.length)]; 344 | } 345 | 346 | // remove duplicates from array 347 | this.undupe = function(array) { 348 | if (!Array.isArray(array)) return array 349 | else return array.filter((x, y) => array.indexOf(x) == y) 350 | } 351 | 352 | // shuffle array 353 | this.shuffle = function(arr) { 354 | for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]] } 355 | return arr 356 | } 357 | 358 | // limit number between two values 359 | this.clamp = function(num, min, max) { 360 | return Math.min(Math.max(num, min), max) 361 | }; 362 | 363 | // cut off text once it passes a certain length and add "..." 364 | this.limitLength = function(string, max, after="...") { 365 | if (string.length <= max) return string 366 | else return string.slice(0, max) + after 367 | } 368 | 369 | // capitalize first letter of word(s) 370 | this.capitalize = function(str, all) { 371 | let text = all ? str.split(" ") : [str] 372 | text = text.map(x => x.charAt(0).toUpperCase() + x.slice(1).toLowerCase()) 373 | return text.join(" ") 374 | } 375 | 376 | // adds commas to long numbers 377 | this.commafy = function(num, locale="en-US") { 378 | return num.toLocaleString(locale, { maximumFractionDigits: 10 }) 379 | } 380 | 381 | // convert timestamp to neat string (e.g. "3 minutes") 382 | this.time = function(ms, decimals=0, noS, shortTime) { 383 | let commafy = this.commafy 384 | if (ms > 3e16) return "Forever" 385 | function timeFormat(amount, str) { 386 | amount = +amount 387 | return `${commafy(amount)} ${str}${noS || amount == 1 ? "" : "s"}` 388 | } 389 | ms = Math.abs(ms) 390 | let seconds = (ms / 1000).toFixed(0) 391 | let minutes = (ms / (1000 * 60)).toFixed(decimals) 392 | let hours = (ms / (1000 * 60 * 60)).toFixed(decimals) 393 | let days = (ms / (1000 * 60 * 60 * 24)).toFixed(decimals) 394 | let years = (ms / (1000 * 60 * 60 * 24 * 365)).toFixed(decimals) 395 | if (seconds < 1) return timeFormat((ms / 1000).toFixed(2), shortTime ? "sec" : "second") 396 | if (seconds < 60) return timeFormat(seconds, shortTime ? "sec" : "second") 397 | else if (minutes < 60) return timeFormat(minutes, shortTime ? "min" : "minute") 398 | else if (hours <= 24) return timeFormat(hours, "hour") 399 | else if (days <= 365) return timeFormat(days, "day") 400 | else return timeFormat(years, "year") 401 | } 402 | 403 | // convert timestamp to h:m:s (e.g. 4:20) 404 | this.timestamp = function(ms, useTimeIfLong) { 405 | if (useTimeIfLong && ms >= 86399000) return this.time(ms, 1) // > 1 day 406 | let secs = Math.ceil(Math.abs(ms) / 1000) 407 | if (secs < 0) secs = 0 408 | let days = Math.floor(secs / 86400) 409 | if (days) secs -= days * 86400 410 | let timestamp = `${ms < 0 ? "-" : ""}${days ? `${days}d + ` : ""}${[Math.floor(+secs / 3600), Math.floor(+secs / 60) % 60, +secs % 60].map(v => v < 10 ? "0" + v : v).filter((v,i) => v !== "00" || i > 0).join(":")}` 411 | if (timestamp.length > 5) timestamp = timestamp.replace(/^0+/, "") 412 | return timestamp 413 | } 414 | 415 | // adds either 's or ' for plural nouns 416 | this.pluralS = function(msg="", full=true) { 417 | let extraS = msg.toLowerCase().endsWith("s") ? "" : "s" 418 | return full ? msg + "'" + extraS : extraS 419 | } 420 | 421 | // adds an extra s for plurals (e.g. 1 level, 2 levels) 422 | this.extraS = function(msg, count, onlyExtra, extra={}) { 423 | let extraStr = (count == 1) ? (extra.s || "") : (extra.p || "s") 424 | return onlyExtra ? extraStr : msg + extraStr 425 | } 426 | 427 | // debug: import xp from json 428 | this.jsonImport = function(serverID, url, xpKey="xp", idKey="id",) { 429 | fetch(url).then(res => res.json()).then(list => { 430 | let xpStuff = list.map(x => ({xp: Math.round(x[xpKey]), id: x[idKey]})); 431 | let users = {}; 432 | xpStuff.forEach(x => users[x.id] = { xp: x.xp }); 433 | client.db.update(serverID, { $set: { users } }).exec().then(() => { int.channel.send({ content: "Success!" }) }) 434 | }).catch(e => { int.channel.send({ content: "Failed! " + e.message }) }) 435 | } 436 | 437 | } 438 | } 439 | 440 | Tools.global = new Tools(); // use for files that never run functions involving the client 441 | module.exports = Tools; -------------------------------------------------------------------------------- /commands/button/export_xp.js: -------------------------------------------------------------------------------- 1 | const Discord = require('discord.js') 2 | module.exports = { 3 | metadata: { 4 | name: "button:export_xp", 5 | }, 6 | 7 | async run(client, int, tools) { 8 | let db = await tools.fetchSettings() // only fetch settings before checking perms 9 | if (!db) return tools.warn("*noData") 10 | 11 | if (!tools.canManageServer(int.member, db.settings.manualPerms)) return tools.warn("*notMod") 12 | 13 | await int.deferReply({ephemeral: true}) 14 | 15 | let allData = await tools.fetchAll(); // fetch all data 16 | 17 | let jsonData = JSON.stringify({ settings: allData.settings, users: allData.users }, null, 2) 18 | let attached = new Discord.AttachmentBuilder(Buffer.from(jsonData, 'utf-8'), { name: `${int.guild.name}.json` }) 19 | return int.followUp({content: `Here's all the data for **${int.guild.name}**, as of `, files: [attached], ephemeral: true}) 20 | .catch(e => int.followUp({content: `**Something went wrong!** ${e.message}`, ephemeral: true})) 21 | 22 | }} -------------------------------------------------------------------------------- /commands/button/list_multipliers.js: -------------------------------------------------------------------------------- 1 | const PageEmbed = require("../../classes/PageEmbed.js") 2 | const Discord = require("discord.js") 3 | 4 | module.exports = { 5 | metadata: { 6 | name: "button:list_multipliers", 7 | }, 8 | 9 | async run(client, int, tools) { 10 | let db = await tools.fetchSettings() 11 | if (!db) return tools.warn("*noData") 12 | 13 | if (!tools.canManageServer(int.member, db.settings.manualPerms)) return tools.warn("*notMod") 14 | 15 | let isChannel = int.customId.split("~")[1] == "channels" 16 | let mType = isChannel ? "channel" : "role" 17 | let mList = db.settings.multipliers[isChannel ? "channels" : "roles"] 18 | 19 | if (!mList.length) return tools.warn(`This server doesn't have any ${mType} multipliers!`) 20 | 21 | let embed = tools.createEmbed({ 22 | title: `${tools.capitalize(mType)} Multipliers (${mList.length})`, 23 | color: tools.COLOR, 24 | footer: "Add or remove multipliers with /multiplier" 25 | }) 26 | 27 | let multipliers = mList.sort((a, b) => a.boost - b.boost); 28 | 29 | let categories; 30 | if (isChannel) { 31 | categories = await int.guild.channels.fetch().then(x => x.filter(c => c.type == Discord.ChannelType.GuildCategory).map(x => x.id)) 32 | } 33 | 34 | let multiplierEmbed = new PageEmbed(embed, multipliers, { 35 | size: 20, owner: int.user.id, 36 | mapFunction: (x) => `**${x.boost}x:** ${isChannel ? (categories.includes(x.id) ? `**<#${x.id}>** (category)` : `<#${x.id}>`) : `<@&${x.id}>`}` 37 | }) 38 | 39 | multiplierEmbed.post(int) 40 | 41 | }} -------------------------------------------------------------------------------- /commands/button/list_reward_roles.js: -------------------------------------------------------------------------------- 1 | const PageEmbed = require("../../classes/PageEmbed.js") 2 | 3 | module.exports = { 4 | metadata: { 5 | name: "button:list_reward_roles", 6 | }, 7 | 8 | async run(client, int, tools) { 9 | let db = await tools.fetchSettings() 10 | if (!db) return tools.warn("*noData") 11 | 12 | if (!tools.canManageServer(int.member, db.settings.manualPerms)) return tools.warn("*notMod") 13 | 14 | if (!db.settings.rewards.length) return tools.warn("This server doesn't have any reward roles!") 15 | 16 | let embed = tools.createEmbed({ 17 | title: `Reward Roles (${db.settings.rewards.length})`, 18 | color: tools.COLOR, 19 | footer: "Add or remove reward roles with /rewardrole" 20 | }) 21 | 22 | let rewards = db.settings.rewards.sort((a, b) => a.level - b.level); 23 | 24 | let rewardEmbed = new PageEmbed(embed, rewards, { 25 | size: 20, owner: int.user.id, 26 | mapFunction: (x) => `**Level ${x.level}** - <@&${x.id}>${x.keep ? " (keep)" : ""}${x.noSync ? " (no sync)" : ""}` 27 | }) 28 | 29 | rewardEmbed.post(int) 30 | 31 | }} -------------------------------------------------------------------------------- /commands/button/settings_edit.js: -------------------------------------------------------------------------------- 1 | const Discord = require("discord.js") 2 | const schema = require("../../database_schema.js").settingsIDs 3 | 4 | module.exports = { 5 | metadata: { 6 | name: "button:settings_edit", 7 | }, 8 | 9 | async run(client, int, tools, modal) { 10 | 11 | let buttonData = int.customId.split("~") 12 | if (!modal && buttonData[2] != int.user.id) return int.deferUpdate() 13 | 14 | let settingID = modal || buttonData[1] 15 | let setting = schema[settingID] 16 | if (!setting) return tools.warn("Invalid setting!") 17 | 18 | let isBool = setting.type == "bool" 19 | let isNumber = (setting.type == "int" || setting.type == "float") 20 | 21 | if (!modal) { 22 | if (isNumber) { 23 | let numModal = new Discord.ModalBuilder() 24 | .setCustomId(`configmodal~${settingID}~${int.user.id}`) 25 | .setTitle("Edit setting") 26 | 27 | let numOption = new Discord.TextInputBuilder() 28 | .setLabel("New value") 29 | .setStyle(Discord.TextInputStyle.Short) 30 | .setCustomId("configmodal_value") 31 | .setMaxLength(20) 32 | .setRequired(true) 33 | if (!isNaN(setting.min) && !isNaN(setting.max)) numOption.setPlaceholder(`${tools.commafy(setting.min)} - ${tools.commafy(setting.max)}`) 34 | 35 | let numRow = new Discord.ActionRowBuilder().addComponents(numOption) 36 | numModal.addComponents(numRow) 37 | return int.showModal(numModal); 38 | } 39 | } 40 | 41 | 42 | let db = await tools.fetchSettings() 43 | if (!db) return tools.warn("*noData") 44 | 45 | let settings = db.settings 46 | if (!tools.canManageServer(int.member, settings.manualPerms)) return tools.warn("*notMod") 47 | 48 | let newValue; 49 | let oldValue = tools.getSettingFromID(settingID, settings); 50 | 51 | if (isBool) newValue = !oldValue 52 | 53 | else if (isNumber) { 54 | let modalVal = int.fields.getTextInputValue("configmodal_value") 55 | 56 | if (modalVal) { 57 | let num = Number(modalVal) 58 | if (isNaN(num)) return int.deferUpdate() 59 | 60 | if (setting.type == "int") num = Math.round(num) 61 | 62 | if (!isNaN(setting.min) && num < setting.min) num = setting.min 63 | else if (!isNaN(setting.max) && num > setting.max) num = setting.max 64 | 65 | newValue = num 66 | } 67 | } 68 | 69 | if (newValue === undefined || newValue == oldValue) return int.deferUpdate() 70 | 71 | client.db.update(int.guild.id, { $set: { [`settings.${settingID}`]: newValue, 'info.lastUpdate': Date.now() }}).then(() => { 72 | client.commands.get("button:settings_view").run(client, int, tools, ["val", null, settingID]) 73 | }).catch(() => tools.warn("Something went wrong while trying to change this setting!")) 74 | 75 | }} -------------------------------------------------------------------------------- /commands/button/settings_list.js: -------------------------------------------------------------------------------- 1 | const Discord = require('discord.js') 2 | const config = require("../../json/quick_settings.json") 3 | const schema = require("../../database_schema.js").settingsIDs 4 | 5 | const rootFolder = "home" 6 | 7 | module.exports = { 8 | metadata: { 9 | name: "button:settings_list", 10 | }, 11 | 12 | async run(client, int, tools, selected) { 13 | 14 | let buttonData = []; 15 | if (int.isButton) { 16 | buttonData = int.customId.split("~") 17 | if (buttonData[2] && buttonData[2] != int.user.id) return int.deferUpdate() 18 | } 19 | 20 | let db = await tools.fetchSettings() 21 | if (!db) return tools.warn("*noData") 22 | 23 | let settings = db.settings 24 | if (!tools.canManageServer(int.member, settings.manualPerms)) return tools.warn("*notMod") 25 | 26 | // displays the preview value for a setting 27 | function previewSetting(val, data, schema) { 28 | if (data.zeroText && val === 0) return data.zeroText 29 | switch(schema.type) { 30 | case "bool": return (data.invert ? !val : val) ? "__True__" : "False"; 31 | case "int": return tools.commafy(val); 32 | case "float": return tools.commafy(Number(val.toFixed(schema.precision || 4))); 33 | } 34 | return val.toString() 35 | } 36 | 37 | function getDataEmoji(type, val) { 38 | if (type == "bool") return val ? "✅" : "❎" 39 | else if (type == "int" || type == "float") return "#️⃣" 40 | else return "📝" 41 | } 42 | 43 | let dirName = (selected ? selected[1] : int.isButton ? buttonData[1] : rootFolder) || rootFolder 44 | let entries = config[dirName] 45 | 46 | if (!entries) return tools.warn("Invalid category!") 47 | 48 | let rows = [] 49 | let options = [] 50 | let groupName = "Settings" 51 | let isHome = (dirName == rootFolder) 52 | 53 | entries.forEach(x => { 54 | if (x.groupName) groupName = x.groupName 55 | 56 | if (x.folder) { 57 | let emoji = x.emoji || "📁" 58 | rows.push(`${emoji} **${x.name}**`) 59 | options.push({ emoji, label: x.name, value: `config_dir_${x.folder}` }) 60 | } 61 | 62 | else if (x.db) { 63 | let val = tools.getSettingFromID(x.db, settings) 64 | let sch = schema[x.db] 65 | rows.push(`**${x.name}**: ${previewSetting(val, x, sch)}`) 66 | options.push({ emoji: getDataEmoji(sch.type, val), label: x.name, description: tools.limitLength(x.desc, 95), value: `config_val_${dirName}_${x.db}` }) 67 | } 68 | 69 | if (x.space || x.folder == "home") rows.push("") 70 | }) 71 | 72 | let embed = tools.createEmbed({ 73 | color: tools.COLOR, 74 | title: groupName, 75 | description: rows.join("\n"), 76 | footer: isHome ? "Most basic settings can be toggled from here" : null 77 | }) 78 | 79 | let dropdown = new Discord.StringSelectMenuBuilder() 80 | .setCustomId(`configmenu_${int.user.id}`) 81 | .setPlaceholder(isHome ? "Choose category..." : "Choose setting...") 82 | .addOptions(...options) 83 | 84 | tools.editOrReply({ embeds: [embed], components: tools.row(dropdown) }, !buttonData[2]) 85 | }} -------------------------------------------------------------------------------- /commands/button/settings_view.js: -------------------------------------------------------------------------------- 1 | const config = require("../../json/quick_settings.json") 2 | const schema = require("../../database_schema.js").settingsIDs 3 | 4 | module.exports = { 5 | metadata: { 6 | name: "button:settings_view", 7 | }, 8 | 9 | async run(client, int, tools, selected) { 10 | 11 | let db = await tools.fetchSettings() 12 | if (!db) return tools.warn("*noData") 13 | 14 | let settings = db.settings 15 | if (!tools.canManageServer(int.member, settings.manualPerms)) return tools.warn("*notMod") 16 | 17 | let group = selected[1] 18 | let settingID = selected[2] 19 | let setting = schema[settingID] 20 | 21 | if (!setting) return tools.warn("Invalid setting!") 22 | 23 | // find group the hard way, if not provided 24 | if (!group) { 25 | for (const [g, x] of Object.entries(config)) { 26 | if (x.find(z => z.db == settingID)) { 27 | group = g 28 | break; 29 | } 30 | } 31 | } 32 | 33 | let val = tools.getSettingFromID(settingID, settings) 34 | let data = config[group].find(x => x.db == settingID) 35 | 36 | function previewSetting(val) { 37 | if (data.zeroText && val === 0) return `0 (${data.zeroText})` 38 | else switch(setting.type) { 39 | case "bool": return ((data.invert ? !val : val) ? "True" : "False"); 40 | case "int": return tools.commafy(+val); 41 | case "float": return tools.commafy(Number(val.toFixed(setting.precision || 8))); 42 | } 43 | return val.toString() 44 | } 45 | 46 | let currentVal = previewSetting(val) 47 | 48 | let footer = data.tip || "" 49 | if (setting.default !== undefined) footer += `${footer ? "\n" : ""}Default: ${previewSetting(setting.default)}` 50 | 51 | let embed = tools.createEmbed({ 52 | color: tools.COLOR, 53 | title: data.name, 54 | description: `**Current value:** ${currentVal}\n\n💡 ${data.desc}`, 55 | footer: footer || null 56 | }) 57 | 58 | let buttons = tools.button([ 59 | {style: "Secondary", label: "Back", customID: `settings_list~${group}~${int.user.id}`}, 60 | {style: "Primary", label: (setting.type == "bool") ? "Toggle" : "Edit", customId: `settings_edit~${settingID}~${int.user.id}`} 61 | ]) 62 | 63 | tools.editOrReply({embeds: [embed], components: tools.row(buttons)}) 64 | 65 | }} -------------------------------------------------------------------------------- /commands/button/toggle_xp.js: -------------------------------------------------------------------------------- 1 | const Discord = require('discord.js') 2 | module.exports = { 3 | metadata: { 4 | name: "button:toggle_xp", 5 | }, 6 | 7 | async run(client, int, tools) { 8 | let enabled = int.component.style == Discord.ButtonStyle.Success 9 | let db = await tools.fetchSettings() 10 | if (!db) return tools.warn("*noData") 11 | 12 | let settings = db.settings 13 | 14 | if (!tools.canManageServer(int.member, settings.manualPerms)) return tools.warn("*notMod") 15 | 16 | if (enabled == settings.enabled) return tools.warn(`XP is already ${enabled ? "enabled" : "disabled"} in this server!`) 17 | 18 | client.db.update(int.guild.id, { $set: { 'settings.enabled': enabled, 'info.lastUpdate': Date.now() }}).then(() => { 19 | int.reply(`✅ **XP is now ${enabled ? "enabled" : "disabled"} in this server!**`) 20 | }).catch(() => tools.warn("Something went wrong while trying to toggle XP!")) 21 | }} -------------------------------------------------------------------------------- /commands/events/message.js: -------------------------------------------------------------------------------- 1 | const LevelUpMessage = require("../../classes/LevelUpMessage.js") 2 | const config = require("../../config.json") 3 | 4 | module.exports = { 5 | 6 | async run(client, message, tools) { 7 | 8 | if (config.lockBotToDevOnly && !tools.isDev(message.author)) return 9 | 10 | // fetch server xp settings, this can probably be optimized with caching but shrug 11 | let author = message.author.id 12 | let db = await tools.fetchSettings(author, message.guild.id) 13 | if (!db || !db.settings?.enabled) return 14 | 15 | let settings = db.settings 16 | 17 | // fetch user's xp, or give them 0 18 | let userData = db.users[author] || { xp: 0, cooldown: 0 } 19 | if (userData.cooldown > Date.now()) return // on cooldown, stop here 20 | 21 | // check role+channel multipliers, exit if 0x 22 | let multiplierData = tools.getMultiplier(message.member, settings, message.channel) 23 | if (multiplierData.multiplier <= 0) return 24 | 25 | // randomly choose an amount of XP to give 26 | let oldXP = userData.xp 27 | let xpRange = [settings.gain.min, settings.gain.max].map(x => Math.round(x * multiplierData.multiplier)) 28 | let xpGained = tools.rng(...xpRange) // number between min and max, inclusive 29 | 30 | if (xpGained > 0) userData.xp += Math.round(xpGained) 31 | else return 32 | 33 | // set xp cooldown 34 | if (settings.gain.time > 0) userData.cooldown = Date.now() + (settings.gain.time * 1000) 35 | 36 | // if hidden from leaderboard, unhide since they're no longer inactive 37 | if (userData.hidden) userData.hidden = false 38 | 39 | // database update 40 | client.db.update(message.guild.id, { $set: { [`users.${author}`]: userData } }).exec(); 41 | 42 | // check for level up 43 | let oldLevel = tools.getLevel(oldXP, settings) 44 | let newLevel = tools.getLevel(userData.xp, settings) 45 | let levelUp = newLevel > oldLevel 46 | 47 | // auto sync roles on xp gain or level up 48 | let syncMode = settings.rewardSyncing.sync 49 | if (syncMode == "xp" || (syncMode == "level" && levelUp)) { 50 | let roleCheck = tools.checkLevelRoles(message.guild.roles.cache, message.member.roles.cache, newLevel, settings.rewards, null, oldLevel) 51 | tools.syncLevelRoles(message.member, roleCheck).catch(() => {}) 52 | } 53 | 54 | // level up message 55 | if (levelUp && settings.levelUp.enabled && settings.levelUp.message) { 56 | let useMultiple = (settings.levelUp.multiple > 1 && (settings.levelUp.multipleUntil == 0 || (newLevel < settings.levelUp.multipleUntil))) 57 | if (!useMultiple || (newLevel % settings.levelUp.multiple == 0)) { 58 | let lvlMessage = new LevelUpMessage(settings, message, { oldLevel, level: newLevel, userData }) 59 | lvlMessage.send() 60 | } 61 | } 62 | 63 | }} -------------------------------------------------------------------------------- /commands/misc/json_import.js: -------------------------------------------------------------------------------- 1 | const Tools = require("../../classes/Tools.js") 2 | let tools = Tools.global 3 | 4 | module.exports = { 5 | 6 | async run(client, serverID, importSettings={}, jsonData) { 7 | 8 | let details = [] 9 | let newData = {} 10 | let importedUsers = 0 11 | 12 | if (jsonData.xp) jsonData.users = jsonData.xp // in case someone messes this up 13 | if (!jsonData.users && !jsonData.settings && jsonData instanceof Object && !Array.isArray(jsonData)) jsonData = { users: jsonData } // if no keys provided, assume it's just XP 14 | 15 | if (jsonData.users && importSettings.xp) { 16 | let userEntries = Object.entries(jsonData.users) 17 | if (!importSettings.isDev && userEntries.length > 2000) return { error: "You can only import up to 2000 users, unless you're a developer of the bot! Remove any invalid IDs, or users with low XP." } 18 | 19 | userEntries.forEach(u => { 20 | const [id, x] = u 21 | if (id.match(/\d{16,20}/g) && !isNaN(x?.xp)) { 22 | importedUsers++ 23 | 24 | // validate the values here, since the db doesn't 25 | let obj = { xp: Number(x.xp) } 26 | if (!isNaN(x.cooldown)) obj.cooldown = Math.round(x.cooldown) 27 | if (x.hidden) obj.hidden = true 28 | 29 | newData[`users.${id}`] = obj 30 | } 31 | }) 32 | details.push(`${tools.commafy(importedUsers)} user${importedUsers == 1 ? "" : "s"}`) 33 | } 34 | 35 | if (jsonData.settings && importSettings.settings) { 36 | newData["settings"] = jsonData.settings // this should really really really really be validated but the schema is enough for me ¯\_(ツ)_/¯ 37 | details.push(`Server settings`) 38 | } 39 | 40 | if (!details.length) return { error: `No JSON data found! Syntax is { users: {...} }, settings: {...} }` } 41 | 42 | return { data: newData, details } 43 | 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /commands/misc/polaris_transfer.js: -------------------------------------------------------------------------------- 1 | const Tools = require("../../classes/Tools.js") 2 | let tools = Tools.global 3 | 4 | module.exports = { 5 | 6 | async run(client, serverID, importSettings={}, guilds) { 7 | 8 | let transferFrom = importSettings.serverID 9 | let foundServer = guilds.find(x => x.id == transferFrom) 10 | 11 | if (!foundServer) return { error: "Not in server" } 12 | else if (foundServer && !foundServer.owner) return { error: "Not owner in server" } 13 | else if (foundServer.id == serverID) return { error: "Cannot transfer from the same server" } 14 | 15 | let toTransfer = [] 16 | if (importSettings.xp) toTransfer.push("users") 17 | if (importSettings.settings) toTransfer.push("settings") 18 | if (!toTransfer.length) return { error: "Invalid import options!" } 19 | 20 | let details = [] 21 | let importedUsers = 0 22 | 23 | let transferData = await client.db.fetch(transferFrom, toTransfer) 24 | if (!transferData) return { error: `No Polaris data found for ${foundServer.name}`, code: "invalidImport" } 25 | 26 | let newData = {} 27 | 28 | if (importSettings.xp) { 29 | let now = Date.now(); 30 | Object.entries(transferData.users).forEach(u => { 31 | importedUsers++ 32 | let xpVal = { xp: u[1].xp } 33 | if (u[1].cooldown && u[1].cooldown > now) xpVal.cooldown = u[1].cooldown 34 | newData[`users.${u[0]}`] = xpVal 35 | }) 36 | details.push(`${tools.commafy(importedUsers)} user${importedUsers == 1 ? "" : "s"}`) 37 | } 38 | 39 | if (importSettings.settings) { 40 | let currentSettings = await client.db.fetch(serverID, "settings").then(x => x.settings) 41 | let transferSettings = transferData.settings 42 | transferSettings.rewards = currentSettings.rewards 43 | transferSettings.multipliers.roles = currentSettings.multipliers.roles 44 | transferSettings.multipliers.channels = currentSettings.multipliers.channels 45 | if (transferSettings.levelUp.channel.length > 8) transferSettings.levelUp.channel = currentSettings.levelUp.channel 46 | newData["settings"] = transferSettings 47 | details.push(`Server settings`) 48 | } 49 | 50 | return { data: newData, details } 51 | 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /commands/slash/addxp.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | metadata: { 3 | permission: "ManageGuild", 4 | name: "addxp", 5 | description: "Add or remove XP from a member. (requires manage server permission)", 6 | args: [ 7 | { type: "user", name: "member", description: "Which member to modify", required: true }, 8 | { type: "integer", name: "xp", description: "How much XP to add (negative number to remove XP)", min: -1e10, max: 1e10, required: true }, 9 | { type: "string", name: "operation", description: "How the XP amount should be interpreted", required: false, choices: [ 10 | {name: "Add XP", value: "add_xp"}, 11 | {name: "Set XP to", value: "set_xp"}, 12 | {name: "Add levels", value: "add_level"}, 13 | {name: "Set level to", value: "set_level"}, 14 | ]}, 15 | ] 16 | }, 17 | 18 | async run(client, int, tools) { 19 | 20 | const member = int.options.get("member")?.member 21 | const amount = int.options.get("xp")?.value 22 | const operation = int.options.get("operation")?.value || "add_xp" 23 | 24 | let user = member?.user 25 | if (!user) return tools.warn("I couldn't find that member!") 26 | 27 | let db = await tools.fetchSettings(user.id) 28 | if (!db) return tools.warn("*noData") 29 | else if (!tools.canManageServer(int.member, db.settings.manualPerms)) return tools.warn("*notMod") 30 | else if (!db.settings.enabled) return tools.warn("*xpDisabled") 31 | 32 | if (amount === 0 && operation.startsWith("add")) return tools.warn("Invalid amount of XP!") 33 | else if (user.bot) return tools.warn("You can't give XP to bots, silly!") 34 | 35 | let currentXP = db.users[user.id] 36 | let xp = currentXP?.xp || 0 37 | let level = tools.getLevel(xp, db.settings) 38 | 39 | let newXP = xp 40 | let newLevel = level 41 | 42 | switch (operation) { 43 | case "add_xp": newXP += amount; break; 44 | case "set_xp": newXP = amount; break; 45 | case "add_level": newLevel += amount; break; 46 | case "set_level": newLevel = amount; break; 47 | } 48 | 49 | newXP = Math.max(0, newXP) // min 0 50 | newLevel = tools.clamp(newLevel, 0, db.settings.maxLevel) // between 0 and max level 51 | 52 | if (newXP != xp) newLevel = tools.getLevel(newXP, db.settings) 53 | else if (newLevel != level) newXP = tools.xpForLevel(newLevel, db.settings) 54 | 55 | let syncMode = db.settings.rewardSyncing.sync 56 | if (syncMode == "xp" || (syncMode == "level" && newLevel != level) || (newLevel > level)) { 57 | let roleCheck = tools.checkLevelRoles(int.guild.roles.cache, member.roles.cache, newLevel, db.settings.rewards) 58 | tools.syncLevelRoles(member, roleCheck).catch(() => {}) 59 | } 60 | let xpDiff = newXP - xp 61 | 62 | client.db.update(int.guild.id, { $set: { [`users.${user.id}.xp`]: newXP } }).then(() => { 63 | int.reply(`${newXP > xp ? "⏫" : "⏬"} ${user.displayName} now has **${tools.commafy(newXP)}** XP${newLevel != level ? ` and is **level ${newLevel}**` : ""}! (previously ${tools.commafy(xp)}, ${xpDiff >= 0 ? "+" : ""}${tools.commafy(xpDiff)})`) 64 | }).catch(() => tools.warn("Something went wrong while trying to modify XP!")) 65 | 66 | }} -------------------------------------------------------------------------------- /commands/slash/botstatus.js: -------------------------------------------------------------------------------- 1 | const { dependencies } = require('../../package.json'); 2 | const config = require("../../config.json") 3 | 4 | module.exports = { 5 | metadata: { 6 | name: "botstatus", 7 | description: "View some details about the bot" 8 | }, 9 | 10 | async run(client, int, tools) { 11 | 12 | let versionNumber = client.version.version != Math.round(client.version.version) ? client.version.version : client.version.version.toFixed(1) 13 | 14 | let stats = await client.shard.broadcastEval(cl => ({ guilds: cl.guilds.cache.size, users: cl.users.cache.size })) 15 | let totalServers = stats.reduce((a, b) => a + b.guilds, 0) 16 | 17 | let botStatus = [ 18 | `**Original creator:** **[Colon](https://gdcolon.com)** 🦊⛩️`, 19 | `**Version:** v${versionNumber} - updated `, 20 | `**Shard:** ${client.shard.id}/${client.shard.count - 1}`, 21 | `**Uptime:** ${tools.timestamp(client.uptime)}`, 22 | `**Servers:** ${tools.commafy(totalServers)}${client.shard.count == 1 ? "" : ` (on shard: ${tools.commafy(client.guilds.cache.size)})`}`, 23 | `**Memory usage:** ${Number((process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2))} MB` 24 | ] 25 | 26 | let embed = tools.createEmbed({ 27 | author: { name: client.user.displayName, iconURL: client.user.avatarURL() }, 28 | color: tools.COLOR, timestamp: true, footer: "Pinging...", 29 | description: botStatus.join("\n") 30 | }) 31 | 32 | let infoButtons = [{style: "Link", label: "Website", url: `${tools.WEBSITE}`}] 33 | if (config.changelogURL) infoButtons.push({style: "Link", label: "Changelog", url: config.changelogURL}) 34 | if (config.supportURL) infoButtons.push({style: "Link", label: "Support", url: config.supportURL}) 35 | 36 | int.reply({embeds: [embed], components: tools.row(tools.button(infoButtons)), fetchReply: true}).then(msg => { 37 | embed.setFooter({ text: `Ping: ${tools.commafy(msg.createdTimestamp - int.createdAt)}ms`}) 38 | int.editReply({ embeds: [embed], components: msg.components }) 39 | }) 40 | 41 | }} -------------------------------------------------------------------------------- /commands/slash/calculate.js: -------------------------------------------------------------------------------- 1 | const multiplierModes = require("../../json/multiplier_modes.json") 2 | 3 | module.exports = { 4 | metadata: { 5 | name: "calculate", 6 | description: "Check how much XP you need to reach a certain level.", 7 | args: [ 8 | { type: "integer", name: "target", description: "The desired level", min: 1, max: 1000, required: true }, 9 | { type: "user", name: "member", description: "Which member to check", required: false } 10 | ] 11 | }, 12 | 13 | async run(client, int, tools) { 14 | 15 | let member = int.member 16 | let foundUser = int.options.get("member") 17 | if (foundUser) member = foundUser.member 18 | 19 | let db = await tools.fetchSettings(member.id) 20 | if (!db) return tools.warn("*noData") 21 | else if (!db.settings.enabled) return tools.warn("*xpDisabled") 22 | 23 | let targetLevel = Math.min(int.options.get("target").value, db.settings.maxLevel) 24 | let targetXP = tools.xpForLevel(targetLevel, db.settings) 25 | 26 | let cardCol = db.settings.rankCard.embedColor 27 | if (cardCol == -1) cardCol = null 28 | 29 | if (db.settings.rankCard.disabled) { 30 | let miniEmbed = tools.createEmbed({ 31 | title: `Level ${tools.commafy(targetLevel)}`, 32 | color: cardCol || member.displayColor || await member.user.fetch().then(x => x.accentColor), 33 | description: `${tools.commafy(targetXP)} XP required`, 34 | footer: "Rank cards are disabled, so calculations are hidden!" 35 | }) 36 | return int.reply({embeds: [miniEmbed]}) 37 | } 38 | 39 | let currentXP = db.users[member.id] 40 | if (!currentXP || !currentXP.xp) return tools.noXPYet(foundUser ? foundUser.user : int.user) 41 | let xp = currentXP.xp 42 | let userLevel = tools.getLevel(xp, db.settings) 43 | 44 | let remaining = targetXP - xp 45 | let reached = remaining <= 0 46 | let percent = xp / targetXP * 100 47 | 48 | let barSize = 33 49 | let barRepeat = Math.min(barSize, Math.round(percent / (100 / barSize))) 50 | let progressBar = `${"▓".repeat(barRepeat)}${"░".repeat(barSize - barRepeat)} (${Number(percent.toFixed(2))}%)` 51 | 52 | if (targetLevel == userLevel && userLevel >= db.settings.maxLevel) progressBar += `\n🎉 You reached the maximum level${db.settings.maxLevel < 1000 ? " in this server" : ""}! Congratulations!` 53 | 54 | let multiplierData = tools.getMultiplier(member, db.settings) 55 | let multiplier = multiplierData.multiplier || multiplierData.role 56 | if (multiplier <= 0) return int.reply("Your multiplier prevents you from gaining any XP!") 57 | 58 | let estimatedMin = Math.ceil(remaining / (db.settings.gain.min * multiplier)) 59 | let estimatedMax = Math.ceil(remaining / (db.settings.gain.max * multiplier)) 60 | let estimatedAvg = Math.round((estimatedMax + estimatedMin) / 2) 61 | let estimatedTime = estimatedAvg * db.settings.gain.time 62 | 63 | let estimatedRange = (estimatedMax == estimatedMin) ? `${tools.commafy(estimatedMax)}` : `${tools.commafy(estimatedMax)} - ${tools.commafy(estimatedMin)} (avg. ${tools.commafy(estimatedAvg)})` 64 | 65 | let levelDetails = [ 66 | `**Current XP: **${tools.commafy(xp)} (Level ${tools.commafy(userLevel)})`, 67 | `**Target XP: **${tools.commafy(targetXP)}`, 68 | `**Remaining XP: **${reached? "0 (" : ""}${tools.commafy(targetXP - xp)}${reached ? ")" : ""}` 69 | ] 70 | 71 | if (!reached) levelDetails = levelDetails.concat([ 72 | "", 73 | `**XP per message: **${db.settings.gain.min == db.settings.gain.max ? tools.commafy(Math.round(db.settings.gain.min * multiplier)) : `${tools.commafy(Math.round(db.settings.gain.min * multiplier))} - ${tools.commafy(Math.round(db.settings.gain.max * multiplier))}`}`, 74 | `**Messages remaining: **${estimatedRange}`, 75 | `**Cooldown remaining: **${estimatedTime == Infinity ? "Until the end of time" : tools.time(estimatedTime * 1000, 1)}`, 76 | ]) 77 | 78 | let embed = tools.createEmbed({ 79 | author: { name: member.user.displayName, iconURL: member.displayAvatarURL() }, 80 | title: `Level ${tools.commafy(targetLevel)}${reached ? " (reached!)" : ""}`, 81 | color: cardCol || member.displayColor || await member.user.fetch().then(x => x.accentColor), 82 | description: levelDetails.join("\n"), footer: progressBar 83 | }) 84 | 85 | return int.reply({embeds: [embed]}) 86 | 87 | }} -------------------------------------------------------------------------------- /commands/slash/clear.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | metadata: { 3 | permission: "ManageGuild", 4 | name: "clear", 5 | description: "Clear a member's cooldown. (requires manage server permission)", 6 | args: [ 7 | { type: "user", name: "member", description: "Which member to clear", required: true } 8 | ] 9 | }, 10 | 11 | async run(client, int, tools) { 12 | 13 | const user = int.options.get("member")?.user 14 | 15 | let db = await tools.fetchSettings(user.id) 16 | if (!db) return tools.warn("*noData") 17 | else if (!tools.canManageServer(int.member, db.settings.manualPerms)) return tools.warn("*notMod") 18 | else if (!db.settings.enabled) return tools.warn("*xpDisabled") 19 | 20 | if (user.bot) return tools.warn("Bots don't have cooldowns, silly!") 21 | 22 | let current = db.users[user.id] 23 | let cooldown = current?.cooldown 24 | if (!cooldown || cooldown <= Date.now()) return tools.warn("This member doesn't have an active cooldown!") 25 | 26 | client.db.update(int.guild.id, { $set: { [`users.${user.id}.cooldown`]: 0 } }).then(() => { 27 | int.reply(`🔄 **${tools.pluralS(user.displayName)} cooldown has been reset!** (previously ${tools.timestamp(cooldown - Date.now())})`) 28 | }).catch(() => tools.warn("Something went wrong while trying to reset the cooldown!")) 29 | 30 | }} -------------------------------------------------------------------------------- /commands/slash/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | metadata: { 3 | permission: "ManageGuild", 4 | name: "config", 5 | description: "Toggle XP gain, or visit the dashboard to tweak server settings. (requires manage server permission)", 6 | }, 7 | 8 | async run(client, int, tools) { 9 | 10 | let db = await tools.fetchSettings() 11 | let settings = db.settings 12 | if (!tools.canManageServer(int.member, db.settings.manualPerms)) return tools.warn("*notMod") 13 | 14 | let polarisSettings = [ 15 | `**✨ XP enabled: __${settings.enabled ? "Yes!" : "No!"}__**`, 16 | `**XP per message:** ${settings.gain.min == settings.gain.max ? tools.commafy(settings.gain.min) : `${tools.commafy(settings.gain.min)} - ${tools.commafy(settings.gain.max)}`}`, 17 | `**XP cooldown:** ${tools.commafy(settings.gain.time)} ${tools.extraS("sec", settings.gain.time)}`, 18 | `**XP curve:** ${settings.curve[3]}x³ + ${settings.curve[2]}x² + ${settings.curve[1]}x`, 19 | `**Level up message:** ${settings.levelUp.enabled && settings.levelUp.message ? (settings.levelUp.embed ? "Enabled (embed)" : "Enabled") : "Disabled"}`, 20 | `**Rank cards:** ${settings.rankCard.disabled ? "Disabled" : settings.rankCard.ephemeral ? "Enabled (forced hidden)" : "Enabled"}`, 21 | `**Leaderboard:** ${settings.leaderboard.disabled ? "Disabled" : `[${settings.leaderboard.private ? "Private" : "Public"}](<${tools.WEBSITE}/leaderboard/${int.guild.id}>)`}` 22 | ] 23 | 24 | let embed = tools.createEmbed({ 25 | author: { name: "Settings for " + int.guild.name, iconURL: int.guild.iconURL() }, 26 | footer: "Visit the online dashboard to change server settings", 27 | color: tools.COLOR, timestamp: true, 28 | description: polarisSettings.join("\n") 29 | }) 30 | 31 | let toggleButton = settings.enabled ? 32 | {style: "Danger", label: "Disable XP", emoji: "❕", customId: "toggle_xp" } 33 | : {style: "Success", label: "Enable XP", emoji: "✨", customId: "toggle_xp" } 34 | 35 | let buttons = tools.button([ 36 | {style: "Success", label: "Edit Settings", emoji: "🛠", customID: "settings_list"}, 37 | toggleButton, 38 | {style: "Link", label: "Edit Online", emoji: "🌎", url: `${tools.WEBSITE}/settings/${int.guild.id}`}, 39 | {style: "Secondary", label: "Export Data", emoji: "⏏️", customId: "export_xp"} 40 | ]) 41 | 42 | let listButtons = tools.button([ 43 | {style: "Primary", label: `Reward Roles (${settings.rewards.length})`, customId: "list_reward_roles"}, 44 | {style: "Primary", label: `Role Multipliers (${settings.multipliers.roles.length})`, customId: "list_multipliers~roles"}, 45 | {style: "Primary", label: `Channel Multipliers (${settings.multipliers.channels.length})`, customId: "list_multipliers~channels"} 46 | ]) 47 | 48 | return int.reply({embeds: [embed], components: [tools.row(buttons)[0], tools.row(listButtons)[0]]}) 49 | 50 | }} -------------------------------------------------------------------------------- /commands/slash/dev_db.js: -------------------------------------------------------------------------------- 1 | const util = require("util") 2 | 3 | module.exports = { 4 | metadata: { 5 | dev: true, 6 | name: "db", 7 | description: "(dev) View or modify database stuff.", 8 | args: [ 9 | { type: "string", name: "property", description: "Property name (e.g. settings.enabled)", required: false }, 10 | { type: "string", name: "new_value", description: "New value for the property (parsed as JSON)", required: false }, 11 | { type: "string", name: "guild_id", description: "Guild ID to use (defaults to current guild)", required: false } 12 | ] 13 | }, 14 | 15 | async run(client, int, tools) { 16 | 17 | const propertyName = int.options.get("property") 18 | const newValue = int.options.get("new_value") 19 | const providedGuild = int.options.get("guild_id") 20 | 21 | let guildID = providedGuild?.value || int.guild.id 22 | let db = await client.db.fetch(guildID) 23 | if (!db) return int.reply("No data!") 24 | 25 | let cleanDB = { _id: db._id, settings: db.settings || {}, users: db.users || {} } 26 | 27 | if (!propertyName) { 28 | let uniqueMembers = Object.keys(cleanDB.users).length 29 | if (uniqueMembers > 16) cleanDB.users = `(${uniqueMembers} entries)` 30 | return int.reply(util.inspect(cleanDB)) 31 | } 32 | 33 | else if (!newValue) { 34 | Promise.resolve().then(() => eval(`db.${propertyName.value}`)) // lmao 35 | .then(x => int.reply(tools.limitLength(util.inspect(x), 1900))) 36 | .catch(e => int.reply(`**Error:** ${e.message}`)) 37 | } 38 | 39 | else { 40 | let val = newValue.value 41 | try { val = JSON.parse(newValue.value) } 42 | catch(e) { newValue.value } 43 | 44 | let confirmMsg = { content: `Click to update **${propertyName.value}** to: [${typeof val}] ${tools.limitLength(JSON.stringify(val), 256)}` } 45 | tools.createConfirmationButtons({ 46 | message: confirmMsg, buttons: "Update!", secs: 30, timeoutMessage: "Update cancelled", 47 | onClick: function(confirmed, msg, b) { 48 | if (!confirmed) return msg.reply("Update cancelled") 49 | else { 50 | client.db.update(guildID, { $set: { [propertyName.value]: val } }).exec().then(() => { 51 | msg.reply(`✅ Successfully updated **${propertyName.value}**!`) 52 | }).catch(e => msg.reply("Update failed! " + e.message)) 53 | } 54 | } 55 | }) 56 | } 57 | 58 | 59 | }} -------------------------------------------------------------------------------- /commands/slash/dev_deploy.js: -------------------------------------------------------------------------------- 1 | const config = require("../../config.json") 2 | const DiscordBuilders = require("@discordjs/builders") 3 | const Discord = require("discord.js") 4 | const { REST } = require("@discordjs/rest") 5 | const { Routes } = require("discord-api-types/v9") 6 | 7 | function prepareOption(option, arg) { 8 | option.setName(arg.name.toLowerCase()) 9 | if (arg.description) option.setDescription(arg.description) 10 | if (arg.required) option.setRequired(true) 11 | return option 12 | } 13 | 14 | function createSlashArg(data, arg) { 15 | switch (arg.type) { 16 | case "subcommand": 17 | return data.addSubcommand(cmd => { 18 | cmd.setName(arg.name) 19 | cmd.setDescription(arg.description) 20 | if (arg.args?.length) arg.args.forEach(a => { createSlashArg(cmd, a) }) 21 | return cmd 22 | }) 23 | case "string": 24 | return data.addStringOption(option => { 25 | prepareOption(option, arg) 26 | if (arg.choices) option.setChoices(...arg.choices) 27 | return option 28 | }) 29 | case "integer": case "number": 30 | return data.addIntegerOption(option => { 31 | prepareOption(option, arg) 32 | if (arg.choices) option.setChoices(...arg.choices) 33 | if (!isNaN(arg.min)) option.setMinValue(arg.min) 34 | if (!isNaN(arg.max)) option.setMaxValue(arg.max) 35 | return option 36 | }) 37 | case "float": 38 | return data.addNumberOption(option => { 39 | prepareOption(option, arg) 40 | if (arg.choices) option.setChoices(...arg.choices) 41 | if (!isNaN(arg.min)) option.setMinValue(arg.min) 42 | if (!isNaN(arg.max)) option.setMaxValue(arg.max) 43 | return option 44 | }) 45 | case "channel": 46 | return data.addChannelOption(option => { 47 | prepareOption(option, arg) 48 | if (arg.types) option.addChannelTypes(arg.types) 49 | else if (arg.acceptAll) option.addChannelTypes([0, 2, 4, 5, 10, 11, 12, 13, 15, 16]) // lol 50 | else option.addChannelTypes([Discord.ChannelType.GuildText, Discord.ChannelType.GuildAnnouncement]) 51 | return option 52 | }) 53 | case "bool": return data.addBooleanOption(option => prepareOption(option, arg)) 54 | case "file": return data.addAttachmentOption(option => prepareOption(option, arg)) 55 | case "user": return data.addUserOption(option => prepareOption(option, arg)) 56 | case "role": return data.addRoleOption(option => prepareOption(option, arg)) 57 | } 58 | } 59 | 60 | 61 | module.exports = { 62 | metadata: { 63 | dev: true, 64 | name: "deploy", 65 | description: "(dev) Deploy/sync the bot's commands.", 66 | args: [ 67 | { type: "bool", name: "global", description: "Publish the public global commands instead of dev ones", required: false }, 68 | { type: "string", name: "server_id", description: "Deploy dev commands to a specific server", required: false }, 69 | { type: "bool", name: "undeploy", description: "Clears all dev commands from the server (or global if it's set to true)", required: false } 70 | ] 71 | }, 72 | 73 | // I made my own slash command builder because discord.js's one is ass 74 | // https://discord.js.org/#/docs/builders/main/class/SlashCommandBuilder 75 | async run(client, int, tools) { 76 | 77 | let isPublic = int && !!int.options.get("global")?.value 78 | let undeploy = int && !!int.options.get("undeploy")?.value 79 | let targetServer = (!int || isPublic) ? null : int.options.get("server_id")?.value 80 | 81 | let interactionList = [] 82 | if (!undeploy) client.commands.forEach(cmd => { 83 | let metadata = cmd.metadata 84 | if (isPublic && metadata.dev) return 85 | else if (!isPublic && !metadata.dev) return 86 | 87 | switch (metadata.type) { 88 | 89 | case "user_context": case "message_context": // context menu, user 90 | let ctx = { name: metadata.name, type: metadata.type == "user_context" ? 2 : 3, dm_permission: !!metadata.dm, contexts: [0] } 91 | interactionList.push(ctx); 92 | break; 93 | 94 | case "slash": // slash commands 95 | let data = new DiscordBuilders.SlashCommandBuilder() 96 | data.setName(metadata.name.toLowerCase()) 97 | data.setContexts([0]) 98 | if (metadata.dev) data.setDefaultMemberPermissions(0) 99 | else if (metadata.permission) data.setDefaultMemberPermissions(Discord.PermissionFlagsBits[metadata.permission]) 100 | if (metadata.description) data.setDescription(metadata.description) 101 | if (metadata.args) metadata.args.forEach(arg => { 102 | return createSlashArg(data, arg) 103 | }) 104 | interactionList.push(data.toJSON()) 105 | break; 106 | } 107 | }) 108 | 109 | const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN); 110 | 111 | if (isPublic) { 112 | const route = Routes.applicationCommands(process.env.DISCORD_ID) 113 | rest.put(route, { body: interactionList }) 114 | .then(() => { 115 | if (int) int.reply(`**${!undeploy ? `${interactionList.length} global commands registered!` : "Global commands cleared!"}** (Wait a bit, or refresh with Ctrl+R to see changes)`) 116 | else console.info("Global commands registered!") 117 | client.shard.broadcastEval(cl => { cl.application.commands.fetch(); return }) // cache new slash commands 118 | }).catch(e => console.error(`Error deploying global commands to ${id}: ${e.message}`)); 119 | } 120 | 121 | else { 122 | let serverIDs = targetServer ? [targetServer] : (int?.guild) ? [int.guild.id] : config.test_server_ids 123 | if (!serverIDs) return console.warn("Cannot deploy dev commands! No test server IDs provided in config.") 124 | 125 | serverIDs.forEach(id => { 126 | const route = Routes.applicationGuildCommands(process.env.DISCORD_ID, id) 127 | rest.put(route, { body: interactionList }) 128 | .then(() => { 129 | let msg = `Dev commands registered to ${id}!` 130 | if (int) int.reply(undeploy ? "Dev commands cleared!" : id == int.guild.id ? "Dev commands registered!" : msg) 131 | else console.info(msg) 132 | }).catch(e => console.error(`Error deploying dev commands to ${id}: ${e.message}`)); 133 | }) 134 | } 135 | 136 | 137 | }} -------------------------------------------------------------------------------- /commands/slash/dev_run.js: -------------------------------------------------------------------------------- 1 | const util = require("util") 2 | 3 | module.exports = { 4 | metadata: { 5 | dev: true, 6 | name: "run", 7 | description: "(dev) Evalute JS code, 100% very much safely.", 8 | args: [ 9 | { type: "string", name: "code", description: "Some JS code to very safely evaluate", required: true } 10 | ] 11 | }, 12 | 13 | async run(client, int, tools) { 14 | 15 | let code = int.options.get("code").value 16 | let db = await client.db.fetch(int.guild.id) 17 | 18 | return Promise.resolve().then(() => { 19 | return eval(code) 20 | }) 21 | .then(x => { 22 | if (typeof x !== "string") x = util.inspect(x) 23 | int.reply(x || "** **").catch((e) => { 24 | int.reply("✅").catch(() => {}) 25 | }); 26 | }) 27 | .catch(e => { int.reply(`**Error:** ${e.message}`); console.warn(e) }) 28 | 29 | }} -------------------------------------------------------------------------------- /commands/slash/dev_setactivity.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | 3 | module.exports = { 4 | metadata: { 5 | dev: true, 6 | name: "setactivity", 7 | description: "(dev) Change the bot's status", 8 | args: [ 9 | { type: "string", name: "type", description: "Activity type", required: true, choices: [ 10 | {name: "Custom", value: "Custom"}, 11 | {name: "Playing", value: "Playing"}, 12 | {name: "Watching", value: "Watching"}, 13 | {name: "Listening to", value: "Listening"}, 14 | {name: "*Current", value: "current"}, 15 | {name: "*Reset", value: "reset"}, 16 | {name: "*Clear", value: "clear"}, 17 | ]}, 18 | { type: "string", name: "name", description: "Custom activity name", required: true }, 19 | { type: "string", name: "state", description: "Custom state", required: false }, 20 | { type: "string", name: "url", description: "Stream URL", required: false }, 21 | { type: "string", name: "status", description: "Online status", required: false, choices: [ 22 | {name: "Online", value: "online"}, 23 | {name: "Idle", value: "idle"}, 24 | {name: "Do Not Disturb", value: "dnd"}, 25 | {name: "Offline", value: "offline"}, 26 | ]}, 27 | ] 28 | }, 29 | 30 | async run(client, int, tools) { 31 | 32 | const statusInfo = require("../../json/auto/status.json") // placed inside run to guarantee it exists 33 | 34 | let type = int.options.get("type")?.value 35 | let name = int.options.get("name")?.value 36 | let state = int.options.get("state")?.value 37 | let status = int.options.get("status")?.value || "online" 38 | let url = int.options.get("url")?.value || null 39 | 40 | if (!state && type == "Custom") state = name 41 | 42 | if (url) type = "Streaming" 43 | 44 | else if (type == "current") { 45 | type = statusInfo.type 46 | name = statusInfo.name 47 | status = statusInfo.status 48 | } 49 | 50 | else if (type == "reset") { 51 | type = statusInfo.default.type 52 | name = statusInfo.default.name 53 | status = "online" 54 | } 55 | 56 | else if (type == "clear") { 57 | type = "" 58 | name = "" 59 | } 60 | 61 | int.reply("✅ **Status updated!**") 62 | 63 | statusInfo.name = name 64 | statusInfo.state = state || "" 65 | statusInfo.type = type 66 | statusInfo.url = url 67 | statusInfo.status = status 68 | client.statusData = statusInfo 69 | fs.writeFileSync('./json/auto/status.json', JSON.stringify(statusInfo, null, 2)) 70 | 71 | client.shard.broadcastEval(async (cl, xd) => { 72 | cl.statusData = xd 73 | cl.updateStatus() 74 | }, { context: statusInfo }) 75 | 76 | }} -------------------------------------------------------------------------------- /commands/slash/dev_setversion.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | 3 | module.exports = { 4 | metadata: { 5 | dev: true, 6 | name: "setversion", 7 | description: "(dev) Change the bot's version number", 8 | args: [ 9 | { type: "string", name: "version", description: "Version number", required: true }, 10 | { type: "bool", name: "change_timestamp", description: "Use current time as update timestamp", required: true }, 11 | { type: "integer", name: "custom_timestamp", description: "Custom update timestamp", required: false }, 12 | ] 13 | }, 14 | 15 | async run(client, int, tools) { 16 | 17 | let versionNumber = int.options.get("version")?.value 18 | let updateTimestamp = !!int.options.get("change_timestamp")?.value ? Date.now() : (int.options.get("custom_timestamp")?.value || client.version.updated) 19 | 20 | client.shard.broadcastEval((cl, xd) => { 21 | cl.version = { version: xd.versionNumber, updated: xd.updateTimestamp } 22 | }, { context: { versionNumber, updateTimestamp } }) 23 | 24 | fs.writeFileSync('./json/auto/version.json', JSON.stringify({ version: versionNumber, updated: updateTimestamp }, null, 2)) 25 | 26 | int.reply(`Bot updated to **v${versionNumber}** ( / ${updateTimestamp})`) 27 | 28 | }} -------------------------------------------------------------------------------- /commands/slash/multiplier.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | metadata: { 3 | permission: "ManageGuild", 4 | name: "multiplier", 5 | description: "Add or remove an XP multiplier. (requires manage server permission)", 6 | args: [ 7 | { type: "subcommand", name: "role", description: "Add or remove a role multiplier", args: [ 8 | { type: "role", name: "role_name", description: "The role to add a multiplier for", required: true }, 9 | { type: "float", name: "multiplier", description: "Multiply XP gain by this amount (0.5, 2, etc), or 0 to disable XP gain", min: 0, max: 100, required: true }, 10 | { type: "bool", name: "remove", description: "Removes this multiplier, if it exists" } 11 | ]}, 12 | 13 | { type: "subcommand", name: "channel", description: "Add or remove a channel multiplier", args: [ 14 | { type: "channel", name: "channel_name", description: "The channel or category to add a multiplier for", required: true, acceptAll: true }, 15 | { type: "float", name: "multiplier", description: "Multiply XP gain by this amount (0.5, 2, etc), or 0 to disable XP gain", min: 0, max: 100, required: true }, 16 | { type: "bool", name: "remove", description: "Removes this multiplier, if it exists" } 17 | ]} 18 | ] 19 | }, 20 | 21 | async run(client, int, tools) { 22 | 23 | let db = await tools.fetchSettings() 24 | if (!tools.canManageServer(int.member, db.settings.manualPerms)) return tools.warn("*notMod") 25 | 26 | let type = int.options.getSubcommand(false) 27 | 28 | let boostVal = int.options.get("multiplier")?.value ?? 1 29 | 30 | let role = int.options.getRole("role_name") 31 | let channel = int.options.getChannel("channel_name") 32 | let boost = tools.clamp(+boostVal.toFixed(2), 0, 100) 33 | let remove = !!int.options.get("remove")?.value 34 | 35 | if (!channel && !role) return 36 | let target = (channel || role) 37 | let tag = role ? `<@&${role.id}>` : `<#${channel.id}>` 38 | 39 | let typeIndex = role ? "roles" : "channels" 40 | let mults = db.settings.multipliers[typeIndex] 41 | let existingIndex = mults.findIndex(x => x.id == target.id) 42 | let foundExisting = (existingIndex >= 0) ? mults[existingIndex] : null 43 | 44 | let newList = db.settings.multipliers 45 | if (foundExisting) db.settings.multipliers[typeIndex].splice(existingIndex, 1) // remove by default 46 | 47 | function finish(msg) { 48 | let viewMultipliers = tools.row([ 49 | tools.button({style: role ? "Primary" : "Secondary", label: `Role multipliers (${newList.roles.length})`, customId: "list_multipliers~roles"}), 50 | tools.button({style: role ? "Secondary" : "Primary", label: `Channel multipliers (${newList.channels.length})`, customId: "list_multipliers~channels"}) 51 | ]) 52 | 53 | client.db.update(int.guild.id, { $set: { [`settings.multipliers.${typeIndex}`]: newList[typeIndex], 'info.lastUpdate': Date.now() }}).then(() => { 54 | return int.reply({ content: msg, components: viewMultipliers }) 55 | }) 56 | } 57 | 58 | // deleting a multiplier 59 | if (remove) { 60 | if (!foundExisting) return tools.warn(`This ${type} never had a multiplier to begin with!`) 61 | return finish(`❌ **Successfully deleted ${foundExisting.boost}x multiplier for ${tag}.**`) 62 | } 63 | 64 | // set up multiplier data 65 | let boostData = { id: target.id, boost } 66 | newList[typeIndex].push(boostData) 67 | let boostStr = boost == 0 ? "no XP" : `${boost}x XP` 68 | 69 | // if multiplier already exists, replace it 70 | if (foundExisting) { 71 | if (foundExisting.boost == boost) return tools.warn(`This ${type} already gives a ${boost}x multiplier!`) 72 | return finish(`📝 **${tag} now gives ${boostStr}!** (previously ${foundExisting.boost}x)`) 73 | } 74 | 75 | return finish(`✅ **${tag} now gives ${boostStr}!**`) 76 | 77 | }} -------------------------------------------------------------------------------- /commands/slash/rank.js: -------------------------------------------------------------------------------- 1 | const multiplierModes = require("../../json/multiplier_modes.json") 2 | 3 | module.exports = { 4 | metadata: { 5 | name: "rank", 6 | description: "View your current XP, level, and cooldown.", 7 | args: [ 8 | { type: "user", name: "member", description: "Which member to view", required: false }, 9 | { type: "bool", name: "hidden", description: "Hides the reply so only you can see it", required: false } 10 | ] 11 | }, 12 | 13 | async run(client, int, tools) { 14 | 15 | // fetch member 16 | let member = int.member 17 | let foundUser = int.options.get("user") || int.options.get("member") // option is "user" if from context menu 18 | if (foundUser) member = foundUser.member 19 | if (!member) return tools.warn("That member couldn't be found!") 20 | 21 | // fetch server xp settings 22 | let db = await tools.fetchSettings(member.id) 23 | if (!db) return tools.warn("*noData") 24 | else if (!db.settings.enabled) return tools.warn("*xpDisabled") 25 | 26 | let currentXP = db.users[member.id] 27 | 28 | if (db.settings.rankCard.disabled) return tools.warn("Rank cards are disabled in this server!") 29 | 30 | // if user has no xp, stop here 31 | if (!currentXP || !currentXP.xp) return tools.noXPYet(foundUser ? foundUser.user : int.user) 32 | 33 | let xp = currentXP.xp 34 | 35 | let levelData = tools.getLevel(xp, db.settings, true) // get user's level 36 | let maxLevel = levelData.level >= db.settings.maxLevel // check if level is maxxed 37 | 38 | let remaining = levelData.xpRequired - xp 39 | let levelPercent = maxLevel ? 100 : (xp - levelData.previousLevel) / (levelData.xpRequired - levelData.previousLevel) * 100 40 | 41 | let multiplierData = tools.getMultiplier(member, db.settings) 42 | let multiplier = multiplierData.multiplier 43 | 44 | let barSize = 33 // how many characters the xp bar is 45 | let barRepeat = Math.round(levelPercent / (100 / barSize)) // .round() so bar can sometimes display as completely full and completely empty 46 | let progressBar = `${"▓".repeat(barRepeat)}${"░".repeat(barSize - barRepeat)} (${!maxLevel ? Number(levelPercent.toFixed(2)) + "%" : "MAX"})` 47 | 48 | let estimatedMin = Math.ceil(remaining / (db.settings.gain.min * (multiplier || multiplierData.role))) 49 | let estimatedMax = Math.ceil(remaining / (db.settings.gain.max * (multiplier || multiplierData.role))) 50 | 51 | // estimated number of messages to level up 52 | let estimatedRange = (estimatedMax == estimatedMin) ? `${tools.commafy(estimatedMax)} ${tools.extraS("message", estimatedMax)}` : `${tools.commafy(estimatedMax)}-${tools.commafy(estimatedMin)} messages` 53 | 54 | // xp required to level up 55 | let nextLevelXP = (db.settings.rankCard.relativeLevel ? `${tools.commafy(xp - levelData.previousLevel)}/${tools.commafy(levelData.xpRequired - levelData.previousLevel)}` : `${tools.commafy(levelData.xpRequired)}`) + ` (${tools.commafy(remaining)} more)` 56 | 57 | let cardCol = db.settings.rankCard.embedColor 58 | if (cardCol == -1) cardCol = null 59 | 60 | let memberAvatar = member.displayAvatarURL() 61 | let memberColor = cardCol || member.displayColor || await member.user.fetch().then(x => x.accentColor) 62 | 63 | let embed = tools.createEmbed({ 64 | author: { name: member.user.displayName, iconURL: memberAvatar }, 65 | color: memberColor, 66 | footer: maxLevel ? progressBar : ((estimatedMin == Infinity || estimatedMin < 0) ? "You are unable to gain XP!" : `${progressBar}\n${estimatedRange} to go!`), 67 | fields: [ 68 | { name: "✨ XP", value: `${tools.commafy(xp)} (lv. ${levelData.level})`, inline: true }, 69 | { name: "⏩ Next level", value: !maxLevel ? nextLevelXP : "Max level! Woah!", inline: true }, 70 | ] 71 | }) 72 | 73 | if (!db.settings.rankCard.hideCooldown) { 74 | let foundCooldown = currentXP.cooldown || 0 75 | let cooldown = foundCooldown > Date.now() ? tools.timestamp(foundCooldown - Date.now()) : "None!" 76 | embed.addFields([{ name: "🕓 Cooldown", value: cooldown, inline: true }]) 77 | } 78 | 79 | let hideMult = db.settings.hideMultipliers 80 | 81 | let multRoles = multiplierData.roleList 82 | let multiplierInfo = [] 83 | if ((!hideMult || multiplierData.role == 0) && multRoles.length) { 84 | let xpStr = multiplierData.role > 0 ? `${multiplierData.role}x XP` : "Cannot gain XP!" 85 | let roleMultiplierStr = multRoles.length == 1 ? `${int.guild.id != multRoles[0].id ? `<@&${multRoles[0].id}>` : "Everyone"} - ${xpStr}` : `**${multRoles.length} roles** - ${xpStr}` 86 | multiplierInfo.push(roleMultiplierStr) 87 | } 88 | 89 | let multChannels = multiplierData.channelList 90 | if ((!hideMult || multiplierData.channel == 0) && multChannels.length && multiplierData.role > 0 && (multiplierData.role != 1 || multiplierData.channel != 1)) { 91 | let chXPStr = multChannels[0].boost > 0 ? `${multiplierData.channel}x XP` : "Cannot gain XP!" 92 | let chMultiplierStr = `<#${multChannels[0].id}> - ${chXPStr}` // leaving room for multiple channels, via categories or vcs or something 93 | multiplierInfo.push(chMultiplierStr) 94 | if (multRoles.length) multiplierInfo.push(`**Total multiplier: ${multiplier}x XP** (${multiplierModes.channelStacking[multiplierData.channelStacking].toLowerCase()})`) 95 | } 96 | 97 | if (multiplierInfo.length) embed.addFields([{ name: "🌟 Multiplier", value: multiplierInfo.join("\n") }]) 98 | 99 | else if (!db.settings.rewardSyncing.noManual && !db.settings.rewardSyncing.noWarning) { 100 | let syncCheck = tools.checkLevelRoles(int.guild.roles.cache, member.roles.cache, levelData.level, db.settings.rewards) 101 | if (syncCheck.incorrect.length || syncCheck.missing.length) embed.addFields([{ name: "⚠ Note", value: `Your level roles are not properly synced! Type ${tools.commandTag("sync")} to fix this.` }]) 102 | } 103 | 104 | let isHidden = db.settings.rankCard.ephemeral || !!int.options.get("hidden")?.value 105 | return int.reply({embeds: [embed], ephemeral: isHidden}) 106 | 107 | }} -------------------------------------------------------------------------------- /commands/slash/rewardrole.js: -------------------------------------------------------------------------------- 1 | const Discord = require("discord.js") 2 | 3 | module.exports = { 4 | metadata: { 5 | permission: "ManageGuild", 6 | name: "rewardrole", 7 | description: "Add or remove a reward role. (requires manage server permission)", 8 | args: [ 9 | { type: "role", name: "role_name", description: "The role to add or remove", required: true }, 10 | { type: "integer", name: "level", description: "The level to grant the role at, or 0 to remove", min: 0, max: 1000, required: true }, 11 | { type: "bool", name: "keep", description: "Keep this role even when a higher one is reached" }, 12 | { type: "bool", name: "dont_sync", description: "Advanced: Ignores this role when syncing roles" } 13 | ] 14 | }, 15 | 16 | async run(client, int, tools) { 17 | 18 | let db = await tools.fetchSettings() 19 | if (!tools.canManageServer(int.member, db.settings.manualPerms)) return tools.warn("*notMod") 20 | 21 | let role = int.options.getRole("role_name") 22 | let level = tools.clamp(Math.round(int.options.get("level")?.value), 0, 1000) 23 | 24 | let isKeep = !!int.options.get("keep")?.value 25 | let isDontSync = !!int.options.get("dont_sync")?.value 26 | 27 | let existingIndex = db.settings.rewards.findIndex(x => x.id == role.id) 28 | let foundExisting = (existingIndex >= 0) ? db.settings.rewards[existingIndex] : null 29 | 30 | let newRoles = db.settings.rewards 31 | if (foundExisting) newRoles.splice(existingIndex, 1) // remove by default 32 | 33 | function finish(msg) { 34 | let viewRewardRoles = tools.row(tools.button({style: "Primary", label: `View all rewards (${newRoles.length})`, customId: "list_reward_roles"})) 35 | 36 | client.db.update(int.guild.id, { $set: { 'settings.rewards': newRoles, 'info.lastUpdate': Date.now() }}).then(() => { 37 | return int.reply({ content: msg, components: viewRewardRoles }) 38 | }) 39 | } 40 | 41 | // deleting a reward role 42 | if (level == 0) { 43 | if (!foundExisting) return tools.warn("Reward roles can't be granted at level 0! Use this to delete existing reward roles.") 44 | return finish(`❌ **Successfully deleted reward role <@&${role.id}> for level ${foundExisting.level}.**`, newRoles) 45 | } 46 | 47 | // no manage roles perm 48 | if (!int.guild.members.me.permissions.has(Discord.PermissionFlagsBits.ManageRoles)) return tools.warn("*cantManageRoles") 49 | 50 | // can't grant role 51 | if (!role.editable) return tools.warn(`I don't have permission to grant <@&${role.id}>!`) 52 | 53 | // set up new role data 54 | let roleData = { id: role.id, level } 55 | let extraStrings = [] 56 | if (isKeep) { roleData.keep = true; extraStrings.push("always kept") } 57 | if (isDontSync) { roleData.noSync = true; extraStrings.push("ignores sync") } 58 | 59 | newRoles.push(roleData) 60 | let extraStr = (extraStrings.length < 1) ? "" : ` (${extraStrings.join(", ")})` 61 | 62 | // if reward already exists, replace existing role 63 | if (foundExisting) { 64 | if (foundExisting.level == level) return tools.warn(`This role is already granted at level ${level}!`) 65 | return finish(`📝 **<@&${role.id}> will now be granted at level ${level}!** (previously ${foundExisting.level})${extraStr}`) 66 | } 67 | 68 | // otherwise, just add the role 69 | return finish(`✅ **<@&${role.id}> will now be granted at level ${level}!**${extraStr}`) 70 | 71 | }} -------------------------------------------------------------------------------- /commands/slash/sync.js: -------------------------------------------------------------------------------- 1 | const Discord = require('discord.js') 2 | module.exports = { 3 | metadata: { 4 | name: "sync", 5 | description: "Sync your level roles by adding missing ones and removing incorrect ones.", 6 | args: [ 7 | { type: "user", name: "member", description: "Which member to sync (requires manage server permission)", required: false } 8 | ] 9 | }, 10 | 11 | async run(client, int, tools) { 12 | 13 | let foundUser = int.options.get("member") 14 | let member = foundUser ? foundUser.member : int.member 15 | if (!int.guild.members.me.permissions.has(Discord.PermissionFlagsBits.ManageRoles)) return tools.warn("*cantManageRoles") 16 | 17 | let db = await tools.fetchSettings(member.id) 18 | if (!db) return tools.warn("*noData") 19 | else if (!db.settings.enabled) return tools.warn("*xpDisabled") 20 | 21 | let isMod = db.settings.manualPerms ? tools.canManageRoles() : tools.canManageServer() 22 | if (member.id != int.user.id && !isMod) return tools.warn("You don't have permission to sync someone else's roles!") 23 | 24 | else if (db.settings.noManual && !isMod) return tools.warn("You don't have permission to sync your level roles!") 25 | else if (!db.settings.rewards.length) return tools.warn("This server doesn't have any reward roles!") 26 | 27 | let currentXP = db.users[member.id] 28 | if (!currentXP || !currentXP.xp) return tools.noXPYet(member.user) 29 | 30 | let xp = currentXP.xp 31 | let level = tools.getLevel(xp, db.settings) 32 | 33 | let currentRoles = member.roles.cache 34 | let roleCheck = tools.checkLevelRoles(int.guild.roles.cache, currentRoles, level, db.settings.rewards) 35 | if (!roleCheck.incorrect.length && !roleCheck.missing.length) return int.reply("✅ Your level roles are already properly synced!") 36 | 37 | tools.syncLevelRoles(member, roleCheck).then(() => { 38 | let replyStr = ["🔄 **Level roles successfully synced!**"] 39 | if (roleCheck.missing.length) replyStr.push(`Added: ${roleCheck.missing.map(x => `<@&${x.id}>`).join(" ")}`) 40 | if (roleCheck.incorrect.length) replyStr.push(`Removed: ${roleCheck.incorrect.map(x => `<@&${x.id}>`).join(" ")}`) 41 | return int.reply(replyStr.join("\n")) 42 | }).catch(e => int.reply(`Error syncing roles! ${e.message}`)) 43 | 44 | }} -------------------------------------------------------------------------------- /commands/slash/top.js: -------------------------------------------------------------------------------- 1 | const PageEmbed = require("../../classes/PageEmbed.js") 2 | 3 | module.exports = { 4 | metadata: { 5 | name: "top", 6 | description: "View the server's XP leaderboard.", 7 | args: [ 8 | { type: "integer", name: "page", description: "Which page to view (negative to start from last page)", required: false }, 9 | { type: "user", name: "member", description: "Finds a certain member's position on the leaderboard (overrides page)", required: false }, 10 | { type: "bool", name: "hidden", description: "Hides the reply so only you can see it", required: false } 11 | ] 12 | }, 13 | 14 | async run(client, int, tools) { 15 | 16 | let lbLink = `${tools.WEBSITE}/leaderboard/${int.guild.id}` 17 | 18 | let db = await tools.fetchAll() 19 | if (!db || !db.users || !Object.keys(db.users).length) return tools.warn(`Nobody in this server is ranked yet!`); 20 | else if (!db.settings.enabled) return tools.warn("*xpDisabled") 21 | else if (db.settings.leaderboard.disabled) return tools.warn("The leaderboard is disabled in this server!" + (tools.canManageServer(int.member) ? `\nAs a moderator, you can still privately view the leaderboard here: ${lbLink}` : "")) 22 | 23 | let pageNumber = int.options.get("page")?.value || 1 24 | let pageSize = 10 25 | 26 | let minLeaderboardXP = db.settings.leaderboard.minLevel > 1 ? tools.xpForLevel(db.settings.leaderboard.minLevel, db.settings) : 0 27 | let rankings = tools.xpObjToArray(db.users) 28 | rankings = rankings.filter(x => x.xp > minLeaderboardXP && !x.hidden).sort(function(a, b) {return b.xp - a.xp}) 29 | 30 | if (db.settings.leaderboard.maxEntries > 0) rankings = rankings.slice(0, db.settings.leaderboard.maxEntries) 31 | 32 | if (!rankings.length) return tools.warn("Nobody in this server is on the leaderboard yet!") 33 | 34 | let highlight = null 35 | let userSearch = int.options.get("user") || int.options.get("member") // option is "user" if from context menu 36 | if (userSearch) { 37 | let foundRanking = rankings.findIndex(x => x.id == userSearch.user.id) 38 | if (isNaN(foundRanking) || foundRanking < 0) return tools.warn(int.user.id == userSearch.user.id ? "You aren't on the leaderboard!" : "This member isn't on the leaderboard!") 39 | else pageNumber = Math.floor(foundRanking / pageSize) + 1 40 | highlight = userSearch.user.id 41 | } 42 | 43 | let listCol = db.settings.leaderboard.embedColor 44 | if (listCol == -1) listCol = null 45 | 46 | let embed = tools.createEmbed({ 47 | color: listCol || tools.COLOR, 48 | author: {name: 'Leaderboard for ' + int.guild.name, iconURL: int.guild.iconURL()} 49 | }) 50 | 51 | let isHidden = db.settings.leaderboard.ephemeral || !!int.options.get("hidden")?.value 52 | 53 | let xpEmbed = new PageEmbed(embed, rankings, { 54 | page: pageNumber, size: pageSize, owner: int.user.id, ephemeral: isHidden, 55 | mapFunction: (x, y, p) => `**${p})** ${x.id == highlight ? "**" : ""}Lv. ${tools.getLevel(x.xp, db.settings)} - <@${x.id}> (${tools.commafy(x.xp)} XP)${x.id == highlight ? "**" : ""}`, 56 | extraButtons: [ tools.button({style: "Link", label: "Online Leaderboard", url: lbLink}) ] 57 | }) 58 | if (!xpEmbed.data.length) return tools.warn("There are no members on this page!") 59 | 60 | xpEmbed.post(int) 61 | 62 | }} -------------------------------------------------------------------------------- /commands/user_context/view_on_leaderboard.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | metadata: { 3 | name: "View on leaderboard", 4 | slashEquivalent: "top" 5 | } 6 | } -------------------------------------------------------------------------------- /commands/user_context/view_xp.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | metadata: { 3 | name: "Check XP", 4 | slashEquivalent: "rank" 5 | } 6 | } -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_server_ids": ["12345678"], 3 | 4 | "developer_ids": ["12345678"], 5 | 6 | "lockBotToDevOnly": false, 7 | 8 | "enableWebServer": true, 9 | "serverPort": 6880, 10 | "siteURL": "http://localhost:6880", 11 | 12 | "changelogURL": "", 13 | "supportURL": "" 14 | } -------------------------------------------------------------------------------- /database_schema.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | 3 | // Most of the properties below are used for the web server, they are not built into the mongo schema 4 | 5 | // type: the value's data type (bool, int, float, string, collection) 6 | // default: the default value 7 | // min+max: for numbers, forces between those values 8 | // precision: for floats, how many decimal places 9 | // maxlength: for strings, max length 10 | // accept: for strings, accepted values. discord:channel and discord:role accept any of those kind of ids 11 | 12 | const settings = { 13 | enabled: { type: "bool", default: false }, 14 | 15 | gain: { 16 | min: { type: "int", default: 50, min: 0, max: 5000 }, 17 | max: { type: "int", default: 100, min: 0, max: 5000 }, 18 | time: { type: "float", precision: 4, default: 60, min: 0, max: 31536000 }, 19 | }, 20 | 21 | curve: { 22 | 3: { type: "float", precision: 10, default: 1, min: 0, max: 100 }, 23 | 2: { type: "float", precision: 10, default: 50, min: 0, max: 10000 }, 24 | 1: { type: "float", precision: 10, default: 100, min: 0, max: 100000 }, 25 | }, 26 | rounding: { type: "int", default: 100, min: 1, max: 1000 }, 27 | maxLevel: { type: "int", default: 1000, min: 1, max: 1000 }, 28 | 29 | levelUp: { 30 | enabled: { type: "bool", default: false }, 31 | embed: { type: "bool", default: false }, 32 | rewardRolesOnly: { type: "bool", default: false }, 33 | message: { type: "string", maxlength: 6000, default: "" }, 34 | channel: { type: "string", default: "current", accept: ["dm", "current", "discord:channel"] }, 35 | multiple: { type: "int", default: 1, min: 1, max: 1000 }, 36 | multipleUntil: { type: "int", default: 20, min: 0, max: 1000 } 37 | }, 38 | 39 | multipliers: { 40 | roles: { type: "collection", values: { 41 | id: { type: "string", accept: ["discord:role"] }, 42 | boost: { type: "float", min: 0, max: 100, precision: 4 }, 43 | }}, 44 | rolePriority: { type: "string", default: "largest", accept: ["largest", "smallest", "highest", "add", "combine"] }, 45 | channels: { type: "collection", values: { 46 | id: { type: "string", accept: ["discord:channel"] }, 47 | boost: { type: "float", min: 0, max: 100, precision: 4 }, 48 | }}, 49 | channelStacking: { type: "string", default: "multiply", accept: ["multiply", "add", "largest", "channel", "role"] } 50 | }, 51 | 52 | rewards: { type: "collection", values: { 53 | id: { type: "string", accept: ["discord:role"] }, 54 | level: { type: "int", min: 1, max: 1000 }, 55 | keep: { type: "bool" }, 56 | noSync: { type: "bool" }, 57 | }}, 58 | 59 | rewardSyncing: { 60 | sync: { type: "string", default: "level", accept: ["level", "xp", "never"] }, 61 | noManual: { type: "bool", default: false }, 62 | noWarning: { type: "bool", default: false } 63 | }, 64 | 65 | leaderboard: { 66 | disabled: { type: "bool", default: false }, 67 | private: { type: "bool", default: false }, 68 | hideRoles: { type: "bool", default: false }, 69 | maxEntries: { type: "int", default: 0, min: 0, max: 1000000 }, 70 | minLevel: { type: "int", default: 0, min: 0, max: 1000 }, 71 | ephemeral: { type: "bool", default: false }, 72 | embedColor: { type: "int", default: -1, min: -1, max: 0xffffff } 73 | }, 74 | 75 | rankCard: { 76 | disabled: { type: "bool", default: false }, 77 | relativeLevel: { type: "bool", default: false }, 78 | hideCooldown: { type: "bool", default: false }, 79 | ephemeral: { type: "bool", default: false }, 80 | embedColor: { type: "int", default: -1, min: -1, max: 0xffffff } 81 | }, 82 | 83 | hideMultipliers: { type: "bool", default: false }, 84 | manualPerms: { type: "bool", default: false } 85 | } 86 | 87 | const settingsArray = [] 88 | const settingsObj = {} 89 | const settingsIDs = {} 90 | 91 | const schemaTypes = { 92 | "bool": Boolean, 93 | "int": Number, 94 | "float": Number, 95 | "string": String, 96 | "collection": [Object] 97 | } 98 | 99 | function schemaVal(val) { 100 | let result = { type: schemaTypes[val.type] } 101 | if (val.type == "collection") result.default = [] 102 | else if (val.default !== undefined) result.default = val.default 103 | return result 104 | } 105 | 106 | function addToSettingsArray(value, name) { 107 | let obj = value 108 | obj.db = name 109 | settingsArray.push(obj) 110 | settingsIDs[name] = obj 111 | } 112 | 113 | // for settings, create the actual mongo schema 114 | Object.entries(settings).forEach(x => { 115 | let [key, val] = x 116 | if (!val.type) { 117 | let collection = {} 118 | Object.entries(val).forEach(z => { 119 | let [innerKey, innerVal] = z 120 | collection[innerKey] = schemaVal(innerVal) 121 | addToSettingsArray(innerVal, `${key}.${innerKey}`) 122 | }) 123 | settingsObj[key] = collection 124 | } 125 | else { 126 | addToSettingsArray(val, key) 127 | settingsObj[key] = schemaVal(val) 128 | } 129 | }) 130 | 131 | const schema = { 132 | _id: String, 133 | users: { type: Object }, // xp, cooldown, hidden. should be validated but it just slows things down 134 | settings: settingsObj, 135 | info: { 136 | lastUpdate: { type: Number, default: 0 }, 137 | } 138 | } 139 | 140 | const finalSchema = new mongoose.Schema(schema) 141 | 142 | module.exports = { 143 | settings, settingsArray, settingsIDs, schema: finalSchema 144 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const Discord = require("discord.js") 2 | const fs = require("fs") 3 | 4 | const config = require("./config.json") 5 | 6 | const Tools = require("./classes/Tools.js") 7 | const Model = require("./classes/DatabaseModel.js") 8 | 9 | // automatic files: these handle discord status and version number, manage them with the dev commands 10 | const autoPath = "./json/auto/" 11 | if (!fs.existsSync(autoPath)) fs.mkdirSync(autoPath) 12 | if (!fs.existsSync(autoPath + "status.json")) fs.copyFileSync("./json/default_status.json", autoPath + "status.json") 13 | if (!fs.existsSync(autoPath + "version.json")) fs.writeFileSync(autoPath + "version.json", JSON.stringify({ version: "1.0.0", updated: Date.now() }, null, 2)) 14 | 15 | const rawStatus = require("./json/auto/status.json") 16 | const version = require("./json/auto/version.json") 17 | 18 | const startTime = Date.now() 19 | 20 | // create client 21 | const client = new Discord.Client({ 22 | allowedMentions: { parse: ["users"] }, 23 | makeCache: Discord.Options.cacheWithLimits({ MessageManager: 0 }), 24 | intents: ['Guilds', 'GuildMessages', 'DirectMessages', 'GuildVoiceStates'].map(i => Discord.GatewayIntentBits[i]), 25 | partials: ['Channel'].map(p => Discord.Partials[p]), 26 | failIfNotExists: false 27 | }) 28 | 29 | if (!client.shard) { 30 | console.error("No sharding info found!\nMake sure you start the bot from polaris.js, not index.js") 31 | return process.exit() 32 | } 33 | 34 | client.shard.id = client.shard.ids[0] 35 | 36 | client.globalTools = new Tools(client); 37 | 38 | // connect to db 39 | client.db = new Model("servers", require("./database_schema.js").schema) 40 | 41 | // command files 42 | const dir = "./commands/" 43 | client.commands = new Discord.Collection() 44 | fs.readdirSync(dir).forEach(type => { 45 | fs.readdirSync(dir + type).filter(x => x.endsWith(".js")).forEach(file => { 46 | let command = require(dir + type + "/" + file) 47 | if (!command.metadata) command.metadata = { name: file.split(".js")[0] } 48 | command.metadata.type = type 49 | client.commands.set(command.metadata.name, command) 50 | }) 51 | }) 52 | 53 | client.statusData = rawStatus 54 | client.updateStatus = function() { 55 | let status = client.statusData 56 | client.user.setPresence({ activities: status.type ? [{ name: status.name, state: status.state || undefined, type: Discord.ActivityType[status.type], url: status.url }] : [], status: status.status }) 57 | } 58 | 59 | // when online 60 | client.on("ready", () => { 61 | if (client.shard.id == client.shard.count - 1) console.log(`Bot online! (${+process.uptime().toFixed(2)} secs)`) 62 | client.startupTime = Date.now() - startTime 63 | client.version = version 64 | 65 | client.application.commands.fetch() // cache slash commands 66 | .then(cmds => { 67 | if (cmds.size < 1) { // no commands!! deploy to test server 68 | console.info("!!! No global commands found, deploying dev commands to test server (Use /deploy global=true to deploy global commands)") 69 | client.commands.get("deploy").run(client, null, client.globalTools) 70 | } 71 | }) 72 | 73 | client.updateStatus() 74 | setInterval(client.updateStatus, 15 * 60000); 75 | 76 | // run the web server 77 | if (client.shard.id == 0 && config.enableWebServer) require("./web_app.js")(client) 78 | }) 79 | 80 | // on message 81 | client.on("messageCreate", async message => { 82 | if (message.system || message.author.bot) return 83 | else if (!message.guild || !message.member) return // dm stuff 84 | else client.commands.get("message").run(client, message, client.globalTools) 85 | }) 86 | 87 | // on interaction 88 | client.on("interactionCreate", async int => { 89 | 90 | if (!int.guild) return int.reply("You can't use commands in DMs!") 91 | 92 | // for setting changes 93 | if (int.isStringSelectMenu()) { 94 | if (int.customId.startsWith("configmenu_")) { 95 | if (int.customId.split("_")[1] != int.user.id) return int.deferUpdate() 96 | let configData = int.values[0].split("_").slice(1) 97 | let configCmd = (configData[0] == "dir" ? "button:settings_list" : "button:settings_view") 98 | client.commands.get(configCmd).run(client, int, new Tools(client, int), configData) 99 | } 100 | return; 101 | } 102 | 103 | // also for setting changes 104 | else if (int.isModalSubmit()) { 105 | if (int.customId.startsWith("configmodal")) { 106 | let modalData = int.customId.split("~") 107 | if (modalData[2] != int.user.id) return int.deferUpdate() 108 | client.commands.get("button:settings_edit").run(client, int, new Tools(client, int), modalData[1]) 109 | } 110 | return; 111 | } 112 | 113 | // general commands and buttons 114 | let foundCommand = client.commands.get(int.isButton() ? `button:${int.customId.split("~")[0]}` : int.commandName) 115 | if (!foundCommand) return 116 | else if (foundCommand.metadata.slashEquivalent) foundCommand = client.commands.get(foundCommand.metadata.slashEquivalent) 117 | 118 | let tools = new Tools(client, int) 119 | 120 | // dev perm check 121 | if (foundCommand.metadata.dev && !tools.isDev()) return tools.warn("Only developers can use this!") 122 | else if (config.lockBotToDevOnly && !tools.isDev()) return tools.warn("Only developers can use this bot!") 123 | 124 | try { await foundCommand.run(client, int, tools) } 125 | catch(e) { console.error(e); int.reply({ content: "**Error!** " + e.message, ephemeral: true }) } 126 | }) 127 | 128 | client.on('error', e => console.warn(e)) 129 | client.on('warn', e => console.warn(e)) 130 | 131 | process.on('uncaughtException', e => console.warn(e)) 132 | process.on('unhandledRejection', (e, p) => console.warn(e)) 133 | 134 | client.login(process.env.DISCORD_TOKEN) -------------------------------------------------------------------------------- /json/curve_presets.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | { 4 | "name": "Polaris (Default)", 5 | "desc": "The default and recommended curve. Decently balanced.", 6 | "curve": { "3": 1, "2": 50, "1": 100 }, 7 | "round": 100, 8 | "bestRange": [50, 100] 9 | }, 10 | 11 | { 12 | "name": "RoboTop", 13 | "desc": "The curve used by RoboTop, the predecessor to Polaris. Starts fine, but gets absurdly difficult.", 14 | "curve": { "3": 5, "2": 100, "1": 150 }, 15 | "round": 100, 16 | "bestRange": [50, 100] 17 | }, 18 | 19 | { 20 | "name": "Generous", 21 | "desc": "Too hard to level up? This curve is a little more on the friendly side.", 22 | "curve": { "3": 0.5, "2": 20, "1": 50 }, 23 | "round": 50, 24 | "bestRange": [50, 100] 25 | }, 26 | 27 | { 28 | "name": "Linear", 29 | "desc": "Completely linear. Set XP per message to 100-100 to level up exactly every 100 messages.", 30 | "curve": { "3": 0, "2": 0, "1": 10000 }, 31 | "round": 1, 32 | "bestRange": [100, 100] 33 | }, 34 | 35 | { 36 | "name": "Exponential", 37 | "desc": "Not too easy, not too difficult. And it scales really smoothly.", 38 | "curve": { "3": 0, "2": 250, "1": 0 }, 39 | "round": 1, 40 | "bestRange": [50, 100] 41 | }, 42 | 43 | { 44 | "name": "Minimalist", 45 | "desc": "1-10 XP per message. Why not?.", 46 | "curve": { "3": 1, "2": 2, "1": 3 }, 47 | "round": 10, 48 | "bestRange": [1, 10] 49 | }, 50 | 51 | { 52 | "name": "Ultra Minimalist", 53 | "desc": "1 XP per message, level up every 100 messages. Alternatively, I also recommend a 12 hour cooldown where you level up every 10 messages!", 54 | "curve": { "3": 0, "2": 0, "1": 100 }, 55 | "round": 1, 56 | "bestRange": [1, 1] 57 | }, 58 | 59 | { 60 | "name": "Minecraft", 61 | "desc": "Minecraft's XP curve changes a bit depending on your level, but this is the curve from level 0-15. Close enough. I recommend 3-11 XP per message, which is how much a Bottle o' Enchanting grants.", 62 | "curve": { "3": 0, "2": 1, "1": 6 }, 63 | "round": 1, 64 | "bestRange": [3, 11] 65 | }, 66 | 67 | { 68 | "name": "Arcane", 69 | "desc": "What a mysterious curve that definitely doesn't come from another bot!", 70 | "curve": { "3": 0, "2": 50, "1": 25 }, 71 | "round": 5, 72 | "bestRange": [15, 40] 73 | }, 74 | 75 | { 76 | "name": "Definitely not Mee6", 77 | "desc": "I can assure you that this oddly specific curve was not lifted from that one bot people don't like. Also last time I checked, you can't own a cubic function.", 78 | "curve": { "3": 1.6666666667, "2": 22.5, "1": 75.8333333333 }, 79 | "round": 5, 80 | "bestRange": [15, 25] 81 | } 82 | ], 83 | 84 | "difficultyRatings": { 85 | "0": "Linear", 86 | "1": "Basically linear", 87 | "25": "Almost no scaling", 88 | "50": "Extremely generous", 89 | "100": "Very generous", 90 | "225": "Generous", 91 | "350": "Slightly generous", 92 | "500": "Normal", 93 | "1000": "Slightly spicy", 94 | "1500": "Moderately spicy", 95 | "2000": "Spicy", 96 | "2500": "Very spicy", 97 | "3250": "Extremely spicy", 98 | "4000": "Dangerously spicy", 99 | "5000": "Insanely spicy", 100 | "6500": "Brutal", 101 | "10000": "Extremely brutal", 102 | "20000": "What the actual heck", 103 | "35000": "Are you out of your mind", 104 | "50000": "AAAAAAAAAAAAAAAAAAAAAAA" 105 | } 106 | } -------------------------------------------------------------------------------- /json/default_status.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Custom", 3 | "name": "Polaris", 4 | "state": "/rank", 5 | "status": "online" 6 | } -------------------------------------------------------------------------------- /json/multiplier_modes.json: -------------------------------------------------------------------------------- 1 | { 2 | "rolePriority": { 3 | "largest": "Role with highest multiplier", 4 | "smallest": "Role with lowest multiplier", 5 | "highest": "Highest role", 6 | "add": "All multipliers summed, n-1 each", 7 | "combine": "All multipliers combined" 8 | }, 9 | 10 | "channelStacking": { 11 | "multiply": "Multipliers combined", 12 | "add": "Multipliers summed, n-1 each", 13 | "largest": "Largest between role and channel", 14 | "channel": "Channel multiplier priority", 15 | "role": "Role multiplier priority" 16 | } 17 | } -------------------------------------------------------------------------------- /json/quick_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "home": [ 4 | { "groupName": "Quick settings" }, 5 | { "emoji": "✨", "name": "General", "folder": "general" }, 6 | { "emoji": "🎁", "name": "Levelling", "folder": "level" }, 7 | { "emoji": "📊", "name": "Rank and Leaderboard", "folder": "rank" } 8 | ], 9 | 10 | "general": [ 11 | { "emoji": "⬅️", "name": "Back", "folder": "home", "groupName": "General settings" }, 12 | { "db": "enabled", "name": "Enable XP", "desc": "Allows members to gain XP", "tip": "Disabling will prevent anyone from gaining XP, but will not reset or remove existing XP." }, 13 | { "db": "gain.min", "name": "Min XP", "desc": "Minimum amount of XP to earn per message", "tip": "Each message gives a random amount of XP, this changes the lower bound" }, 14 | { "db": "gain.max", "name": "Max XP", "desc": "Maximum amount of XP to earn per message", "tip": "Each message gives a random amount of XP, this changes the upper bound" }, 15 | { "db": "gain.time", "name": "XP Cooldown", "desc": "Members must wait this many seconds before they can gain XP again", "tip": "Messages sent during the cooldown period will not gain", "str": "# seconds" }, 16 | { "db": "maxLevel", "name": "Level cap", "desc": "The highest level members can reach", "tip": "Members can still gain XP past this, unless you reward a role with a 0x multiplier" } 17 | ], 18 | 19 | "level": [ 20 | { "emoji": "⬅️", "name": "Back", "folder": "home", "groupName": "Levelling settings" }, 21 | { "db": "levelUp.enabled", "name": "Enable level up message", "desc": "Sends a message or DM when a member levels up" }, 22 | { "db": "levelUp.multiple", "name": "Level up multiple", "desc": "Only sends the level up message when reaching a multiple of this number", "tip": "A multiple of 5 sends on levels 5, 10, 15, 20, 25, ...", "str": "Every # level(s)" }, 23 | { "db": "levelUp.multipleUntil", "name": "Level up multiple cap", "desc": "After reaching this level, the level up multiple is no longer used and a message is posted on every level reached", "tip": "Set to 0 to disable", "str": "Level #", "zeroText": "Disabled" }, 24 | { "space": true }, 25 | { "db": "rewardSyncing.noManual", "name": "Manual sync", "invert": true, "desc": "Allows members to manually sync their roles whenever they want, via /sync" }, 26 | { "db": "rewardSyncing.noWarning", "name": "Sync warning", "invert": true, "desc": "Show a warning message in /rank when a member's roles aren't synced properly" } 27 | ], 28 | 29 | "rank": [ 30 | { "emoji": "⬅️", "name": "Back", "folder": "home", "groupName": "Rank and Leaderboard settings" }, 31 | { "db": "rankCard.disabled", "name": "Enable /rank", "invert": true, "desc": "Enables /rank cards", "tip": "Disabling this also hides most info in /calculate" }, 32 | { "db": "rankCard.ephemeral", "name": "Hide /rank messages", "desc": "Forces /rank messages to be ephemeral, meaning only the member who typed the commmand can see it" }, 33 | { "db": "rankCard.hideCooldown", "name": "Hide cooldown", "desc": "Hides the amount of time until XP can be gained again " }, 34 | { "db": "hideMultipliers", "name": "Hide multipliers", "desc": "Hides which roles have multipliers (except 0x)" }, 35 | { "db": "rankCard.relativeLevel", "name": "Show relative XP", "desc": "Changes the 'next level' section of /rank to start at 0 and only include XP from that level", "tip": "e.g. If level 10 requires 2000 XP and level 11 requires 3000, it will display \"500/1000 XP until level 11\" for a member with 2500 XP" }, 36 | { "space": true }, 37 | { "db": "leaderboard.disabled", "name": "Enable leaderboard", "invert": true, "desc": "Enables the server XP leaderboard" }, 38 | { "db": "leaderboard.private", "name": "Private leaderboard", "desc": "Restricts the online leaderboard so only server members can access it (requires logging in)" }, 39 | { "db": "leaderboard.hideRoles", "name": "Hide reward roles", "desc": "Hides the list of reward roles on the online leaderboard" }, 40 | { "db": "leaderboard.ephemeral", "name": "Hide /top messages", "desc": "Forces /top messages to be ephemeral, meaning only the member who typed the commmand can see it" }, 41 | { "db": "leaderboard.minLevel", "name": "Minimum leaderboard level", "desc": "Restricts the leaderboard to only show members above this level", "tip": "Set to 0 to disable", "zeroText": "None" }, 42 | { "db": "leaderboard.maxEntries", "name": "Max leaderboard entries", "desc": "Only shows the top X members on the leaderboard", "tip": "Set to 0 to display everyone", "zeroText": "Unlimited" } 43 | ] 44 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@discordjs/builders": "^1.9.0", 4 | "@discordjs/rest": "^2.4.0", 5 | "bufferutil": "^4.0.8", 6 | "connect-timeout": "^1.9.0", 7 | "cookie-parser": "^1.4.6", 8 | "cors": "^2.8.5", 9 | "discord-api-types": "^0.37.99", 10 | "discord.js": "^14.16.1", 11 | "dotenv": "^16.4.5", 12 | "express": "^4.19.2", 13 | "express-async-errors": "^3.1.1", 14 | "install": "^0.13.0", 15 | "mongoose": "^8.6.1", 16 | "ordinal": "^1.0.3", 17 | "utf-8-validate": "^6.0.4", 18 | "zlib-sync": "^0.1.9" 19 | }, 20 | "name": "polaris", 21 | "version": "1.0.0", 22 | "main": "index.js", 23 | "scripts": { 24 | "test": "echo \"Error: no test specified\" && exit 1" 25 | }, 26 | "author": "", 27 | "license": "ISC", 28 | "description": "" 29 | } 30 | -------------------------------------------------------------------------------- /polaris.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ] 7 | } -------------------------------------------------------------------------------- /polaris.js: -------------------------------------------------------------------------------- 1 | const Discord = require("discord.js") 2 | require('dotenv').config(); 3 | 4 | const token = process.env.DISCORD_TOKEN 5 | if (!token) return console.log("No Discord token provided! Put one in your .env file") 6 | 7 | const Shard = new Discord.ShardingManager('./index.js', { token } ); 8 | const guildsPerShard = 2000 9 | 10 | Discord.fetchRecommendedShardCount(token, {guildsPerShard}).then(shards => { 11 | let shardCount = Math.floor(shards) 12 | console.info(shardCount == 1 ? "Starting up..." : `Preparing ${shardCount} shards...`) 13 | Shard.spawn({amount: shardCount, timeout: 60000}).catch(console.error) 14 | Shard.on('shardCreate', shard => { 15 | shard.on("disconnect", (event) => { 16 | console.warn(`Shard ${shard.id} disconnected!`); console.log(event); 17 | }); 18 | shard.on("death", (event) => { 19 | console.warn(`Shard ${shard.id} died!\nExit code: ${event.exitCode}`); 20 | }); 21 | shard.on("reconnecting", () => { 22 | console.info(`Shard ${shard.id} is reconnecting!`); 23 | }); 24 | 25 | }) 26 | }).catch(e => {console.log(e.headers || e)}) -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Polaris! 2 | ...is a super customizable XP bot for Discord with all sorts of neat features! 3 | Unfortunately, it's become increasingly annoying to host, so I'm passing the torch by open-sourcing all the messy code and allowing anyone to host this thing! 4 | 5 | If you're an experienced software dev i am so sorry for what comes next 6 | 7 | ## How do I host it? 8 | It's ""easy!"" 9 | 10 | ### Step 0: Node.js 11 | 1. If you don't have node.js, [go get it](https://nodejs.org/en) 12 | 2. Once you've set up node, run `npm i` in the bot's root directory 13 | - If you're new around here, and you're on Windows, you can open up the terminal in a specific directory by shift+rightclicking a blank spot in the folder and pressing Open in Terminal/Powershell/cmd/etc. 14 | 15 | ### Step 1: Setting up the bot 16 | Fortunately Discord makes this super easy, thanks Discord 17 | 1. Create a Discord Application via the [developer portal](https://discord.com/developers/applications) 18 | - Name it, decorate it, etc 19 | 2. Copy the file named `.env.example`, rename it to `.env`, and open it in a text editor 20 | 3. Remove the placeholder values and paste in the application's actual ID, token, and secret. 21 | - ID can be found in the general tab on the dev portal. It's literally just the bot's account ID 22 | - A token can be generated from the bot tab. Keep it top secret, you should know this 23 | - A secret can be generated from the OAuth2 tab 24 | 3. On the OAuth2 tab, add `http://localhost:6880/auth` as a redirect URI. You can change the port as long as you do so in config.json as well. 25 | 4. Invite the bot to a server by going to [this link](https://discord.com/oauth2/authorize?client_id=123456789&permissions=429765545024&scope=bot%20applications.commands) and replacing "123456789" in the URL with your bot's ID 26 | 27 | ### Step 2: Set up the config file 28 | 1. Open the `config.json` file in the codebase 29 | - Add a server ID to `test_server_ids`, this is where dev commands will be deployed when you run the bot for the first time 30 | - Add your own user ID to `developer_ids` so you can run dev commands 31 | - `lockBotToDevOnly` makes it so only you can use the bot, for local testing and such 32 | - There's a couple settings for the web server, you probably don't really need to touch them 33 | - `siteURL` does **NOT** control the actual URL for your server - it just changes where the bot links users to 34 | - If you provide a `changelogURL` or `supportURL` they'll appear in /botstatus 35 | 2. Test out the bot by opening up your terminal in the root directory and typing `node polaris.js` 36 | - Most commands won't work due to the lack of a database, but if the bot appears online it means you're good 37 | - Only dev commands will be present by default, the rest will be deployed in step 4. Dev commands are only visible to server admins and only work if you're specified as a dev in the config file 38 | 39 | ### Step 3: Setting up the database 40 | Personally I know very little about this topic so if it sounds like I have no idea what I'm saying, it's because I don't. This is just what I do for my own projects. 41 | This step is like the equivalent of learning about port forwarding for your Minecraft server, so don't feel bad if this is the point where you give up 42 | 43 | There's many different ways to set up MongoDB, but I recommend one of these methods: 44 | 45 | **Option 1: [MongoDB Atlas](https://cloud.mongodb.com/)** 46 | - This is MongoDB's cloud service. It's by far the easiest to set up, but all the data is stored on their cloud, not yours. The free tier has a storage limit, but you won't go anywhere close to exceeding it. 47 | 48 | **Option 2: Host it on a server** 49 | - My personal choice, because I have one. If you have an Ubuntu server, [this tutorial](https://www.digitalocean.com/community/tutorials/how-to-install-mongodb-on-ubuntu-20-04) followed by [this one](https://www.digitalocean.com/community/tutorials/how-to-secure-mongodb-on-ubuntu-20-04) should be good. (if the `mongo` command doesn't work, use `mongosh`) 50 | - If you don't have an Ubuntu server, just google around and there will probably be a guide for your platform 51 | - If you created a DB along with a username and password, you did it correctly 52 | - I also recommend setting up [MongoDB compass](https://www.mongodb.com/products/tools/compass) so you have a GUI! 53 | 54 | **Option 3: Host it on your computer** 55 | - Probably not the wisest idea since it needs to be running 24/7. I would only do this if you're hosting the entire bot on it for some reason, or just want to test things out. But if that sounds like a plan to you, go follow this [absurdly long tutorial](https://www.mongodb.com/docs/manual/tutorial/install-mongodb-on-windows/). 56 | 57 | All set? Awesome. Polaris uses two collections: `servers` for server data, and `auth` for website logins. I'm pretty sure the bot automatically creates these for you. 58 | 59 | 1. Find your **connection string**. The way you obtain it depends on how you set up MongoDB, but there will definitely be one. It should start with `mongodb://` or `mongodb+srv://` or something similar. **If you're self-hosting and can't find the string, you can skip this and just use the username + password you set up.** 60 | 61 | 2. Open up `.env` and paste in your database name as well as the connection string (MONGO_DB_URI in the file). If you don't want to use a connection string you can leave the value blank and provide the IP, username, and password instead. 62 | 63 | 3. Fire up the bot and check the console to see if it connected! 64 | - To double check, you can run /db with no arguments - if the bot responds (likely "No data!") it means you actually did it correctly!!! If not, cry 65 | 66 | If it's not connecting, try checking: 67 | - Is the database actually running? 68 | - Did you paste the connection string incorrectly? 69 | - Did you enter the correct database name into .env? 70 | - Did you enter the right username and password? 71 | 72 | 73 | ### Step 4: Final steps 74 | 1. Deploy the bot's commands by running /deploy with the global argument set to true 75 | 2. If you have the web server enabled, it should be running on localhost. From there you can authorize your Discord account and change server settings 76 | - The web server uses a lot of resources but is also needed to modify advanced settings for the bot. It also contains the leaderboard page 77 | - If you're happy with your settings and only plan on using the bot for one server, you can disable the server in `config.json` and only enable it when you need to tweak things 78 | - Note that most simple settings (booleans and numbers) can be tweaked from /config 79 | - Reward roles and multipliers can be configured via /rewardrole and /multiplier 80 | - If you're running Polaris from a hosted server, make sure the port you chose is open. Then you should be able to visit `http://:`, e.g. http://7.7.7.7:6880. Make sure to also add it as an OAuth2 redirect in the Discord dev portal, ending in /auth. (e.g. http://7.7.7.7:6880/auth) 81 | - If you don't want the URL to be a shady looking IP, you're going to need to buy a domain then reverse proxy your localhost into an actual public URL. I wish you luck. (add that one as an OAuth2 redirect as well) 82 | - Just google "localhost to public URL" and you should get some info on how to do this 83 | - Alternatively, you can try [Cloudflare Tunnel](https://developers.cloudflare.com/pages/how-to/preview-with-cloudflare-tunnel/) or [ngrok](https://ngrok.com/) - though these are usually more temporary solutions 84 | 85 | 3. If you want the bot to be public, set that up in the Discord dev portal. But make sure you can handle it. 86 | - The bot should work fine until sharding kicks in (at ~2500 servers), then it might start to break down a little 87 | - Really, it comes down to your server specs and the number of members in a server 88 | 89 | --- 90 | 91 | ## Some other tips 92 | ### Transferring data from the original Polaris 93 | **NOTE**: If you are listed as a bot developer, you can access the dashboard for any server your bot is in. The JSON import feature is heavily limited for non-devs (security reasons), so feel free to use this power in order to import .json files for others. 94 | 1. On the [original Polaris dashboard](https://gdcolon.com/polaris), go to the Data tab of your server settings and press **Download all data**. This will download a .json file 95 | 2. On your own hosted dashboard, go to the Data tab of your server settings, scroll down, and scroll down to the import settings section 96 | 3. Upload the .json file and press import 97 | 4. All data from Polaris should be transferred! 98 | 99 | ### Using dev commands 100 | `/db` allows you to view a server's raw data, or modify it 101 | - e.g. `/db property:settings.multipliers` returns the data in `settings.multipliers` 102 | - e.g. `/db property:users.123456.xp new_value:10` sets the user with ID 123456's XP to 10 103 | 104 | `/setactivity`lets you change the bot's custom status, the args should walk you through it 105 | 106 | `/setversion` updates the version number in /botstatus 107 | 108 | `/deploy` deploys dev commands to the server, or the global commands everyone uses. 109 | - There's also an option to undeploy the dev commands, but make sure at least one server has them or you'll need to dive into the code to get them back 110 | - If this happens, open `index.js` and change `if (cmds.size < 1)` to `if (true)` in order to force-deploy the commands on the next startup 111 | 112 | `/run` simply lets you evaluate js code, not much need for this unless you're adding new stuff and are familiar with discord.js 113 | 114 | Devs can also view and modify any server from the web dashboard. The main use for this is importing from .json files, since only bot devs can do that (security reasons) 115 | 116 | ## Want to modify the bot? 117 | Do whatever you want as long as you credit me and use your own fork for it. 118 | 119 | * If you're hosting this publicly, credit me extra hard 120 | * Do not add any paid or monetized features 121 | * Issues and PRs on this repo are only for things that improve the open-source code, it's not a place for feature requests and new stuff as I'm no longer maintaining this bot 122 | 123 | If you ever have any questions feel free to reach out to me, the Polaris support server is a good place for it 124 | 125 | And if the code is bad, forgive me --------------------------------------------------------------------------------