diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 218a21a..5a31a31 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,72 +1,30 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" +name: CodeQL on: push: - branches: [ "main" ] + branches: ["main", "professional-v14-rebuild"] pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] + branches: ["main"] schedule: - - cron: '18 10 * * 4' + - cron: "18 10 * * 4" jobs: analyze: - name: Analyze + name: Analyze JavaScript runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write - strategy: - fail-fast: false - matrix: - language: [ 'javascript' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + - name: Repository'i al + uses: actions/checkout@v4 - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh + - name: CodeQL başlat + uses: github/codeql-action/init@v3 + with: + languages: javascript - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + - name: CodeQL analizi + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/node-compatibility.yml b/.github/workflows/node-compatibility.yml new file mode 100644 index 0000000..8c73890 --- /dev/null +++ b/.github/workflows/node-compatibility.yml @@ -0,0 +1,42 @@ +name: Node.js Compatibility + +on: + push: + branches: + - main + - professional-v14-rebuild + pull_request: + branches: ["main"] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [20, 22, 24] + + steps: + - name: Repository'i al + uses: actions/checkout@v4 + + - name: Node.js ${{ matrix.node-version }} kur + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Bağımlılıkları yükle + run: npm install --no-audit --no-fund + + - name: JavaScript sözdizimini kontrol et + shell: bash + run: | + set -euo pipefail + find . -type f -name '*.js' \ + -not -path './node_modules/*' \ + -print0 | while IFS= read -r -d '' file; do + node --check "$file" + done + + - name: Testleri çalıştır + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4db45c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Local configuration / secrets +.env +.env.* +!.env.example +botConfig.local.js + +# Runtime database and database sidecars +data/ +*.db +*.sqlite +*.sqlite3 +*.sqlite-wal +*.sqlite-shm +*.db-journal + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS / editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Temporary files +*.tmp +*.temp diff --git a/README.md b/README.md index a39e1cf..c683060 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,667 @@ -# Welcome To EconomyBot -## This EconomyBot was written by 0_0#6666 -### Language : JavaScript/NodeJS (Core Lang : EN) -##### Library : Discord.js -###### Framework used : quick.eco by Snowflake Development - -##### IN BOTCONFIG.JSON DO NOT CHANGE ANY VARIABLE THE CountChannel var is for if you want to set a channel to count for users if not just leave it blank AND EVERYTHING ELSE IS SELF EPLAINATORY JUST CHANGE THE VALUES INSIDE "" TO MAKE YOUR BOT FUNCTION PROPERLY! -# Links -- 🔗 [Youtube Channel](https://www.youtube.com/channel/UCF9E-xef9jL9QgziZRDHKKQ) -- [Support Server Link](https://discord.gg/ARu4hr6hJw) -# Copyright -Copyright 2020 © All RIghts are Reserved | If you are using any part of code please give me credits for the same. Thanks - -# License -**nginx 2021 all rights reserved** - -# Features -- Shop -- Customisable Daily, weekly , search , crime and beg commands (earning) -- Balance -- Leaderboard -- CodeFactor : Easily usable -## Contribute -### Feel free to contribute to the repository by forking it and submitting a pull request we would love to have you as a contributor! You mst read through the nginx code of conduct and the (Included) license carefully before submitting a pull request. -#### Node Version Requirement -``12.x or higher`` - -**NOTE FOR GLITCH HOSTERS -`` THIS BOT DOES NOT DIE IT SENDS A HEARTBEAT (PING) EVERY 5 MINS TO THE GLITCH PROJECT SO THAT YOUR PROJECT STAYS ALIVE IF IT DOES NOT WORK FOR YOU THEN -DM ME FOR A SCRIPT THAT PREVENTS THIS JOIN THE SERVER ABOVE AND DM ME @0_0#6666 THIS MIGHT GET YOUR PROJECT SUSPENDED BUT YOU CAN ALWAYS -MAKE A NEW ONE USING MY TUTORIAL :D``** -``IT WORKS ON REPL.IT PRETTY FINE ITS BEEN TESTED ALREADY`` - -# Host On Repl.it -[![Use on Repl.it](https://repl.it/badge/github/ZeroDiscord/EconomyBot)](https://repl.it/github/ZeroDiscord/EconomyBot) -# Host On Glitch -[Click Here to Host On Glitch](https://glitch.com/edit/#!/import/git?url=https://github.com/ZeroDiscord/EconomyBot/) - -# Dependencies -- *Discord.js v12* -- *quick.eco (has a Dev dependency of quick.db and better-sqlite3*) +
+ +# INS Development Economy Bot + +### Re-engineered • Node.js 20+ • discord.js v14 • English / Türkçe + +**A self-hosted, configuration-first Discord economy and community marketplace platform with persistent inventories, member shops, transactional trading, configurable item drops, native slash commands and bilingual localization.** + +[![Node.js](https://img.shields.io/badge/Node.js-20%2B-339933?style=for-the-badge&logo=node.js&logoColor=white)](https://nodejs.org/) +[![discord.js](https://img.shields.io/badge/discord.js-v14-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.js.org/) +[![SQLite](https://img.shields.io/badge/SQLite-better--sqlite3-003B57?style=for-the-badge&logo=sqlite&logoColor=white)](https://www.sqlite.org/) +[![License](https://img.shields.io/badge/License-Nginx-8A2BE2?style=for-the-badge)](LICENSE) + +🇹🇷 Türkçe🇬🇧 English💬 Join the Discord Server

+EconomyBot Preview + +
+ +--- + +## English + +> **Navigation:** [Overview](#overview) · [Why this version is different](#why-this-version-is-different) · [Architecture](#architecture) · [Configuration](#configuration) · [Market & Member Shops](#market--member-shops) · [Inventory & Drops](#inventory--drops) · [Shared Inventory](#shared-inventory-foundation) · [Commands](#commands) · [Installation](#installation) · [Testing](#testing) + +### Overview + +EconomyBot is no longer a small modernization of the original `ZeroDiscord/EconomyBot` codebase. This release should be treated as a **separately engineered generation of the bot**. + +The original project provided the starting point and historical reference, but the runtime, storage boundaries, command layer, permission handling, localization pipeline, inventory model, market behavior and transaction strategy have been substantially redesigned around modern Node.js 20+ and discord.js v14. + +In practical terms: this is intended to feel like **a different bot built on the same project lineage**, not a cosmetic upgrade. + +### Why this version is different + +This release introduces a configuration-first domain model instead of burying economy definitions inside command files. The system now validates the configured item registry and market during startup, separates canonical items from purchasable market entries, persists market stock, and maintains player listings as independent transactional records. + +The economy engine was also rebuilt around explicit service boundaries. SQLite is used as a durable state layer, money operations and marketplace settlements execute atomically, inventory operations are centralized, cooldown state is persistent, and slash/prefix commands execute against a shared command context rather than relying on compatibility hacks. + +The command system was rebuilt for native discord.js v14 interactions, with centralized validation, bilingual translation, role/user/permission-based administration, alias resolution and defensive error handling. The project also includes CI coverage for multiple supported Node.js versions and CodeQL analysis. + +### Architecture + +```text + ┌─────────────────────────┐ + │ Discord Gateway │ + └────────────┬────────────┘ + │ + ┌───────────────────┴───────────────────┐ + │ │ + Slash Interactions Prefix Messages + │ │ + └───────────────────┬───────────────────┘ + │ + Unified Command Context + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + Economy Service Inventory Service Localization + │ │ │ + └───────────────────┼───────────────────┘ + │ + Transactional SQLite + │ + ┌─────────────────────────┼─────────────────────────┐ + │ │ │ + Balances Inventories Marketplace + │ │ │ + Cooldowns Drops Member Listings +``` + +The marketplace and inventory layers were intentionally separated so a future roleplay bot can share the same inventory representation without forcing roleplay functionality into the economy bot itself + +### Configuration + +Nearly every piece of the item economy is now declarative in `botConfig.js`. + +```js +module.exports = { + token: "BOT_TOKEN", + prefix: "!", + serverId: "SERVER_ID", + language: "en", + + admins: ["ADMIN_USER_ID"], + adminRoles: ["ADMIN_ROLE_ID"], + adminRoleNames: [], + + market: [ + { item: "laptop", price: 2000, stock: 10 }, + { item: "mobile", price: 1000, stock: 15 }, + { item: "pc", price: 3000, stock: 5 } + ], + + items: [ + { + id: "laptop", + name: "Laptop", + description: "A portable computer.", + dailyDropChance: 0, + weeklyDropChance: 2 + }, + { + id: "mobile", + name: "Mobile", + description: "A personal mobile phone.", + dailyDropChance: 1, + weeklyDropChance: 4 + }, + { + id: "pc", + name: "PC", + description: "A desktop computer.", + dailyDropChance: 0, + weeklyDropChance: 1 + } + ], + + sharedInventory: { + enabled: false, + databasePath: "./data/shared-economy.sqlite" + }, + + debug: false, + countChannel: "COUNT_CHANNEL_ID" +}; +``` + +`items` is the **canonical item registry**. If an item does not exist here, the bot will not allow it to be introduced through commands. + +`market` is a **sales catalog**, not the item registry. Every market entry must reference a configured item and must start with stock greater than zero. Initial stock is persisted per guild in SQLite, so restarting the bot does not silently refill sold-out products. + +### Market & Member Shops + +The old hard-coded “Laptop / Mobile / PC” concept has been replaced with a real catalog system. + +The system market is defined through `market` in configuration. Prices and initial stock are configurable without touching command source code. + +Every member can participate in the marketplace. A member can take an item from their own inventory and publish it as a marketplace listing with `/sell`. + +A member listing contains: + +- The configured item identity +- Seller ID +- Quantity listed +- Unit price +- Creation timestamp +- A unique listing ID + +A buyer can purchase from that listing with `/buy `. The settlement is performed inside the same SQLite transaction: buyer funds are checked, seller funds are credited, the listing quantity is reduced or removed, and the purchased item is delivered to the buyer's inventory as one atomic operation. + +Members therefore do not need an administrator to create a personal shop. A listing itself acts as a **member shop inventory slot**, while `/shop` provides a single community-facing market view. + +Most importantly, members cannot manufacture arbitrary item types through the marketplace. They may only sell items that: + +1. Exist in `items`. +2. Are currently present in their own inventory. +3. Are listed with a valid positive quantity and price. + +### Inventory & Drops + +The inventory layer is now an explicit service rather than a side effect of shopping code. + +Items can enter inventories from the configured market, member-to-member marketplace purchases, daily rewards and weekly rewards. + +Daily and weekly commands can also produce item drops. Each item controls its own probability: + +```js +{ + id: "mobile", + name: "Mobile", + description: "A personal mobile phone.", + dailyDropChance: 1, + weeklyDropChance: 4 +} +``` + +Drop values are percentages. The configured probabilities for a drop type may not exceed 100% in total. The remaining probability represents “no item drop”. + +This makes the reward table tunable without modifying command code. + +### Shared Inventory Foundation + +The inventory key space is designed around a stable `inventory::` representation so another bot can consume the same logical inventory records. + +This is deliberately **optional**. By default, EconomyBot uses its own local SQLite database. + +To prepare a future roleplay bot to share inventories, configure: + +```js +sharedInventory: { + enabled: true, + databasePath: "./data/shared-economy.sqlite" +} +``` + +Only enable this when the participating applications are intentionally designed to use the same SQLite file and compatible item IDs. The roleplay bot can then build on the same inventory namespace without requiring EconomyBot to absorb roleplay-specific business logic. + +### Commands + +| Command | Purpose | +|---|---| +| `/bal` | Show balance and economy rank | +| `/daily` | Claim money and a possible configured item drop | +| `/weekly` | Claim money and a possible configured item drop | +| `/work` | Earn money from a random job | +| `/beg` | Trigger a random earning event | +| `/search` | Search for money | +| `/rob` | Attempt a robbery mechanic | +| `/transfer` | Transfer money to another user | +| `/shop` | View the system market and member shops | +| `/buy` | Buy from the system market or a member listing | +| `/sell` | Publish configured inventory items in the member marketplace | +| `/inventory` | View owned items | +| `/lb` | View the economy leaderboard | +| `/ping` | View bot/client latency | +| `/help` | View commands and current prefix | +| `/prefix` | Change the server prefix | +| `/addmoney` | Administrator economy operation | +| `/setmoney` | Administrator economy operation | + +Prefix aliases remain available, including Turkish aliases such as `!bakiye`, `!günlük`, `!mağaza`, `!envanter`, `!aktar`, `!sat` and `!sıralama` where supported. + +### Localization + +Set `language` to `en` or `tr` in `botConfig.js`. + +The selected language controls command descriptions, responses, embeds, cooldown messages, marketplace wording and help content. Slash-command descriptions are redeployed when the bot starts. + +### Administration + +Economy administration commands can be authorized through: + +- A configured admin user ID. +- A configured admin role ID. +- A configured admin role name. +- Discord `Administrator` permission. +- Discord `Manage Server` permission. + +Role IDs are preferred because they are stable and unambiguous. + +### Installation + +#### 1. Clone + +```bash +git clone https://github.com/LoiFragola/EconomyBot-Multi-Language.git +cd EconomyBot-Multi-Language +``` + +#### 2. Install dependencies + +```bash +npm install +``` + +#### 3. Configure + +Edit `botConfig.js` and fill in your token, server ID, language, administrator configuration, market, item registry and optional shared-inventory settings. + +#### 4. Discord Developer Portal + +Enable **Message Content Intent** if you want prefix commands. + +Invite the application with `bot` and `applications.commands` scopes. + +#### 5. Start + +```bash +npm start +``` + +The bot deploys the current slash-command definitions to the configured guild when it starts. + +### Requirements + +| Component | Supported | +|---|---| +| Node.js | `20+` | +| discord.js | `14.27.0` | +| better-sqlite3 | `12.11.1` | + +Node.js 22 or 24 is also fully tested by the project's CI matrix. + +### Testing + +Run: + +```bash +npm test +``` + +The offline integration suite validates the critical domain boundaries, including: + +- Native slash-command definitions and command count +- Prefix aliases and shared command contexts +- English/Turkish localization +- User/role/permission administration +- Persistent SQLite lifecycle +- Atomic money transfers +- Config-driven market stock and stock depletion +- Inventory integrity +- Member listing creation from owned inventory only +- Member marketplace settlement +- Invalid/non-configured item rejection +- Daily and weekly item drops +- Native `interactionCreate` and `messageCreate` paths + +GitHub Actions additionally tests Node.js 20, 22 and 24 and runs CodeQL analysis. + +### Security + +The local SQLite database lives under `data/` and should not be committed. + +### Project Structure + +```text +EconomyBot-Multi-Language/ +├── commands/ # Native slash + prefix command implementations +├── events/ +│ ├── clientReady.js # Guild slash deployment +│ ├── interactionCreate.js # Native slash execution +│ └── messageCreate.js # Prefix execution +├── lib/ +│ ├── commandUtils.js # Validation, permissions and formatting +│ ├── context.js # Unified slash/prefix command context +│ ├── database.js # SQLite storage and transaction layer +│ ├── economy.js # Economy + marketplace business logic +│ ├── inventory.js # Canonical inventory service +│ └── i18n.js # English/Turkish localization +├── data/ # Local/shared SQLite database (generated) +├── counter.js # Optional counting channel +├── botConfig.js # Local configuration +├── index.js # Application bootstrap and validation +├── tests/test.js # Offline integration tests +└── package.json +``` + +### Version lineage + +This release is derived from the open-source `ZeroDiscord/EconomyBot` project, but it should **not** be considered a drop-in patch or a minor visual update. The runtime architecture and data model have been substantially re-engineered. + +The original repository remains part of the project's lineage; this release is a modernized, independently structured continuation focused on extensibility, transactional integrity, configurability and interoperability. + +--- + +## Türkçe + +> **Gezinme:** [Genel Bakış](#t%C3%BCrk%C3%A7e) · [Bu sürüm neden farklı?](#bu-s%C3%BCr%C3%BCm-neden-farkl%C4%B1) · [Mimari](#mimari) · [Yapılandırma](#yap%C4%B1land%C4%B1rma) · [Market & Oyuncu Mağazaları](#market--oyuncu-ma%C4%9fazalar%C4%B1) · [Envanter & Droplar](#envanter--droplar) · [Ortak Envanter](#ortak-envanter-temeli) · [Komutlar](#komutlar) · [Kurulum](#kurulum) · [Testler](#testler) + +### Genel Bakış + +EconomyBot artık `ZeroDiscord/EconomyBot` projesinin küçük bir Node.js/discord.js güncellemesi olarak değerlendirilmemelidir. Bu sürüm, **aynı proje soyundan gelen fakat çalışma zamanı, veri modeli ve iş mantığı büyük ölçüde yeniden tasarlanmış yeni bir nesildir**. + +Orijinal proje başlangıç noktası ve tarihsel referans olarak korunurken; SQLite veri katmanı, ekonomi servisleri, inventory modeli, market altyapısı, oyuncu ilan sistemi, komut bağlamı, permission sistemi, localization, hata yönetimi ve startup doğrulama mekanizmaları modern bir mimariye taşındı. + +Başka bir ifadeyle bu sürüm, eski botun üzerine birkaç özellik eklenmiş hali değil; **aynı temelden yeniden mühendislik geçirilmiş ayrı bir bot sürümü** olarak tasarlanmıştır. + +### Bu sürüm neden farklı? + +Sistem artık komut dosyalarının içine gömülmüş sabit ürünlerden oluşmuyor. `botConfig.js` merkezi bir domain tanımı olarak kullanılıyor; item registry ve market açılışta doğrulanıyor, market stoğu guild bazında kalıcı tutuluyor, oyuncu ilanları ayrı kayıtlar olarak saklanıyor ve item kimlikleri merkezi bir registry üzerinden yönetiliyor. + +Ekonomi çekirdeği de servis sınırlarına ayrıldı. Para hareketleri ve marketplace ödemeleri SQLite transaction'ları içinde atomik olarak işleniyor; inventory işlemleri tek bir servis altında toplandı; cooldown durumları kalıcı hale getirildi; slash/prefix komutları ortak bir context yapısı üzerinden çalışıyor. + +discord.js v14 uyumluluğu da sadece API isimlerinin değiştirilmesi seviyesinde bırakılmadı. Native interaction akışı, slash schema'ları, alias çözümleme, dil sistemi, permission doğrulaması, hata yönetimi ve CI/CodeQL kontrolleri birlikte yeniden düzenlendi. + +### Mimari + +```text + ┌─────────────────────────┐ + │ Discord Gateway │ + └────────────┬────────────┘ + │ + ┌───────────────────┴───────────────────┐ + │ │ + Slash Etkileşimleri Prefix Mesajları + │ │ + └───────────────────┬───────────────────┘ + │ + Ortak Command Context + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + Economy Service Inventory Service Localization + │ │ │ + └───────────────────┼───────────────────┘ + │ + Transactional SQLite + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + Bakiyeler Envanterler Marketplace + │ │ │ + Cooldown'lar Droplar Oyuncu İlanları +``` + +Envanter ve marketplace katmanları, ileride roleplay botuyla veri paylaşımına uygun olacak şekilde ayrıştırıldı. Böylece gelecekte RP tarafının özel iş mantığını ekonomi botunun içine yığmadan ortak item verisi kullanılabilir. + +### Yapılandırma + +Item ekonomisinin neredeyse tamamı artık `botConfig.js` üzerinden tanımlanabilir. + +```js +module.exports = { + token: "BOT_TOKEN", + prefix: "!", + serverId: "SUNUCU_ID", + language: "tr", + + admins: ["YETKILI_KULLANICI_ID"], + adminRoles: ["YETKILI_ROL_ID"], + adminRoleNames: [], + + market: [ + { item: "laptop", price: 2000, stock: 10 }, + { item: "mobile", price: 1000, stock: 15 }, + { item: "pc", price: 3000, stock: 5 } + ], + + items: [ + { + id: "laptop", + name: "Laptop", + description: "Taşınabilir bilgisayar.", + dailyDropChance: 0, + weeklyDropChance: 2 + }, + { + id: "mobile", + name: "Mobile", + description: "Kişisel mobil telefon.", + dailyDropChance: 1, + weeklyDropChance: 4 + }, + { + id: "pc", + name: "PC", + description: "Masaüstü bilgisayar.", + dailyDropChance: 0, + weeklyDropChance: 1 + } + ], + + sharedInventory: { + enabled: false, + databasePath: "./data/shared-economy.sqlite" + }, + + debug: false, + countChannel: "SAYAÇ_KANAL_ID" +}; +``` + +`items` **gerçek item registry'sidir**. Bir item burada yoksa komutlar üzerinden yeni item oluşturulamaz. + +`market` ise **satış kataloğudur**. Her market girdisi `items` içinde tanımlı bir item'a bağlanmalıdır ve başlangıç stoğu sıfırdan büyük olmak zorundadır. Açılış stoğu SQLite içinde guild bazında tutulur; böylece bot yeniden başlatıldığında satılmış ürünler otomatik olarak geri dolmaz. + +### Market & Oyuncu Mağazaları + +Eski “Laptop / Mobile / PC kodun içinde sabit” yaklaşımı kaldırıldı ve gerçek bir katalog sistemi kuruldu. + +Sistem marketi `market` bölümü üzerinden tanımlanır. Fiyat ve ilk stok için komut dosyasına dokunmak gerekmez. + +Her üye marketplace'e katılabilir. Üye kendi envanterindeki bir item'ı `/sell` komutuyla satış ilanına dönüştürebilir. + +Bir oyuncu ilanı şunları taşır: + +- Config içindeki canonical item kimliği +- Satıcı ID'si +- Satış miktarı +- Birim fiyat +- Oluşturulma zamanı +- Benzersiz ilan ID'si + +Alıcı `/buy ` kullanarak ilan üzerinden satın alabilir. İşlem tek SQLite transaction'ında tamamlanır: alıcının bakiyesi kontrol edilir, satıcının bakiyesi artırılır, ilan miktarı azaltılır veya ilan silinir ve item alıcının envanterine aktarılır. + +Dolayısıyla oyuncunun kendi mağazasını açması için yöneticiye ihtiyaç yoktur. İlanın kendisi **oyuncu mağazasının bir satış slotu** olarak davranır; `/shop` ise sistem marketi ile oyuncu mağazalarını tek görünümde birleştirir. + +Özellikle yeni item üretme konusu güvenli biçimde sınırlandırılmıştır. Bir oyuncu yalnızca şu koşulları sağlayan item'ı satabilir: + +1. `items` içinde tanımlı olmalı. +2. Satıcının kendi envanterinde bulunmalı. +3. Pozitif miktar ve pozitif birim fiyat ile listelenmeli. + +### Envanter & Droplar + +Envanter artık alışveriş komutunun yan ürünü değildir; ayrı bir servis olarak yönetilir. + +Item'lar sistem marketinden, oyuncu marketplace alımlarından, günlük ödüllerden ve haftalık ödüllerden envantere girebilir. + +Daily ve weekly komutları ayrıca item drop üretebilir. Her item kendi olasılığını taşır: + +```js +{ + id: "mobile", + name: "Mobile", + description: "Kişisel mobil telefon.", + dailyDropChance: 1, + weeklyDropChance: 4 +} +``` + +Drop değerleri yüzde cinsindendir. Aynı drop türündeki toplam olasılık %100'ü aşamaz. Geri kalan oran item düşmemesi anlamına gelir. + +Böylece ödül ekonomisinin drop tablosu kod değiştirmeden yeniden dengelenebilir. + +### Ortak Envanter Temeli + +Envanter anahtar yapısı `inventory::` biçiminde standartlaştırıldı. Bu sayede gelecekte başka bir bot aynı mantıksal envanter verisini okuyabilecek bir altyapıya sahip olur. + +Bu özellik **tamamen isteğe bağlıdır**. Varsayılan olarak EconomyBot kendi SQLite veritabanını kullanır. + +İleride RP botuyla ortak envanter kullanmak üzere: + +```js +sharedInventory: { + enabled: true, + databasePath: "./data/shared-economy.sqlite" +} +``` + +Bunu yalnızca iki uygulamanın aynı SQLite dosyasını ve uyumlu item ID'lerini bilinçli şekilde paylaşacağı senaryolarda açın. Böylece RP botu ortak item/ inventory verisini kullanabilir, fakat RP iş mantığı EconomyBot'un içine gömülmek zorunda kalmaz. + +### Komutlar + +| Komut | Görevi | +|---|---| +| `/bal` | Bakiye ve ekonomi sırasını gösterir | +| `/daily` | Para ve olası yapılandırılmış item drop'u verir | +| `/weekly` | Para ve olası yapılandırılmış item drop'u verir | +| `/work` | Rastgele iş ile para kazandırır | +| `/beg` | Rastgele kazanç etkinliği başlatır | +| `/search` | Para arar | +| `/rob` | Soygun mekaniğini dener | +| `/transfer` | Başka kullanıcıya para gönderir | +| `/shop` | Sistem marketini ve oyuncu mağazalarını gösterir | +| `/buy` | Sistem marketinden veya oyuncu ilanından satın alır | +| `/sell` | Envanterindeki yapılandırılmış item ile oyuncu marketinde ilan açar | +| `/inventory` | Sahip olunan item'ları gösterir | +| `/lb` | Ekonomi sıralamasını gösterir | +| `/ping` | Bot/istemci gecikmesini gösterir | +| `/help` | Komutları ve güncel prefix'i gösterir | +| `/prefix` | Sunucu prefix'ini değiştirir | +| `/addmoney` | Yönetici ekonomi işlemi | +| `/setmoney` | Yönetici ekonomi işlemi | + +Prefix alias'ları da korunmuştur. Örneğin `!bakiye`, `!günlük`, `!mağaza`, `!envanter`, `!aktar`, `!sat` ve desteklenen diğer Türkçe alias'lar kullanılabilir. + +### Dil Sistemi + +`botConfig.js` içindeki `language` alanını `en` veya `tr` yapabilirsiniz. + +Seçilen dil; komut açıklamalarını, yanıtları, embed içeriklerini, cooldown mesajlarını, marketplace metinlerini ve help ekranını etkiler. Slash komut açıklamaları bot açılışında yeniden deploy edilir. + +### Yönetici Sistemi + +Ekonomi yönetim komutları şu yöntemlerden biriyle yetkilendirilebilir: + +- Yapılandırılmış yönetici kullanıcı ID'si. +- Yapılandırılmış yönetici rol ID'si. +- Yapılandırılmış yönetici rol adı. +- Discord `Administrator` yetkisi. +- Discord `Manage Server / Sunucuyu Yönet` yetkisi. + +Rol ID'si kullanmak en güvenli ve kararlı yöntemdir. + +### Kurulum + +#### 1. Projeyi klonlayın + +```bash +git clone https://github.com/LoiFragola/EconomyBot-Multi-Language.git +cd EconomyBot-Multi-Language +``` + +#### 2. Bağımlılıkları yükleyin + +```bash +npm install +``` + +#### 3. Yapılandırın + +`botConfig.js` içinde token, server ID, dil, yönetici ayarları, market, item registry ve isterseniz shared inventory yapılandırmasını doldurun. + +#### 4. Discord Developer Portal + +Prefix komutları kullanılacaksa **Message Content Intent** özelliğini açın. + +Botu `bot` ve `applications.commands` kapsamları ile davet edin. + +#### 5. Başlatın + +```bash +npm start +``` + +Bot açılışta güncel slash komutlarını yapılandırılmış sunucuya deploy eder. + +### Gereksinimler + +| Bileşen | Destek | +|---|---| +| Node.js | `20+` | +| discord.js | `14.27.0` | +| better-sqlite3 | `12.11.1` | + +Projenin CI matrisi Node.js 20, 22 ve 24 sürümlerini test eder. + +### Testler + +```bash +npm test +``` + +Offline integration testleri özellikle şu alanları doğrular: + +- Native slash komutları ve komut sayısı +- Prefix alias'ları ve ortak command context +- İngilizce/Türkçe dil sistemi +- Kullanıcı/rol/yetki bazlı yönetici kontrolleri +- Kalıcı SQLite lifecycle +- Atomik para transferleri +- Config tabanlı market stoğu ve stok tükenmesi +- Envanter bütünlüğü +- Yalnızca kendi inventory'sindeki item ile ilan açma +- Oyuncu marketplace settlement işlemleri +- Config dışı item reddi +- Daily/weekly item dropları +- Gerçek `interactionCreate` ve `messageCreate` akışları + +GitHub Actions ayrıca Node.js 20, 22 ve 24 testlerini ve CodeQL analizini çalıştırır. + +### Güvenlik + +Yerel SQLite veritabanı `data/` altında tutulur ve Git'e gönderilmemelidir. + +### Proje Yapısı + +```text +EconomyBot-Multi-Language/ +├── commands/ # Native slash + prefix komutları +├── events/ +│ ├── clientReady.js # Guild slash deploy +│ ├── interactionCreate.js # Native slash yürütmesi +│ └── messageCreate.js # Prefix yürütmesi +├── lib/ +│ ├── commandUtils.js # Doğrulama, yetki ve formatlama +│ ├── context.js # Ortak slash/prefix command context +│ ├── database.js # SQLite storage + transaction katmanı +│ ├── economy.js # Economy + marketplace iş mantığı +│ ├── inventory.js # Canonical inventory servisi +│ └── i18n.js # İngilizce/Türkçe localization +├── data/ # Local/shared SQLite DB (oluşturulur) +├── counter.js # İsteğe bağlı sayaç sistemi +├── botConfig.js # Yerel yapılandırma +├── index.js # Bootstrap + config doğrulama +├── tests/test.js # Offline integration testleri +└── package.json +``` + +### Sürüm ilişkisi + +Bu sürüm açık kaynak `ZeroDiscord/EconomyBot` projesinden türetilmiştir; fakat **drop-in bir patch veya küçük bir görsel güncelleme olarak değerlendirilmemelidir**. Çalışma zamanı mimarisi ve veri modeli büyük ölçüde yeniden mühendislikten geçirilmiştir. + +Orijinal repo projenin soyunun bir parçasıdır; bu sürüm ise genişletilebilirlik, transaction bütünlüğü, yapılandırılabilirlik ve gelecekteki botlar arası veri paylaşımı odağında bağımsız bir devam sürümüdür. + + diff --git a/botConfig.js b/botConfig.js index c8289c7..c9b63eb 100644 --- a/botConfig.js +++ b/botConfig.js @@ -1,9 +1,65 @@ module.exports = { token: "YOUR_TOKEN", - prefix: "PREFIX", + prefix: "!", + serverId: "YOUR_SERVER_ID", + + // "en" = English, "tr" = Turkish. + language: "en", + + // User IDs that always have economy-admin access. admins: [ - "PEOPLE WHO CAN USE ADD MONEY (IDS)" -], - debug: true, - countChannel: "countChannelID" + "YOUR_ADMIN_USER_ID" + ], + + // Role IDs that have economy-admin access. + adminRoles: [ + "YOUR_ADMIN_ROLE_ID" + ], + + // Optional role names. Use role IDs above for the safest configuration. + adminRoleNames: [], + + // Initial stock and public market configuration. + // Stock is persisted by SQLite after the first initialization. + market: [ + { item: "laptop", price: 2000, stock: 10 }, + { item: "mobile", price: 1000, stock: 15 }, + { item: "pc", price: 3000, stock: 5 } + ], + + // Canonical item registry. New item types must be defined here. + // Drop chances are percentages from 0 to 100. + items: [ + { + id: "laptop", + name: "Laptop", + description: "A portable computer.", + dailyDropChance: 0, + weeklyDropChance: 2 + }, + { + id: "mobile", + name: "Mobile", + description: "A personal mobile phone.", + dailyDropChance: 1, + weeklyDropChance: 4 + }, + { + id: "pc", + name: "PC", + description: "A desktop computer.", + dailyDropChance: 0, + weeklyDropChance: 1 + } + ], + + // Optional shared-inventory groundwork for a future roleplay bot. + // Keep disabled unless both applications intentionally point at the same SQLite file. + sharedInventory: { + enabled: false, + databasePath: "./data/shared-economy.sqlite" + }, + + debug: false, + countChannel: "YOUR_COUNT_CHANNEL_ID" }; diff --git a/commands/addmoney.js b/commands/addmoney.js index ca940e8..642e325 100644 --- a/commands/addmoney.js +++ b/commands/addmoney.js @@ -1,25 +1,34 @@ -const { MessageEmbed } = require("discord.js"); +const { EmbedBuilder, SlashCommandBuilder } = require("discord.js"); +const { isAdmin, resolveUser, getAmount, formatMoney } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); -exports.execute = async (client, message, args) => { - if (!client.config.admins.includes(message.author.id)) return; // return if author isn't bot owner - let user = message.mentions.users.first(); - if (!user) return message.channel.send("Please specify a user!"); - let amount = args[1]; - if (!amount || isNaN(amount)) return message.reply("Please specify a valid amount."); - let data = client.eco.addMoney(user.id, parseInt(amount)); - const embed = new MessageEmbed() - .setTitle(`Money Added!`) - .addField(`User`, `<@${data.user}>`) - .addField(`Balance Given`, `${data.amount} 💸`) - .addField(`Total Amount`, data.after) - .setColor("RANDOM") - .setThumbnail(user.displayAvatarURL) - .setTimestamp(); - return message.channel.send(embed); -} +exports.data = new SlashCommandBuilder() + .setName("addmoney") + .setDescription(commandDescription("addmoney", require("../botConfig").language)) + .addUserOption((option) => option.setName("kullanici").setDescription("User to receive money.").setRequired(true)) + .addIntegerOption((option) => option.setName("miktar").setDescription("Amount to add.").setMinValue(1).setMaxValue(2147483647).setRequired(true)); +exports.name = "addmoney"; +exports.aliases = ["addbal", "paraekle", "para-ekle"]; -exports.help = { - name: "addmoney", - aliases: ["addbal"], - usage: `addmoney @user ` -} +exports.execute = async (ctx) => { + if (!isAdmin(ctx)) return ctx.reply({ content: ctx.t("errors.noPermission"), ephemeral: true }); + const user = await resolveUser(ctx, 0); + const amount = getAmount(ctx, "miktar", 1); + if (!user || user.bot) return ctx.reply(ctx.t("errors.noUser")); + if (!amount) return ctx.reply(ctx.t("errors.noAmount")); + + const data = ctx.client.eco.addMoney(ctx.guildId, user.id, amount); + const embed = new EmbedBuilder() + .setTitle(ctx.t("economy.moneyAddedTitle")) + .addFields( + { name: ctx.t("economy.user"), value: `<@${user.id}>`, inline: true }, + { name: ctx.t("economy.added"), value: formatMoney(data.amount, ctx.language), inline: true }, + { name: ctx.t("economy.newBalance"), value: formatMoney(data.after, ctx.language), inline: true } + ) + .setColor("Blurple") + .setThumbnail(user.displayAvatarURL()) + .setTimestamp(); + return ctx.reply({ embeds: [embed] }); +}; + +exports.help = { name: exports.name, aliases: exports.aliases, usage: "addmoney @user " }; diff --git a/commands/bal.js b/commands/bal.js index 5e46e81..f1ccf79 100644 --- a/commands/bal.js +++ b/commands/bal.js @@ -1,21 +1,31 @@ -const { MessageEmbed } = require("discord.js"); - -exports.execute = async (client, message, args) => { - let user = message.mentions.users.first() || message.author; - let userBalance = client.eco.fetchMoney(user.id); - const embed = new MessageEmbed() - .setTitle(`Balance`) - .addField(`User`, `<@${userBalance.user}>`) - .addField(`Balance`, `${userBalance.amount} 💸`) - .addField(`Position`, userBalance.position) - .setColor("RANDOM") - .setThumbnail(user.displayAvatarURL) - .setTimestamp(); - return message.channel.send(embed); -} - -exports.help = { - name: "bal", - aliases: ["money", "credits", "balance"], - usage: `bal` -} +const { EmbedBuilder, SlashCommandBuilder } = require("discord.js"); +const { resolveUser, formatMoney } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); + +exports.data = new SlashCommandBuilder() + .setName("bal") + .setDescription(commandDescription("bal", require("../botConfig").language)) + .addUserOption((option) => option.setName("kullanici").setDescription("User whose balance should be shown.")); +exports.name = "bal"; +exports.aliases = ["money", "credits", "balance", "bakiye", "para"]; + +exports.execute = async (ctx) => { + const user = (ctx.userOption("kullanici") || (ctx.isSlash ? null : await resolveUser(ctx, 0)) || ctx.user); + if (user.bot) return ctx.reply(ctx.t("errors.botEconomy")); + + const balance = ctx.client.eco.getBalance(ctx.guildId, user.id); + const position = ctx.client.eco.getPosition(ctx.guildId, user.id); + const embed = new EmbedBuilder() + .setTitle(ctx.t("economy.balanceTitle")) + .addFields( + { name: ctx.t("economy.user"), value: `<@${user.id}>`, inline: true }, + { name: ctx.t("economy.balance"), value: formatMoney(balance, ctx.language), inline: true }, + { name: ctx.t("economy.rank"), value: `#${position}`, inline: true } + ) + .setColor("Blurple") + .setThumbnail(user.displayAvatarURL()) + .setTimestamp(); + return ctx.reply({ embeds: [embed] }); +}; + +exports.help = { name: exports.name, aliases: exports.aliases, usage: "bal [@user]" }; diff --git a/commands/beg.js b/commands/beg.js index a1a7633..e0f383a 100644 --- a/commands/beg.js +++ b/commands/beg.js @@ -1,19 +1,18 @@ -exports.execute = async (client, message, args) => { - let users = [ - "PewDiePie", - "T-Series", - "Sans", - "Zero" - ]; - let amount = Math.floor(Math.random() * 50) + 10; - let beg = client.eco.beg(client.ecoAddUser, amount, { canLose: true }); - if (beg.onCooldown) return message.reply(`Begon Thot! Come back after ${beg.time.seconds} seconds.`); - if (beg.lost) return message.channel.send(`**${users[Math.floor(Math.random() * users.length)]}:** Begon Thot! Try again later.`); - else return message.reply(`**${users[Math.floor(Math.random() * users.length)]}** donated you **${beg.amount}** 💸. Now you have **${beg.after}** 💸.`); -}; - -exports.help = { - name: "beg", - aliases: [], - usage: "beg" -} +const { SlashCommandBuilder } = require("discord.js"); +const { formatMoney, formatRemaining, randomInt } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); + +const donors = ["PewDiePie", "T-Series", "Sans", "Zero"]; +exports.data = new SlashCommandBuilder().setName("beg").setDescription(commandDescription("beg", require("../botConfig").language)); +exports.name = "beg"; +exports.aliases = ["dilen", "dilenme", "dilencilik"]; + +exports.execute = async (ctx) => { + const amount = randomInt(10, 59); + const result = ctx.client.eco.randomEarning(ctx.guildId, ctx.userId, "beg", amount, { canLose: true, cooldown: 60_000 }); + if (result.onCooldown) return ctx.reply(ctx.t("economy.begCooldown", { time: formatRemaining(result.remainingMs, ctx.language) })); + const donor = donors[randomInt(0, donors.length - 1)]; + if (result.lost) return ctx.reply(ctx.t("economy.begLost", { donor })); + return ctx.reply(ctx.t("economy.beg", { donor, amount: formatMoney(result.amount, ctx.language), balance: formatMoney(result.after, ctx.language) })); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "beg" }; diff --git a/commands/buy.js b/commands/buy.js index e624a33..4f3e292 100644 --- a/commands/buy.js +++ b/commands/buy.js @@ -1,27 +1,40 @@ -const { MessageEmbed } = require("discord.js"); +const { SlashCommandBuilder } = require("discord.js"); +const { getProduct, formatMoney, parsePositiveInteger } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); +const config = require("../botConfig"); -exports.execute = async (client, message, args) => { - let userBalance = client.eco.fetchMoney(message.author.id); - if (userBalance.amount < 1) return message.channel.send("Looks like you are poor."); - let item = args[0]; - if (!item) return message.channel.send("What are you trying to buy?"); - let hasItem = client.shop[item]; - if (!hasItem || hasItem == undefined) return message.reply("That item doesnt exists lol"); - let isBalanceEnough = (userBalance.amount >= hasItem.cost); - if (!isBalanceEnough) return message.reply("Your balance is insufficient. You need :dollar: "+hasItem.cost+" to buy this item."); - let buy = client.eco.removeMoney(message.author.id, hasItem.cost); - - let itemStruct = { - name: item.toLowerCase(), - prize: hasItem.cost - }; - - client.db.push(`items_${message.author.id}`, itemStruct); - return message.channel.send(`You purchased **${item}** for **:dollar: ${hasItem.cost}**.`); -}; +exports.data = new SlashCommandBuilder() + .setName("buy") + .setDescription(commandDescription("buy", config.language)) + .addStringOption((option) => option.setName("urun").setDescription("Item or member listing ID.").setRequired(true)) + .addIntegerOption((option) => option.setName("miktar").setDescription("Quantity.").setMinValue(1).setRequired(false)); +exports.name = "buy"; +exports.aliases = ["satınal", "satinal", "al"]; + +exports.execute = async (ctx) => { + const requested = String(getProduct(ctx) || "").trim().toLowerCase(); + const quantity = ctx.isSlash ? (ctx.integerOption("miktar") || 1) : (parsePositiveInteger(ctx.args[1]) || 1); + if (!Number.isSafeInteger(quantity) || quantity <= 0) return ctx.reply(ctx.t("errors.noAmount")); + + const marketItem = ctx.client.eco.getMarketItem(ctx.guildId, requested); + if (marketItem) { + const balance = ctx.client.eco.getBalance(ctx.guildId, ctx.userId); + const total = marketItem.cost * quantity; + if (marketItem.stock < quantity) return ctx.reply(ctx.t("economy.purchaseStock", { stock: marketItem.stock })); + if (!Number.isSafeInteger(total) || balance < total) return ctx.reply(ctx.t("economy.purchaseNeed", { price: formatMoney(total, ctx.language), balance: formatMoney(balance, ctx.language) })); + const result = ctx.client.eco.purchase(ctx.guildId, ctx.userId, marketItem, quantity); + if (result.error === "Yetersiz stok.") return ctx.reply(ctx.t("economy.purchaseStock", { stock: result.stock })); + if (result.error) return ctx.reply(ctx.t("errors.insufficient")); + return ctx.reply(ctx.t("economy.purchase", { item: marketItem.name, price: formatMoney(result.total, ctx.language), balance: formatMoney(result.after, ctx.language), quantity })); + } -exports.help = { - name: "buy", - aliases: [], - usage: `buy ` + const listing = ctx.client.eco.getListing(ctx.guildId, requested); + if (!listing) return ctx.reply(ctx.t("errors.invalidProduct")); + const result = ctx.client.eco.buyListing(ctx.guildId, ctx.userId, requested, quantity); + if (result.error === "Kendi ilanını satın alamazsın.") return ctx.reply(ctx.t("errors.ownListing")); + if (result.error === "İlanda yeterli stok yok.") return ctx.reply(ctx.t("economy.listingStock", { stock: result.available })); + if (result.error === "Yetersiz bakiye.") return ctx.reply(ctx.t("economy.purchaseNeed", { price: formatMoney(result.total, ctx.language), balance: formatMoney(result.balance, ctx.language) })); + if (result.error) return ctx.reply(ctx.t("errors.invalidProduct")); + return ctx.reply(ctx.t("economy.listingPurchase", { item: listing.itemName, quantity, price: formatMoney(result.total, ctx.language), balance: formatMoney(result.after, ctx.language) })); }; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "buy [quantity]" }; diff --git a/commands/daily.js b/commands/daily.js index 4017785..7b95b89 100644 --- a/commands/daily.js +++ b/commands/daily.js @@ -1,12 +1,16 @@ -module.exports.execute = async (client, message, args) => { - let amount = Math.floor(Math.random() * 500) + 100; - let addMoney = client.eco.daily(client.ecoAddUser, amount); - if (addMoney.onCooldown) return message.reply(`You have already claimed your daily credit. Come back after ${addMoney.time.hours} hours, ${addMoney.time.minutes} minutes & ${addMoney.time.seconds} seconds to claim it again.`); - else return message.reply(`You have claimed **${addMoney.amount}** 💸 as your daily credit & now you have **${addMoney.after}** 💸.`); -}; - -module.exports.help = { - name: "daily", - aliases: [], - usage: "daily" -} +const { SlashCommandBuilder } = require("discord.js"); +const { formatMoney, formatRemaining, randomInt } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); +const config = require("../botConfig"); + +exports.data = new SlashCommandBuilder().setName("daily").setDescription(commandDescription("daily", config.language)); +exports.name = "daily"; +exports.aliases = ["günlük", "gunluk"]; + +exports.execute = async (ctx) => { + const result = ctx.client.eco.daily(ctx.guildId, ctx.userId, randomInt(100, 599)); + if (result.onCooldown) return ctx.reply(ctx.t("economy.dailyCooldown", { time: formatRemaining(result.remainingMs, ctx.language) })); + const drop = result.droppedItem ? ` ${ctx.t("economy.itemDrop", { item: result.droppedItem.name })}` : ""; + return ctx.reply(ctx.t("economy.daily", { amount: formatMoney(result.amount, ctx.language), balance: formatMoney(result.after, ctx.language) }) + drop); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "daily" }; diff --git a/commands/help.js b/commands/help.js index c868883..3ba97d0 100644 --- a/commands/help.js +++ b/commands/help.js @@ -1,23 +1,23 @@ -const { MessageEmbed } = require("discord.js"); +const { EmbedBuilder, SlashCommandBuilder } = require("discord.js"); +const { commandDescription } = require("../lib/i18n"); -exports.execute = async (client, message, args) => { - const embed = new MessageEmbed() - .setAuthor("Commands") - .setTitle("Make Sure To Check Out This Channel For More Such Bots") - .setURL("https://www.youtube.com/channel/UCF9E-xef9jL9QgziZRDHKKQ") - .setDescription(`Total Commands: ${client.commands.size}`) - .setColor("BLURPLE") - .setTimestamp() - .setThumbnail(client.user.displayAvatarURL) - .setFooter(message.author.tag, message.author.displayAvatarURL); - client.commands.forEach(cmd => { - embed.addField(`${cmd.help.name}`, `Aliases: ${cmd.help.aliases.join(", ") || "None"}\nUsage: \`${client.prefix}${cmd.help.usage}\``, true); - }); - return message.channel.send(embed); -} +exports.data = new SlashCommandBuilder().setName("help").setDescription(commandDescription("help", require("../botConfig").language)); +exports.name = "help"; +exports.aliases = ["h", "yardım", "yardim", "komutlar"]; -exports.help = { - name: "help", - aliases: ["h"], - usage: `help` -} +exports.execute = async (ctx) => { + const commands = [...ctx.client.commands.values()].sort((a, b) => a.name.localeCompare(b.name)); + const fields = commands.map((command) => ({ name: `/${command.name}`, value: commandDescription(command.name, ctx.language), inline: true })); + const prefix = ctx.client.db.getPrefix(ctx.guildId, ctx.client.config.prefix); + const embed = new EmbedBuilder() + .setAuthor({ name: "INS Development" }) + .setTitle(ctx.t("economy.helpTitle")) + .setDescription(ctx.t("economy.helpDescription", { count: commands.length, prefix })) + .addFields(fields) + .setColor("Blurple") + .setThumbnail(ctx.client.user.displayAvatarURL()) + .setFooter({ text: ctx.t("economy.helpFooter", { user: ctx.user.tag }) }) + .setTimestamp(); + return ctx.reply({ embeds: [embed] }); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "help" }; diff --git a/commands/inventory.js b/commands/inventory.js index b524d0c..d3087ae 100644 --- a/commands/inventory.js +++ b/commands/inventory.js @@ -1,25 +1,30 @@ -const { MessageEmbed } = require("discord.js") -exports.execute = async (client, message, args) => { +const { EmbedBuilder, SlashCommandBuilder } = require("discord.js"); +const { formatMoney } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); +const config = require("../botConfig"); - const embed = new MessageEmbed() - .setAuthor(`Inventory of ${message.author.tag}`, message.guild.iconURL) - .setColor("RANDOM") - .setThumbnail() - .setTimestamp(); - const x = client.db.get(`items_${message.author.id}`); -if(!x) { return message.channel.send(`No Items Found To Display`); } -const arrayToObject = x.reduce((itemsobj, x) => { - itemsobj[x.name] = (itemsobj[x.name] || 0) + 1; - return itemsobj; -}, {}); -const result = Object.keys(arrayToObject).map(k => embed.addField(`Name: ${k}`,`Quantity: **${arrayToObject[k]}**`, false)); - - - return message.channel.send(embed); -} -exports.help = { - name: "inventory", - aliases: ["inv"], - usage: `inv` -} +exports.data = new SlashCommandBuilder().setName("inventory").setDescription(commandDescription("inventory", config.language)); +exports.name = "inventory"; +exports.aliases = ["inv", "envanter", "eşyalar", "esya"]; +exports.execute = async (ctx) => { + const items = ctx.client.eco.getInventory(ctx.guildId, ctx.userId); + if (items.length === 0) return ctx.reply(ctx.t("economy.inventoryEmpty")); + const grouped = new Map(); + for (const owned of items) { + const item = ctx.client.inventory.getItem(owned.id); + const id = String(owned.id || "unknown"); + const current = grouped.get(id) || { name: item?.name || owned.name || id, count: 0, description: item?.description || owned.description || "", price: 0 }; + current.count += 1; + const marketItem = ctx.client.eco.getMarketItem(ctx.guildId, id); + current.price = marketItem?.cost || 0; + grouped.set(id, current); + } + const embed = new EmbedBuilder().setTitle(ctx.t("economy.inventoryTitle", { user: ctx.user.username })).setColor("Blurple").setThumbnail(ctx.user.displayAvatarURL()).setTimestamp(); + for (const [id, info] of grouped) { + const value = `${ctx.language === "tr" ? "ID" : "ID"}: \`${id}\`\n${ctx.language === "tr" ? "Miktar" : "Quantity"}: **${info.count}**${info.price ? `\n${ctx.language === "tr" ? "Market değeri" : "Market value"}: **${formatMoney(info.price, ctx.language)}**` : ""}`; + embed.addFields({ name: info.name, value: value.slice(0, 1024), inline: true }); + } + return ctx.reply({ embeds: [embed] }); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "inventory" }; diff --git a/commands/leaderboard.js b/commands/leaderboard.js index 45305ee..f15769d 100644 --- a/commands/leaderboard.js +++ b/commands/leaderboard.js @@ -1,22 +1,16 @@ -const { MessageEmbed } = require("discord.js"); +const { EmbedBuilder, SlashCommandBuilder } = require("discord.js"); +const { formatMoney } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); -exports.execute = async (client, message, args) => { - - let leaderboard = client.eco.leaderboard({ limit: 15, raw: false }); - if (!leaderboard || leaderboard.length < 1) return message.channel.send("❌ | Empty Leaderboard!"); - const embed = new MessageEmbed() - .setAuthor(`Leaderboard of ${message.guild.name}!`, message.guild.iconURL) - .setColor("RANDOM") - .setThumbnail(client.users.cache.get(leaderboard[0].id) ? client.users.cache.get(leaderboard[0].id).displayAvatarURL : "https://cdn.discordapp.com/avatars/603948445362946084/a_f61398e073d78ae104e32b0517c891c3.gif") - .setTimestamp(); - leaderboard.forEach(u => { - embed.addField(`${u.position}. ${client.users.cache.get(u.id) ? client.users.cache.get(u.id).tag : "Unknown#0000"}`, `${u.money} 💸`); - }); - return message.channel.send(embed); -} +exports.data = new SlashCommandBuilder().setName("lb").setDescription(commandDescription("lb", require("../botConfig").language)); +exports.name = "lb"; +exports.aliases = ["leaderboard", "sıralama", "siralama", "liderlik"]; -exports.help = { - name: "lb", - aliases: ["leaderboard"], - usage: `lb` -} +exports.execute = async (ctx) => { + const leaderboard = ctx.client.eco.leaderboard(ctx.guildId, 15); + if (!leaderboard.length) return ctx.reply(ctx.t("economy.leaderboardEmpty")); + const lines = leaderboard.map((entry) => ctx.t("economy.leaderboardLine", { position: entry.position, id: entry.id, money: formatMoney(entry.money, ctx.language) })); + const embed = new EmbedBuilder().setTitle(`${ctx.guild.name} — ${ctx.t("economy.leaderboardTitle")}`).setDescription(lines.join("\n")).setColor("Blurple").setTimestamp(); + return ctx.reply({ embeds: [embed] }); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "lb" }; diff --git a/commands/ping.js b/commands/ping.js index 18d70c9..3f143fb 100644 --- a/commands/ping.js +++ b/commands/ping.js @@ -1,21 +1,18 @@ -const { MessageEmbed } = require("discord.js"); +const { EmbedBuilder, SlashCommandBuilder } = require("discord.js"); +const { commandDescription } = require("../lib/i18n"); -exports.execute = (client, message, args) => { - let gatewayLatency = Math.floor(client.ws.ping); - message.channel.send("Pinging...").then(m => { - const trip = Math.floor(m.createdTimestamp - message.createdTimestamp); - const embed = new MessageEmbed() - .setTitle("Pong!") - .addField("API Latency", `${gatewayLatency}ms`, true) - .addField("Client Latency", `${trip}ms`, true) - .setColor("#7289DA") - .setTimestamp(); - m.edit(embed); - }); -} +exports.data = new SlashCommandBuilder().setName("ping").setDescription(commandDescription("ping", require("../botConfig").language)); +exports.name = "ping"; +exports.aliases = ["pong", "latency", "gecikme"]; -exports.help = { - name: "ping", - aliases: ["pong", "latency"], - usage: `ping` -} +exports.execute = async (ctx) => { + const apiLatency = Math.max(0, Math.round(ctx.client.ws.ping)); + const createdAt = ctx.interaction?.createdTimestamp ?? ctx.message?.createdTimestamp ?? Date.now(); + const clientLatency = Math.max(0, Date.now() - createdAt); + const embed = new EmbedBuilder().setTitle(ctx.t("economy.pingTitle")).addFields( + { name: ctx.t("economy.pingApi"), value: `${apiLatency} ms`, inline: true }, + { name: ctx.t("economy.pingClient"), value: `${clientLatency} ms`, inline: true } + ).setColor("Blurple").setTimestamp(); + return ctx.reply({ embeds: [embed] }); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "ping" }; diff --git a/commands/prefix.js b/commands/prefix.js index c59ced2..15bb5e4 100644 --- a/commands/prefix.js +++ b/commands/prefix.js @@ -1,17 +1,21 @@ -exports.execute = (client, message, args) => { - if (!message.member.hasPermission("MANAGE_GUILD") && !client.config.admins.includes(message.member.id)) return message.channel.send(`My prefix for this server is **${client.prefix}**.`); - let prefix = args[0]; - if (!prefix) { - client.db.delete(`prefix_${message.guild.id}`); - return message.channel.send(`✅ | Prefix for this server has been reset.`); - } else { - let setTo = client.db.set(`prefix_${message.guild.id}`, prefix); - return message.channel.send(`✅ | Prefix set to \`${setTo}\`.`); - } -} - -exports.help = { - name: "prefix", - aliases: ["setprefix"], - usage: `prefix` -} +const { EmbedBuilder, SlashCommandBuilder } = require("discord.js"); +const { isAdmin } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); + +exports.data = new SlashCommandBuilder() + .setName("prefix") + .setDescription(commandDescription("prefix", require("../botConfig").language)) + .addStringOption((option) => option.setName("yeni_prefix").setDescription("New prefix.").setMinLength(1).setMaxLength(5).setRequired(true)); +exports.name = "prefix"; +exports.aliases = ["setprefix", "önek", "onek"]; + +exports.execute = async (ctx) => { + if (!isAdmin(ctx)) return ctx.reply({ content: ctx.t("errors.noPermission"), ephemeral: true }); + const prefix = String(ctx.isSlash ? ctx.stringOption("yeni_prefix") : ctx.args[0] || "").trim(); + if (!/^\S{1,5}$/.test(prefix)) return ctx.reply(ctx.t("errors.invalidPrefix")); + const current = ctx.client.db.getPrefix(ctx.guildId, ctx.client.config.prefix); + if (current === prefix) return ctx.reply(ctx.t("errors.samePrefix")); + ctx.client.db.setPrefix(ctx.guildId, prefix); + return ctx.reply(ctx.t("economy.prefixUpdated", { prefix })); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "prefix " }; diff --git a/commands/rob.js b/commands/rob.js index d5daa57..b6b6cd3 100644 --- a/commands/rob.js +++ b/commands/rob.js @@ -1,25 +1,26 @@ -exports.execute = async (client, message, args) => { - let target = message.mentions.members.first() || message.guild.members.cache.get(args[0]) - if(!target) return message.reply("Who are you trying to rob?") - let messages = [ - `You tripped while trying to rob ${target} and got caught!`, - `Getting sneaky eh? ${target} called the cops on you!`, - `You failed robbing ${target} becase you didn't subscribe to zerosync.` - ] - let amount = Math.floor(Math.random() * 50) + 10; - let rob = client.eco.beg(client.ecoAddUser, amount, { canLose: true }); - if (rob.onCooldown) return message.reply(`You have recently attempted to rob someone try again after ${rob.time.seconds} seconds.`); - if (rob.lost) return message.channel.send(messages[Math.floor(Math.random() * messages.length)]); - else { - let x = client.eco.fetchMoney(target.id).amount - amount - - client.eco.setMoney(target.id,parseInt(x)) - message.reply(`You robbed ${target} for **${rob.amount}** 💸. Now you have **${rob.after}** 💸.`); - } -}; +const { SlashCommandBuilder } = require("discord.js"); +const { resolveUser, formatMoney, formatRemaining, randomInt } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); + +exports.data = new SlashCommandBuilder().setName("rob").setDescription(commandDescription("rob", require("../botConfig").language)).addUserOption((option) => option.setName("kullanici").setDescription("Target user.").setRequired(true)); +exports.name = "rob"; +exports.aliases = ["soy", "soygun"]; + +exports.execute = async (ctx) => { + const target = await resolveUser(ctx, 0); + if (!target || target.bot) return ctx.reply(ctx.t("errors.noUser")); + if (target.id === ctx.userId) return ctx.reply(ctx.t("errors.selfRob")); -exports.help = { - name: "rob", - aliases: [], - usage: "rob " -} + const cooldown = ctx.client.eco.useCooldown(ctx.guildId, ctx.userId, "rob", 60_000); + if (cooldown.onCooldown) return ctx.reply(ctx.t("economy.robCooldown", { time: formatRemaining(cooldown.remainingMs, ctx.language) })); + + const targetBalance = ctx.client.eco.getBalance(ctx.guildId, target.id); + if (targetBalance < 1) return ctx.reply(ctx.t("errors.noMoneyToSteal")); + if (Math.random() < 0.2) return ctx.reply(ctx.t("economy.robFail")); + + const amount = Math.min(targetBalance, randomInt(10, 59)); + const result = ctx.client.eco.rob(ctx.guildId, ctx.userId, target.id, amount); + if (result.error) return ctx.reply(ctx.t("errors.insufficient")); + return ctx.reply(ctx.t("economy.robSuccess", { target: target.id, amount: formatMoney(amount, ctx.language), balance: formatMoney(ctx.client.eco.getBalance(ctx.guildId, ctx.userId), ctx.language) })); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "rob " }; diff --git a/commands/search.js b/commands/search.js index 4e83e3c..bc29317 100644 --- a/commands/search.js +++ b/commands/search.js @@ -1,19 +1,18 @@ -exports.execute = async (client, message, args) => { - let users = [ - "Pocket", - "T-Shirt", - "Zero's Databse", - "Street" - ]; - let amount = Math.floor(Math.random() * 200) + 50; - let beg = await client.eco.beg(client.ecoAddUser, amount, { canLose: true, cooldown: 300000, customName: "search" }); - if (beg.onCooldown) return message.reply(`Come back after ${beg.time.minutes} minutes & ${beg.time.seconds} seconds.`); - if (beg.lost) return message.channel.send(`**${users[Math.floor(Math.random() * users.length)]}:** You were caught! You couldn't get money kiddo.`); - else return message.reply(`**${users[Math.floor(Math.random() * users.length)]}** was somewhat profitable, you found **${beg.amount}** 💸. Now you have **${beg.after}** 💸.`); -}; +const { SlashCommandBuilder } = require("discord.js"); +const { formatMoney, formatRemaining, randomInt } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); + +const places = ["Pocket", "Jacket", "Street", "An old cabinet"]; +exports.data = new SlashCommandBuilder().setName("search").setDescription(commandDescription("search", require("../botConfig").language)); +exports.name = "search"; +exports.aliases = ["ara", "arama"]; -exports.help = { - name: "search", - aliases: [], - usage: "search" -} +exports.execute = async (ctx) => { + const amount = randomInt(50, 249); + const result = ctx.client.eco.randomEarning(ctx.guildId, ctx.userId, "search", amount, { canLose: true, cooldown: 300_000 }); + if (result.onCooldown) return ctx.reply(ctx.t("economy.searchCooldown", { time: formatRemaining(result.remainingMs, ctx.language) })); + const place = places[randomInt(0, places.length - 1)]; + if (result.lost) return ctx.reply(ctx.language === "tr" ? `**${place}:** Bir şey bulamadın. Bir dahaki sefere daha şanslı olabilirsin.` : `**${place}:** You did not find anything. Better luck next time.`); + return ctx.reply(ctx.t("economy.search", { amount: formatMoney(result.amount, ctx.language), balance: formatMoney(result.after, ctx.language) })); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "search" }; diff --git a/commands/sell.js b/commands/sell.js new file mode 100644 index 0000000..e9c4f49 --- /dev/null +++ b/commands/sell.js @@ -0,0 +1,33 @@ +const { SlashCommandBuilder } = require("discord.js"); +const { parsePositiveInteger } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); +const config = require("../botConfig"); + +exports.data = new SlashCommandBuilder() + .setName("sell") + .setDescription(commandDescription("sell", config.language)) + .addStringOption((option) => option.setName("urun").setDescription("Configured item ID.").setRequired(true)) + .addIntegerOption((option) => option.setName("miktar").setDescription("Quantity to list.").setMinValue(1).setRequired(true)) + .addIntegerOption((option) => option.setName("fiyat").setDescription("Price per unit.").setMinValue(1).setRequired(true)); +exports.name = "sell"; +exports.aliases = ["sat", "ilan", "listele"]; + +exports.execute = async (ctx) => { + const itemId = String(ctx.isSlash ? ctx.stringOption("urun") : ctx.args[0] || "").trim().toLowerCase(); + const quantity = ctx.isSlash ? ctx.integerOption("miktar") : parsePositiveInteger(ctx.args[1]); + const unitPrice = ctx.isSlash ? ctx.integerOption("fiyat") : parsePositiveInteger(ctx.args[2]); + if (!itemId || !quantity || !unitPrice) return ctx.reply(ctx.t("errors.invalidListing")); + + const result = ctx.client.eco.createListing(ctx.guildId, ctx.userId, itemId, quantity, unitPrice); + if (result.error === "Bilinmeyen item.") return ctx.reply(ctx.t("errors.invalidItem")); + if (result.error === "Envanterinde bu üründen yeterli miktar yok.") return ctx.reply(ctx.t("errors.notEnoughItems", { available: result.available })); + if (result.error) return ctx.reply(ctx.t("errors.invalidListing")); + + return ctx.reply(ctx.t("economy.listingCreated", { + item: result.listing.itemName, + quantity: result.listing.quantity, + price: result.listing.unitPrice, + listing: result.listing.id + })); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "sell " }; diff --git a/commands/setmoney.js b/commands/setmoney.js index 7ad76bb..61dc062 100644 --- a/commands/setmoney.js +++ b/commands/setmoney.js @@ -1,24 +1,26 @@ -const { MessageEmbed } = require("discord.js"); +const { EmbedBuilder, SlashCommandBuilder } = require("discord.js"); +const { isAdmin, resolveUser, formatMoney, parseNonNegativeInteger } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); -exports.execute = async (client, message, args) => { - if (!client.config.admins.includes(message.author.id)) return; // return if author isn't bot owner - let user = message.mentions.users.first(); - if (!user) return message.channel.send("Please specify a user!"); - let amount = args[1]; - if (!amount || isNaN(amount)) return message.reply("Please specify a valid amount."); - let data = client.eco.setMoney(user.id, parseInt(amount)); - const embed = new MessageEmbed() - .setTitle(`Money Updated!`) - .addField(`User`, `<@${data.user}>`) - .addField(`Total Amount`, data.after) - .setColor("RANDOM") - .setThumbnail(user.displayAvatarURL) - .setTimestamp(); - return message.channel.send(embed); -} +exports.data = new SlashCommandBuilder() + .setName("setmoney") + .setDescription(commandDescription("setmoney", require("../botConfig").language)) + .addUserOption((option) => option.setName("kullanici").setDescription("User whose balance will be changed.").setRequired(true)) + .addIntegerOption((option) => option.setName("miktar").setDescription("New balance.").setMinValue(0).setMaxValue(2147483647).setRequired(true)); +exports.name = "setmoney"; +exports.aliases = ["setbal", "parayarla", "bakiyeayarla"]; -exports.help = { - name: "setmoney", - aliases: ["setbal"], - usage: `setmoney @user ` -} +exports.execute = async (ctx) => { + if (!isAdmin(ctx)) return ctx.reply({ content: ctx.t("errors.noPermission"), ephemeral: true }); + const user = await resolveUser(ctx, 0); + const amount = ctx.isSlash ? ctx.amount("miktar") : parseNonNegativeInteger(ctx.args[1]); + if (!user || user.bot) return ctx.reply(ctx.t("errors.noUser")); + if (amount === null || amount === undefined) return ctx.reply(ctx.t("errors.noAmount")); + const data = ctx.client.eco.setMoney(ctx.guildId, user.id, amount); + const embed = new EmbedBuilder().setTitle(ctx.t("economy.moneySetTitle")).addFields( + { name: ctx.t("economy.user"), value: `<@${user.id}>`, inline: true }, + { name: ctx.t("economy.newBalanceSet"), value: formatMoney(data.after, ctx.language), inline: true } + ).setColor("Blurple").setThumbnail(user.displayAvatarURL()).setTimestamp(); + return ctx.reply({ embeds: [embed] }); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "setmoney @user " }; diff --git a/commands/shop.js b/commands/shop.js index eb450c7..3c62a9a 100644 --- a/commands/shop.js +++ b/commands/shop.js @@ -1,23 +1,35 @@ -const { MessageEmbed } = require("discord.js"); +const { EmbedBuilder, SlashCommandBuilder } = require("discord.js"); +const { formatMoney } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); +const config = require("../botConfig"); -exports.execute = async (client, message, args) => { - let items = Object.keys(client.shop); - let content = ""; - - for (var i in items) { - content += `${items[i]} - :dollar: ${client.shop[items[i]].cost}\n` +exports.data = new SlashCommandBuilder().setName("shop").setDescription(commandDescription("shop", config.language)); +exports.name = "shop"; +exports.aliases = ["mağaza", "magaza", "market"]; + +exports.execute = async (ctx) => { + const market = ctx.client.eco.getMarket(ctx.guildId); + const listings = ctx.client.eco.getListings(ctx.guildId); + const isTr = ctx.language === "tr"; + const embed = new EmbedBuilder() + .setTitle(ctx.t("economy.shopTitle")) + .setDescription(isTr ? "Sistem marketi ve oyuncuların açtığı satış ilanları burada. Kendi envanterindeki item'ları `/sell` ile mağazaya çıkarabilirsin." : "The system market and member-created listings are shown here. List items from your inventory with `/sell` to open your own shop listing.") + .setColor("Blurple") + .setTimestamp(); + + if (market.length) { + const marketText = market.map((item) => `**${item.name}** — ${formatMoney(item.cost, ctx.language)} • ${isTr ? "Stok" : "Stock"}: **${item.stock}** • \`/buy ${item.id}\``).join("\n"); + embed.addFields({ name: isTr ? "Sistem Marketi" : "System Market", value: marketText.slice(0, 1024) }); + } + + if (listings.length) { + const lines = listings.slice(0, 20).map((listing) => `**${listing.itemName}** ×${listing.quantity} — ${formatMoney(listing.unitPrice, ctx.language)}/${isTr ? "adet" : "unit"} • ${isTr ? "Satıcı" : "Seller"}: <@${listing.sellerId}> • ID: \`${listing.id}\``); + embed.addFields({ name: isTr ? "Oyuncu Mağazaları" : "Member Shops", value: lines.join("\n").slice(0, 1024) }); + } else { + embed.addFields({ name: isTr ? "Oyuncu Mağazaları" : "Member Shops", value: isTr ? "Henüz oyuncu satış ilanı bulunmuyor." : "There are no member listings yet." }); } - - let embed = new MessageEmbed() - .setTitle("Store") - .setDescription(content) - .setColor("BLURPLE") - .setFooter("Do :?buy to purchase the item.") - return message.channel.send(embed); -}; -exports.help = { - name: "shop", - aliases: [], - usage: `shop` + embed.setFooter({ text: isTr ? "Bir ilandaki ürünü almak için /buy kullan." : "Use /buy to purchase from a member listing." }); + return ctx.reply({ embeds: [embed] }); }; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "shop" }; diff --git a/commands/transfer.js b/commands/transfer.js index 168848a..012f3b7 100644 --- a/commands/transfer.js +++ b/commands/transfer.js @@ -1,15 +1,23 @@ -exports.execute = async (client, message, args) => { - let member = message.mentions.members.first() || message.guild.members.cache.get(args[0]) - let authordata = client.eco.fetchMoney(message.author.id) - if (!member) return message.channel.send('Please mention the person or give their ID') - let amount = args[1] - if (!amount || isNaN(amount)) return message.channel.send('Please enter a valid amount to transfer') - if(authordata.amount < amount) return message.channel.send('Looks like you don\'t have that much money') - await client.eco.transfer(message.author.id, member.id, amount) - return message.channel.send(`You have successfully transferred 💸**${amount}** to ** ${member.user.tag}**.`) -} -exports.help = { - name: "transfer", - aliases: ['give', 'share'], - usage: `transfer ` +const { SlashCommandBuilder } = require("discord.js"); +const { resolveUser, getAmount, formatMoney } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); + +exports.data = new SlashCommandBuilder() + .setName("transfer") + .setDescription(commandDescription("transfer", require("../botConfig").language)) + .addUserOption((option) => option.setName("kullanici").setDescription("User who should receive the money.").setRequired(true)) + .addIntegerOption((option) => option.setName("miktar").setDescription("Amount to transfer.").setMinValue(1).setMaxValue(2147483647).setRequired(true)); +exports.name = "transfer"; +exports.aliases = ["give", "share", "aktar", "paraaktar"]; + +exports.execute = async (ctx) => { + const target = await resolveUser(ctx, 0); + const amount = getAmount(ctx, "miktar", 1); + if (!target || target.bot) return ctx.reply(ctx.t("errors.noUser")); + if (!amount) return ctx.reply(ctx.t("errors.noAmount")); + if (target.id === ctx.userId) return ctx.reply(ctx.t("errors.self")); + const result = ctx.client.eco.transfer(ctx.guildId, ctx.userId, target.id, amount); + if (result.error) return ctx.reply(ctx.t("errors.insufficient")); + return ctx.reply(ctx.t("economy.transfer", { target: target.id, amount: formatMoney(amount, ctx.language), balance: formatMoney(result.fromBalance, ctx.language) })); }; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "transfer " }; diff --git a/commands/weekly.js b/commands/weekly.js index 8625edb..2ff9283 100644 --- a/commands/weekly.js +++ b/commands/weekly.js @@ -1,12 +1,16 @@ -exports.execute = async (client, message, args) => { - let amount = Math.floor(Math.random() * 1000) + 500; - let addMoney = client.eco.weekly(client.ecoAddUser, amount); - if (addMoney.onCooldown) return message.reply(`You have already claimed your weekly credit. Come back after ${addMoney.time.days} days, ${addMoney.time.hours} hours, ${addMoney.time.minutes} minutes & ${addMoney.time.seconds} seconds to claim it again.`); - else return message.reply(`You have claimed **${addMoney.amount}** 💸 as your weekly credit & now you have **${addMoney.after}** 💸. But you will lose your balance if you do not subscribe to ZeroSync on yt :P`); -}; - -exports.help = { - name: "weekly", - aliases: [], - usage: "weekly" -} +const { SlashCommandBuilder } = require("discord.js"); +const { formatMoney, formatRemaining, randomInt } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); +const config = require("../botConfig"); + +exports.data = new SlashCommandBuilder().setName("weekly").setDescription(commandDescription("weekly", config.language)); +exports.name = "weekly"; +exports.aliases = ["haftalık", "haftalik"]; + +exports.execute = async (ctx) => { + const result = ctx.client.eco.weekly(ctx.guildId, ctx.userId, randomInt(750, 1999)); + if (result.onCooldown) return ctx.reply(ctx.t("economy.weeklyCooldown", { time: formatRemaining(result.remainingMs, ctx.language) })); + const drop = result.droppedItem ? ` ${ctx.t("economy.itemDrop", { item: result.droppedItem.name })}` : ""; + return ctx.reply(ctx.t("economy.weekly", { amount: formatMoney(result.amount, ctx.language), balance: formatMoney(result.after, ctx.language) }) + drop); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "weekly" }; diff --git a/commands/work.js b/commands/work.js index 84e957b..c783070 100644 --- a/commands/work.js +++ b/commands/work.js @@ -1,12 +1,16 @@ -module.exports.execute = async (client, message, args) => { - let amount = Math.floor(Math.random() * 1500) + 1000; - let work = client.eco.work(client.ecoAddUser, amount); - if (work.onCooldown) return message.reply(`You are tired rn. Come back after ${work.time.minutes} minutes & ${work.time.seconds} seconds to work again.`); - else return message.reply(`You worked as **${work.workedAs}** and earned **${work.amount}** 💸. Now you have **${work.after}** 💸.`); -}; - -module.exports.help = { - name: "work", - aliases: [], - usage: "work" -} +const { SlashCommandBuilder } = require("discord.js"); +const { formatMoney, formatRemaining, randomInt } = require("../lib/commandUtils"); +const { commandDescription } = require("../lib/i18n"); + +exports.data = new SlashCommandBuilder().setName("work").setDescription(commandDescription("work", require("../botConfig").language)); +exports.name = "work"; +exports.aliases = ["çalış", "calis", "çalıştır", "calistir"]; + +exports.execute = async (ctx) => { + const jobs = ctx.language === "tr" ? ["Geliştirici", "Doktor", "Öğretmen", "Müzisyen", "Madenci", "Mühendis", "Tasarımcı", "Yayıncı"] : ["Developer", "Doctor", "Teacher", "Musician", "Miner", "Engineer", "Designer", "Streamer"]; + const amount = randomInt(150, 499); + const result = ctx.client.eco.work(ctx.guildId, ctx.userId, amount, { cooldown: 2_700_000, jobs }); + if (result.onCooldown) return ctx.reply(ctx.t("economy.workCooldown", { time: formatRemaining(result.remainingMs, ctx.language) })); + return ctx.reply(ctx.t("economy.work", { job: result.workedAs, amount: formatMoney(result.amount, ctx.language), balance: formatMoney(result.after, ctx.language) })); +}; +exports.help = { name: exports.name, aliases: exports.aliases, usage: "work" }; diff --git a/counter.js b/counter.js index 33027fc..8ad0843 100644 --- a/counter.js +++ b/counter.js @@ -1,30 +1,32 @@ -const { db } = require("quick.eco"); +const { createTranslator } = require("./lib/i18n"); -function counter(message, client) { - let channel = message.channel; - let count = db.fetch(`counter_${message.guild.id}`); - if (count === null) count = db.set(`counter_${message.guild.id}`, { - number: 0, - author: client.user.id - }); - - if (!message.author.bot && message.author.id === count.author) { - message.delete(); - message.reply("Please wait for your turn").then(m => m.delete(3000)); - return; - } - if (!message.author.bot && isNaN(message.content)) { - message.delete(); - message.reply("messages in this channel must be a number").then(m => m.delete(3000)); - return; - } - if (!message.author.bot && parseInt(message.content) !== count.number + 1) { - message.delete(); - message.reply(`next number must be ${count.number + 1}`).then(m => m.delete(3000)); - return; - } - count = db.set(`counter_${message.guild.id}`, { number: count.number + 1, author: message.author.id }); - channel.setTopic(`Next number must be ${count.number + 1}.`); +async function counter(message, client) { + const t = createTranslator(client.config.language); + const key = `counter:${message.guild.id}`; + let state = client.db.get(key, { number: 0, author: null }); + if (!state || typeof state !== "object" || !Number.isSafeInteger(state.number) || state.number < 0) state = { number: 0, author: null }; + + const reject = async (text) => { + await message.delete().catch(() => {}); + const reply = await message.reply(text).catch(() => null); + if (reply) setTimeout(() => reply.delete().catch(() => {}), 3000); + return false; + }; + + const yourTurn = client.config.language === "tr" ? "Sıra sende değil, lütfen başka birinin sayı yazmasını bekle." : "It is not your turn. Please wait for another user to post the next number."; + const onlyNumbers = client.config.language === "tr" ? "Bu kanaldaki mesajlar sayı olmalıdır." : "Messages in this channel must contain numbers only."; + const nextNumber = (number) => client.config.language === "tr" ? `Sıradaki sayı **${number}** olmalıdır.` : `The next number must be **${number}**.`; + const nextTopic = (number) => client.config.language === "tr" ? `Sıradaki sayı ${number} olmalıdır.` : `The next number should be ${number}.`; + + if (message.author.id === state.author) return reject(yourTurn); + if (!/^\d+$/.test(message.content)) return reject(onlyNumbers); + + const number = Number(message.content); + if (!Number.isSafeInteger(number) || number !== state.number + 1) return reject(nextNumber(state.number + 1)); + + client.db.set(key, { number, author: message.author.id }); + await message.channel.setTopic(nextTopic(number + 1)).catch(() => {}); + return true; } module.exports = counter; diff --git a/events/clientReady.js b/events/clientReady.js new file mode 100644 index 0000000..f126e85 --- /dev/null +++ b/events/clientReady.js @@ -0,0 +1,22 @@ +const { REST, Routes } = require("discord.js"); +const { commandDescription } = require("../lib/i18n"); + +module.exports = async (client) => { + console.log(`${client.user.tag} çevrimiçi!`); + console.log(`Dil: ${client.config.language === "tr" ? "Türkçe" : "English"}`); + console.log(`Veritabanı: ${client.db.connection?.open ? "açık" : "kapalı"}`); + + const commands = [...client.commands.values()].map((command) => { + const json = command.data.toJSON(); + json.description = commandDescription(command.name, client.config.language); + return json; + }); + + try { + const rest = new REST({ version: "10" }).setToken(client.config.token); + await rest.put(Routes.applicationGuildCommands(client.user.id, String(client.config.serverId)), { body: commands }); + console.log(`${commands.length} slash komutu ${client.config.language === "tr" ? "sunucuya" : "to the server"} başarıyla deploy edildi.`); + } catch (error) { + console.error("Slash komutları deploy edilirken hata oluştu:", error); + } +}; diff --git a/events/debug.js b/events/debug.js deleted file mode 100644 index 99832ba..0000000 --- a/events/debug.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = (client, debug) => { - if (!client.config.debug) return; - else console.log(debug); -}; diff --git a/events/interactionCreate.js b/events/interactionCreate.js new file mode 100644 index 0000000..fa25f73 --- /dev/null +++ b/events/interactionCreate.js @@ -0,0 +1,27 @@ +const { createInteractionContext } = require("../lib/context"); + +module.exports = async (client, interaction) => { + if (!interaction.isChatInputCommand() || !interaction.inGuild()) return; + + const command = client.commands.get(interaction.commandName); + if (!command) { + return interaction.reply({ content: "Command is currently unavailable.", ephemeral: true }).catch(() => {}); + } + + try { + const context = createInteractionContext(interaction, client); + await command.execute(context); + if (!interaction.replied && !interaction.deferred) { + throw new Error(`/${interaction.commandName} completed without sending a response.`); + } + } catch (error) { + console.error(`/${interaction.commandName} command error:`, error); + const context = createInteractionContext(interaction, client); + const message = context.t("errors.generic"); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: message, ephemeral: true }).catch(() => {}); + } else { + await interaction.reply({ content: message, ephemeral: true }).catch(() => {}); + } + } +}; diff --git a/events/message.js b/events/message.js deleted file mode 100644 index 7118001..0000000 --- a/events/message.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = async (client, message) => { - if (!message.guild || message.author.bot) return; - if (message.channel.id === client.config.countChannel) require("../counter")(message, client); - client.prefix = client.db.fetch(`prefix_${message.guild.id}`) ? client.db.fetch(`prefix_${message.guild.id}`) : client.config.prefix; - if (!message.content.startsWith(client.prefix)) return; - let args = message.content.slice(client.prefix.length).trim().split(" "); - let commandName = args.shift().toLowerCase(); - let command = client.commands.get(commandName) || client.commands.get(client.aliases.get(commandName)); - if (!command) return; - client.ecoAddUser = message.author.id; - command.execute(client, message, args); -}; diff --git a/events/messageCreate.js b/events/messageCreate.js new file mode 100644 index 0000000..4f7d561 --- /dev/null +++ b/events/messageCreate.js @@ -0,0 +1,34 @@ +const { createPrefixContext } = require("../lib/context"); + +module.exports = async (client, message) => { + if (!message.inGuild() || message.author.bot) return; + + if (client.config.countChannel && message.channel.id === client.config.countChannel) { + try { + await require("../counter")(message, client); + } catch (error) { + console.error("Counter system error:", error); + } + } + + const prefix = client.db.getPrefix(message.guild.id, client.config.prefix); + if (!message.content.startsWith(prefix)) return; + const content = message.content.slice(prefix.length).trim(); + if (!content) return; + + const args = content.split(/\s+/); + const commandName = args.shift().toLowerCase(); + const canonicalName = client.commands.has(commandName) ? commandName : client.aliases.get(commandName); + if (!canonicalName) return; + + const command = client.commands.get(canonicalName); + const context = createPrefixContext(message, client, args); + context.prefix = prefix; + + try { + await command.execute(context); + } catch (error) { + console.error(`Prefix command error (${prefix}${commandName}):`, error); + await message.reply(context.t("errors.generic")).catch(() => {}); + } +}; diff --git a/events/ready.js b/events/ready.js deleted file mode 100644 index d654b82..0000000 --- a/events/ready.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = (client) => { - console.log(`${client.user.tag} is online!`); - client.user.setActivity("Coded by ZeroSync"); -}; diff --git a/index.js b/index.js index 7c815b7..6829511 100644 --- a/index.js +++ b/index.js @@ -1,45 +1,129 @@ -const Discord = require("discord.js"); - const client = new Discord.Client({ disableMentions: 'everyone' }); -const Eco = require("quick.eco"); -client.eco = new Eco.Manager(); // quick.eco -client.db = Eco.db; // quick.db -client.config = require("./botConfig"); -client.commands = new Discord.Collection(); -client.aliases = new Discord.Collection(); -client.shop = { - "Laptop" : { - cost: 2000 - }, - "Mobile" : { - cost: 1000 - }, - "PC" : { - cost: 3000 +const path = require("node:path"); +const fs = require("node:fs"); +const { Client, Collection, GatewayIntentBits, Partials } = require("discord.js"); +const config = require("./botConfig"); +const { KeyValueStore, defaultPath } = require("./lib/database"); +const EconomyManager = require("./lib/economy"); +const InventoryManager = require("./lib/inventory"); +const { normalizeLanguage } = require("./lib/i18n"); + +function assertDiscordId(value, label) { + if (!/^\d{17,20}$/.test(String(value || ""))) throw new Error(`${label} geçerli bir Discord ID'si olmalı.`); +} + +function validateItemRegistry(items) { + if (!Array.isArray(items) || items.length === 0) throw new Error("botConfig.js içindeki items en az bir item içermeli."); + const ids = new Set(); + let dailyChance = 0; + let weeklyChance = 0; + for (const item of items) { + if (!item || typeof item !== "object") throw new TypeError("items içindeki her kayıt bir obje olmalı."); + const id = String(item.id || "").trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(id)) throw new Error(`Geçersiz item ID'si: ${item.id}`); + if (ids.has(id)) throw new Error(`Yinelenen item ID'si: ${id}`); + if (!String(item.name || "").trim()) throw new Error(`${id}: item name alanı boş olamaz.`); + const daily = Number(item.dailyDropChance ?? 0); + const weekly = Number(item.weeklyDropChance ?? 0); + if (!Number.isFinite(daily) || daily < 0 || daily > 100) throw new Error(`${id}: dailyDropChance 0-100 arasında olmalı.`); + if (!Number.isFinite(weekly) || weekly < 0 || weekly > 100) throw new Error(`${id}: weeklyDropChance 0-100 arasında olmalı.`); + ids.add(id); + dailyChance += daily; + weeklyChance += weekly; + item.id = id; + item.name = String(item.name).trim(); + item.description = String(item.description || "").trim(); + item.dailyDropChance = daily; + item.weeklyDropChance = weekly; } -}; -const fs = require("fs"); - -fs.readdir("./events/", (err, files) => { - if (err) return console.error(err); - files.forEach(f => { - if (!f.endsWith(".js")) return; - const event = require(`./events/${f}`); - let eventName = f.split(".")[0]; - client.on(eventName, event.bind(null, client)); - }); -}); + if (dailyChance > 100 || weeklyChance > 100) throw new Error("Günlük veya haftalık item drop olasılıklarının toplamı %100'ü aşamaz."); + return ids; +} + +function validateMarket(market, itemIds) { + if (!Array.isArray(market) || market.length === 0) throw new Error("botConfig.js içindeki market en az bir ürün içermeli."); + const seen = new Set(); + for (const entry of market) { + if (!entry || typeof entry !== "object") throw new TypeError("market içindeki her kayıt bir obje olmalı."); + const itemId = String(entry.item || "").trim().toLowerCase(); + if (!itemIds.has(itemId)) throw new Error(`Market ürünü '${itemId}' items bölümünde tanımlı değil.`); + if (seen.has(itemId)) throw new Error(`Market içinde '${itemId}' birden fazla tanımlanamaz.`); + const price = Number(entry.price); + const stock = Number(entry.stock); + if (!Number.isSafeInteger(price) || price < 0) throw new Error(`${itemId}: market price güvenli ve negatif olmayan bir tam sayı olmalı.`); + if (!Number.isSafeInteger(stock) || stock <= 0) throw new Error(`${itemId}: market stock başlangıçta en az 1 olmalı.`); + entry.item = itemId; + entry.price = price; + entry.stock = stock; + seen.add(itemId); + } +} + +if (!config || typeof config !== "object") throw new Error("botConfig.js geçerli bir yapılandırma döndürmelidir."); +if (!config.token || config.token === "YOUR_TOKEN") throw new Error("botConfig.js içindeki token ayarlanmalı."); +assertDiscordId(config.serverId, "serverId"); +if (!config.prefix || typeof config.prefix !== "string" || !/^\S{1,5}$/.test(config.prefix)) throw new Error("botConfig.js içindeki prefix 1-5 karakter ve boşluksuz olmalı."); +config.admins = Array.isArray(config.admins) ? config.admins.map(String) : []; +config.adminRoles = Array.isArray(config.adminRoles) ? config.adminRoles.map(String) : []; +config.adminRoleNames = Array.isArray(config.adminRoleNames) ? config.adminRoleNames.map((name) => String(name).trim()) : []; +config.language = normalizeLanguage(config.language); +const itemIds = validateItemRegistry(config.items); +validateMarket(config.market, itemIds); +config.sharedInventory = config.sharedInventory && typeof config.sharedInventory === "object" ? config.sharedInventory : { enabled: false, databasePath: defaultPath }; +config.sharedInventory.enabled = Boolean(config.sharedInventory.enabled); +config.sharedInventory.databasePath = path.resolve(String(config.sharedInventory.databasePath || defaultPath)); +if (config.sharedInventory.enabled) fs.mkdirSync(path.dirname(config.sharedInventory.databasePath), { recursive: true }); -fs.readdir("./commands/", (err, files) => { - if (err) return console.error(err); - files.forEach(f => { - if (!f.endsWith(".js")) return; - let command = require(`./commands/${f}`); - client.commands.set(command.help.name, command); - command.help.aliases.forEach(alias => { - client.aliases.set(alias, command.help.name); - }); - }); +const database = new KeyValueStore(config.sharedInventory.enabled ? config.sharedInventory.databasePath : defaultPath); +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent], + partials: [Partials.Channel] }); +client.config = config; +client.db = database; +client.inventory = new InventoryManager(database, config.items); +client.eco = new EconomyManager(database, client.inventory, config.items, config.market); +client.commands = new Collection(); +client.aliases = new Collection(); +client.shop = client.eco.getMarketCatalog(); +client.items = client.inventory.getItemRegistry(); -client.login(client.config.token); +const commandsPath = path.join(__dirname, "commands"); +const commandFiles = fs.readdirSync(commandsPath).filter((file) => file.endsWith(".js")).sort(); +for (const file of commandFiles) { + const command = require(path.join(commandsPath, file)); + const name = String(command?.name || "").trim().toLowerCase(); + if (!name || typeof command.execute !== "function" || typeof command.data?.toJSON !== "function") throw new TypeError(`commands/${file}: name, data veya execute eksik.`); + if (command.help?.name !== name) throw new Error(`commands/${file}: help.name ve name eşleşmiyor.`); + if (client.commands.has(name)) throw new Error(`Yinelenen komut adı: ${name}`); + client.commands.set(name, command); + for (const alias of command.aliases || []) { + const normalized = String(alias).trim().toLowerCase(); + if (!normalized || normalized === name || client.commands.has(normalized) || client.aliases.has(normalized)) throw new Error(`Geçersiz veya çakışan takma ad: ${alias}`); + client.aliases.set(normalized, name); + } +} +if (client.commands.size !== 18) throw new Error(`18 komut bekleniyordu, ${client.commands.size} komut yüklendi.`); + +client.once("clientReady", require("./events/clientReady").bind(null, client)); +client.on("interactionCreate", require("./events/interactionCreate").bind(null, client)); +client.on("messageCreate", require("./events/messageCreate").bind(null, client)); +client.on("error", (error) => console.error("Discord client hatası:", error)); +client.on("warn", (warning) => console.warn("Discord uyarısı:", warning)); +process.on("unhandledRejection", (error) => console.error("Yakalanmamış Promise hatası:", error)); +process.on("uncaughtException", (error) => console.error("Yakalanmamış uygulama hatası:", error)); + +let shuttingDown = false; +const shutdown = () => { + if (shuttingDown) return; + shuttingDown = true; + try { client.destroy(); } finally { database.close(); } +}; +process.once("SIGINT", shutdown); +process.once("SIGTERM", shutdown); + +client.login(config.token).catch((error) => { + console.error("Discord'a giriş yapılamadı:", error); + shutdown(); + process.exitCode = 1; +}); diff --git a/lib/commandUtils.js b/lib/commandUtils.js new file mode 100644 index 0000000..6cf5108 --- /dev/null +++ b/lib/commandUtils.js @@ -0,0 +1,123 @@ +const { PermissionsBitField } = require("discord.js"); + +function hasPermissionValue(permissionSource, permission) { + if (!permissionSource) return false; + if (typeof permissionSource.has === "function") return permissionSource.has(permission, true); + try { + return new PermissionsBitField(permissionSource).has(permission, true); + } catch { + return false; + } +} + +function memberHasConfiguredRole(member, roleIds, roleNames) { + if (!member) return false; + + const roles = member.roles?.cache; + if (roles) { + for (const role of roles.values()) { + if (roleIds.includes(String(role.id))) return true; + if (roleNames.includes(String(role.name).trim().toLowerCase())) return true; + } + } + + if (Array.isArray(member.roles)) { + for (const roleId of member.roles) { + if (roleIds.includes(String(roleId))) return true; + } + } + + return false; +} + +function getMemberForRoleChecks(ctx) { + if (ctx.member) return ctx.member; + const cached = ctx.guild?.members?.cache?.get?.(ctx.userId); + if (cached) return cached; + return ctx.interaction?.guild?.members?.cache?.get?.(ctx.userId) || null; +} + +function isAdmin(ctx) { + const config = ctx.client.config || {}; + const userId = String(ctx.userId || ""); + const adminUsers = Array.isArray(config.admins) ? config.admins.map(String).filter((id) => /^\d{17,20}$/.test(id)) : []; + if (adminUsers.includes(userId)) return true; + + const permissions = ctx.interaction?.memberPermissions || ctx.member?.permissions; + if (hasPermissionValue(permissions, PermissionsBitField.Flags.Administrator)) return true; + if (hasPermissionValue(permissions, PermissionsBitField.Flags.ManageGuild)) return true; + + const roleIds = Array.isArray(config.adminRoles) ? config.adminRoles.map(String).filter((id) => /^\d{17,20}$/.test(id)) : []; + const roleNames = Array.isArray(config.adminRoleNames) ? config.adminRoleNames.map((name) => String(name).trim().toLowerCase()).filter(Boolean) : []; + return memberHasConfiguredRole(getMemberForRoleChecks(ctx), roleIds, roleNames); +} + +function parsePositiveInteger(value) { + const text = String(value ?? "").trim(); + if (!/^\d+$/.test(text)) return null; + const number = Number(text); + return Number.isSafeInteger(number) && number > 0 ? number : null; +} + +function parseNonNegativeInteger(value) { + const text = String(value ?? "").trim(); + if (!/^\d+$/.test(text)) return null; + const number = Number(text); + return Number.isSafeInteger(number) && number >= 0 ? number : null; +} + +async function resolveUser(ctx, argumentIndex = 0) { + if (ctx.isSlash) return ctx.userOption("kullanici"); + const mentioned = ctx.message.mentions?.users?.first?.(); + if (mentioned) return mentioned; + const id = ctx.args[argumentIndex]; + if (!/^\d{17,20}$/.test(String(id || ""))) return null; + return ctx.client.users.fetch(id).catch(() => null); +} + +async function resolveMember(ctx, argumentIndex = 0) { + if (ctx.isSlash) return ctx.memberOption("kullanici"); + const mentioned = ctx.message.mentions?.members?.first?.(); + if (mentioned) return mentioned; + const id = ctx.args[argumentIndex]; + if (!/^\d{17,20}$/.test(String(id || ""))) return null; + return ctx.guild.members.fetch(id).catch(() => null); +} + +function getAmount(ctx, optionName = "miktar", argumentIndex = 1) { + return ctx.isSlash ? ctx.amount(optionName) : parsePositiveInteger(ctx.args[argumentIndex]); +} + +function getProduct(ctx) { + return ctx.isSlash ? ctx.stringOption("urun") : ctx.args[0] || null; +} + +function formatMoney(value, language = "en") { + const locale = String(language).toLowerCase().startsWith("tr") ? "tr-TR" : "en-US"; + return `${Number(value).toLocaleString(locale)} 💸`; +} + +function formatRemaining(ms, language = "en") { + let seconds = Math.max(0, Math.ceil(ms / 1000)); + const isTurkish = String(language).toLowerCase().startsWith("tr"); + const days = Math.floor(seconds / 86400); + seconds %= 86400; + const hours = Math.floor(seconds / 3600); + seconds %= 3600; + const minutes = Math.floor(seconds / 60); + seconds %= 60; + const units = isTurkish ? ["gün", "saat", "dakika", "saniye"] : ["day", "hour", "minute", "second"]; + const parts = []; + if (days) parts.push(`${days} ${units[0]}${!isTurkish && days !== 1 ? "s" : ""}`); + if (hours) parts.push(`${hours} ${units[1]}${!isTurkish && hours !== 1 ? "s" : ""}`); + if (minutes) parts.push(`${minutes} ${units[2]}${!isTurkish && minutes !== 1 ? "s" : ""}`); + if (seconds || parts.length === 0) parts.push(`${seconds} ${units[3]}${!isTurkish && seconds !== 1 ? "s" : ""}`); + return parts.join(", "); +} + +function randomInt(min, max) { + if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min > max) throw new RangeError("Geçersiz rastgele sayı aralığı."); + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +module.exports = { isAdmin, parsePositiveInteger, parseNonNegativeInteger, resolveUser, resolveMember, getAmount, getProduct, formatMoney, formatRemaining, randomInt }; diff --git a/lib/context.js b/lib/context.js new file mode 100644 index 0000000..ac523b2 --- /dev/null +++ b/lib/context.js @@ -0,0 +1,90 @@ +const { createTranslator, getLanguage } = require("./i18n"); + +function createBaseContext(client, language) { + const t = createTranslator(language ?? getLanguage(client.config)); + return { client, language: t.language, t }; +} + +function createInteractionContext(interaction, client) { + const base = createBaseContext(client); + return { + ...base, + interaction, + isSlash: true, + guild: interaction.guild, + guildId: interaction.guildId, + user: interaction.user, + userId: interaction.user.id, + member: interaction.member, + args: [], + option(name) { + return interaction.options.get(name)?.value ?? null; + }, + userOption(name = "kullanici") { + return interaction.options.getUser(name); + }, + memberOption(name = "kullanici") { + return interaction.options.getMember(name); + }, + amount(name = "miktar") { + return interaction.options.getInteger(name); + }, + integerOption(name) { + return interaction.options.getInteger(name); + }, + stringOption(name) { + return interaction.options.getString(name); + }, + async reply(payload) { + return interaction.reply(payload); + } + }; +} + +function createPrefixContext(message, client, args) { + const base = createBaseContext(client); + return { + ...base, + message, + interaction: null, + isSlash: false, + guild: message.guild, + guildId: message.guild.id, + user: message.author, + userId: message.author.id, + member: message.member, + args, + prefix: null, + option() { + return null; + }, + userOption() { + const mentioned = message.mentions?.users?.first?.(); + return mentioned || null; + }, + memberOption() { + const mentioned = message.mentions?.members?.first?.(); + return mentioned || null; + }, + amount(_name = "miktar", argumentIndex = 1) { + const value = args[argumentIndex]; + if (!/^\d+$/.test(String(value ?? ""))) return null; + const number = Number(value); + return Number.isSafeInteger(number) && number > 0 ? number : null; + }, + integerOption(_name, argumentIndex = 1) { + const value = args[argumentIndex]; + if (!/^\d+$/.test(String(value ?? ""))) return null; + const number = Number(value); + return Number.isSafeInteger(number) && number > 0 ? number : null; + }, + stringOption(_name, argumentIndex = 0) { + return args[argumentIndex] ?? null; + }, + async reply(payload) { + return message.reply(payload); + } + }; +} + +module.exports = { createInteractionContext, createPrefixContext }; diff --git a/lib/database.js b/lib/database.js new file mode 100644 index 0000000..3490bb3 --- /dev/null +++ b/lib/database.js @@ -0,0 +1,111 @@ +const Database = require("better-sqlite3"); +const fs = require("node:fs"); +const path = require("node:path"); + +const dataDir = path.join(__dirname, "..", "data"); + +class KeyValueStore { + constructor(filePath = path.join(dataDir, "economy.sqlite")) { + this.filePath = path.resolve(filePath); + this.connection = null; + this.statements = null; + this.open(); + } + + open() { + if (this.connection?.open && this.statements) return this; + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + this.connection = new Database(this.filePath); + this.connection.pragma("journal_mode = WAL"); + this.connection.pragma("foreign_keys = ON"); + this.connection.pragma("synchronous = NORMAL"); + this.connection.exec(`CREATE TABLE IF NOT EXISTS kv (id TEXT PRIMARY KEY, value TEXT NOT NULL);`); + this.statements = { + get: this.connection.prepare("SELECT value FROM kv WHERE id = ?"), + set: this.connection.prepare("INSERT INTO kv (id, value) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET value = excluded.value"), + delete: this.connection.prepare("DELETE FROM kv WHERE id = ?"), + all: this.connection.prepare("SELECT id, value FROM kv ORDER BY id"), + clear: this.connection.prepare("DELETE FROM kv") + }; + return this; + } + + ensureOpen() { + if (!this.connection?.open || !this.statements) this.open(); + if (!this.connection?.open || !this.statements) throw new Error("Veritabanı bağlantısı açılamadı."); + } + + encode(value) { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new TypeError("Veritabanı değeri JSON olarak saklanamıyor."); + return encoded; + } + + decode(value) { + return JSON.parse(value); + } + + get(key, fallback = null) { + this.ensureOpen(); + const row = this.statements.get.get(String(key)); + return row ? this.decode(row.value) : fallback; + } + + fetch(key, fallback = null) { return this.get(key, fallback); } + + has(key) { + this.ensureOpen(); + return Boolean(this.statements.get.get(String(key))); + } + + set(key, value) { + this.ensureOpen(); + this.statements.set.run(String(key), this.encode(value)); + return value; + } + + push(key, value) { + const current = this.get(key, []); + if (!Array.isArray(current)) throw new TypeError(`'${key}' mevcut bir dizi değil.`); + current.push(value); + this.set(key, current); + return current; + } + + delete(key) { + this.ensureOpen(); + return this.statements.delete.run(String(key)).changes > 0; + } + + all() { + this.ensureOpen(); + return this.statements.all.all().map(({ id, value }) => ({ ID: id, data: this.decode(value) })); + } + + startsWith(prefix) { + const normalized = String(prefix); + return this.all().filter(({ ID }) => ID.startsWith(normalized)); + } + + getPrefix(guildId, fallback = "!") { return String(this.get(`prefix:${guildId}`, fallback)); } + setPrefix(guildId, prefix) { this.set(`prefix:${guildId}`, String(prefix)); return String(prefix); } + resetPrefix(guildId) { return this.delete(`prefix:${guildId}`); } + + clear() { + this.ensureOpen(); + return this.statements.clear.run().changes; + } + + transaction(callback) { + this.ensureOpen(); + return this.connection.transaction(callback)(); + } + + close() { + if (this.connection?.open) this.connection.close(); + this.connection = null; + this.statements = null; + } +} + +module.exports = { KeyValueStore, defaultPath: path.join(dataDir, "economy.sqlite") }; diff --git a/lib/economy.js b/lib/economy.js new file mode 100644 index 0000000..bd6392c --- /dev/null +++ b/lib/economy.js @@ -0,0 +1,336 @@ +const DAY = 86_400_000; +const WEEK = 7 * DAY; + +class EconomyManager { + constructor(database, inventory, items, market) { + if (!database || !inventory) throw new TypeError("EconomyManager veritabanı ve inventory servisi gerektirir."); + this.db = database; + this.inventory = inventory; + this.items = new Map((items || []).map((item) => [item.id, item])); + this.marketConfig = Array.isArray(market) ? market.map((entry) => ({ ...entry })) : []; + } + + assertId(value, label = "ID") { + if (!/^\d{17,20}$/.test(String(value))) throw new TypeError(`Geçersiz Discord ${label}.`); + } + + assertAmount(value, allowZero = false) { + if (!Number.isSafeInteger(value) || (allowZero ? value < 0 : value <= 0)) throw new TypeError("Miktar güvenli bir tam sayı olmalı."); + } + + moneyKey(guildId, userId) { + this.assertId(guildId, "sunucu ID'si"); + this.assertId(userId, "kullanıcı ID'si"); + return `money:${guildId}:${userId}`; + } + + cooldownKey(guildId, userId, command) { + this.assertId(guildId, "sunucu ID'si"); + this.assertId(userId, "kullanıcı ID'si"); + return `cooldown:${guildId}:${userId}:${command}`; + } + + inventoryKey(guildId, userId) { + return this.inventory.key(guildId, userId); + } + + marketKey(guildId, itemId) { + this.assertId(guildId, "sunucu ID'si"); + return `market:${guildId}:${itemId}`; + } + + listingKey(guildId, listingId) { + this.assertId(guildId, "sunucu ID'si"); + return `listing:${guildId}:${listingId}`; + } + + listingPrefix(guildId) { + this.assertId(guildId, "sunucu ID'si"); + return `listing:${guildId}:`; + } + + getBalance(guildId, userId) { + const stored = Number(this.db.get(this.moneyKey(guildId, userId), 0)); + return Number.isSafeInteger(stored) && stored >= 0 ? stored : 0; + } + + fetchMoney(guildId, userId) { + return { user: { id: String(userId) }, balance: this.getBalance(guildId, userId) }; + } + + _addMoney(guildId, userId, amount) { + const before = this.getBalance(guildId, userId); + const after = before + amount; + if (!Number.isSafeInteger(after)) throw new RangeError("Bakiye güvenli sayı sınırını aşıyor."); + this.db.set(this.moneyKey(guildId, userId), after); + return { user: { id: String(userId) }, amount, before, after }; + } + + addMoney(guildId, userId, amount) { + this.assertAmount(amount); + return this.db.transaction(() => this._addMoney(guildId, userId, amount)); + } + + setMoney(guildId, userId, amount) { + this.assertAmount(amount, true); + return this.db.transaction(() => { + const before = this.getBalance(guildId, userId); + this.db.set(this.moneyKey(guildId, userId), amount); + return { user: { id: String(userId) }, amount, before, after: amount }; + }); + } + + removeMoney(guildId, userId, amount) { + this.assertAmount(amount); + return this.db.transaction(() => { + const before = this.getBalance(guildId, userId); + if (before < amount) return { error: "Yetersiz bakiye.", before, after: before }; + const after = before - amount; + this.db.set(this.moneyKey(guildId, userId), after); + return { user: { id: String(userId) }, amount, before, after }; + }); + } + + transfer(guildId, fromUserId, toUserId, amount) { + this.assertAmount(amount); + this.assertId(fromUserId, "kullanıcı ID'si"); + this.assertId(toUserId, "kullanıcı ID'si"); + if (String(fromUserId) === String(toUserId)) return { error: "Kendine para gönderemezsin." }; + return this.db.transaction(() => { + const fromBalance = this.getBalance(guildId, fromUserId); + const toBalance = this.getBalance(guildId, toUserId); + if (fromBalance < amount) return { error: "Yetersiz bakiye.", fromBalance, toBalance }; + if (!Number.isSafeInteger(toBalance + amount)) throw new RangeError("Hedef bakiye güvenli sayı sınırını aşıyor."); + this.db.set(this.moneyKey(guildId, fromUserId), fromBalance - amount); + this.db.set(this.moneyKey(guildId, toUserId), toBalance + amount); + return { amount, fromBalance: fromBalance - amount, toBalance: toBalance + amount }; + }); + } + + getItem(itemId) { + return this.items.get(String(itemId || "").trim().toLowerCase()) || null; + } + + getMarketCatalog() { + return Object.fromEntries(this.marketConfig.map((entry) => { + const item = this.getItem(entry.item); + return [entry.item, { id: entry.item, name: item.name, description: item.description, cost: entry.price, stock: entry.stock }]; + })); + } + + initializeMarket(guildId) { + this.assertId(guildId, "sunucu ID'si"); + for (const entry of this.marketConfig) { + const key = this.marketKey(guildId, entry.item); + if (!this.db.has(key)) this.db.set(key, { item: entry.item, price: entry.price, stock: entry.stock }); + } + } + + getMarket(guildId) { + this.initializeMarket(guildId); + return this.marketConfig.map((entry) => { + const item = this.getItem(entry.item); + const state = this.db.get(this.marketKey(guildId, entry.item), { price: entry.price, stock: entry.stock }); + return { ...item, id: entry.item, cost: Number(state.price), stock: Number(state.stock) }; + }); + } + + getMarketItem(guildId, itemId) { + this.initializeMarket(guildId); + const id = String(itemId || "").trim().toLowerCase(); + const configured = this.marketConfig.find((entry) => entry.item === id); + if (!configured) return null; + const item = this.getItem(id); + const state = this.db.get(this.marketKey(guildId, id), { price: configured.price, stock: configured.stock }); + return { ...item, id, cost: Number(state.price), stock: Number(state.stock) }; + } + + purchase(guildId, userId, item, quantity = 1) { + this.assertAmount(quantity); + if (!item || typeof item.id !== "string" || !Number.isSafeInteger(item.cost) || item.cost < 0) throw new TypeError("Geçersiz mağaza ürünü."); + const total = item.cost * quantity; + if (!Number.isSafeInteger(total)) throw new RangeError("Satın alma toplamı güvenli sayı sınırını aşıyor."); + return this.db.transaction(() => { + const marketItem = this.getMarketItem(guildId, item.id); + if (!marketItem) return { error: "Ürün markette bulunmuyor." }; + if (marketItem.stock < quantity) return { error: "Yetersiz stok.", stock: marketItem.stock }; + const balance = this.getBalance(guildId, userId); + if (balance < total) return { error: "Yetersiz bakiye.", after: balance }; + const state = this.db.get(this.marketKey(guildId, item.id)); + state.stock -= quantity; + this.db.set(this.marketKey(guildId, item.id), state); + this.inventory.add(guildId, userId, item.id, quantity, { source: "market" }); + this.db.set(this.moneyKey(guildId, userId), balance - total); + return { error: null, after: balance - total, item: marketItem, quantity, total }; + }); + } + + createListing(guildId, sellerId, itemId, quantity, unitPrice) { + this.assertId(guildId, "sunucu ID'si"); + this.assertId(sellerId, "satıcı ID'si"); + this.assertAmount(quantity); + this.assertAmount(unitPrice); + const item = this.getItem(itemId); + if (!item) return { error: "Bilinmeyen item." }; + return this.db.transaction(() => { + const available = this.inventory.count(guildId, sellerId, item.id); + if (available < quantity) return { error: "Envanterinde bu üründen yeterli miktar yok.", available }; + const removed = this.inventory.remove(guildId, sellerId, item.id, quantity); + if (!removed.removed) return { error: "Ürün envanterden alınamadı.", available: removed.available }; + const listingId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`.toLowerCase(); + const listing = { + id: listingId, + guildId: String(guildId), + sellerId: String(sellerId), + itemId: item.id, + itemName: item.name, + quantity, + unitPrice, + createdAt: Date.now() + }; + this.db.set(this.listingKey(guildId, listingId), listing); + return { error: null, listing }; + }); + } + + getListings(guildId) { + return this.db.startsWith(this.listingPrefix(guildId)) + .map(({ data }) => data) + .filter((listing) => listing && Number.isSafeInteger(listing.quantity) && listing.quantity > 0 && Number.isSafeInteger(listing.unitPrice) && listing.unitPrice > 0) + .sort((a, b) => b.createdAt - a.createdAt); + } + + getListing(guildId, listingId) { + const listing = this.db.get(this.listingKey(guildId, String(listingId || "")), null); + return listing && listing.quantity > 0 ? listing : null; + } + + buyListing(guildId, buyerId, listingId, quantity = 1) { + this.assertId(guildId, "sunucu ID'si"); + this.assertId(buyerId, "alıcı ID'si"); + this.assertAmount(quantity); + return this.db.transaction(() => { + const listing = this.getListing(guildId, listingId); + if (!listing) return { error: "Satış ilanı bulunamadı." }; + if (listing.sellerId === String(buyerId)) return { error: "Kendi ilanını satın alamazsın." }; + if (listing.quantity < quantity) return { error: "İlanda yeterli stok yok.", available: listing.quantity }; + const total = listing.unitPrice * quantity; + if (!Number.isSafeInteger(total)) throw new RangeError("Satın alma toplamı güvenli sayı sınırını aşıyor."); + const buyerBalance = this.getBalance(guildId, buyerId); + if (buyerBalance < total) return { error: "Yetersiz bakiye.", balance: buyerBalance, total }; + const sellerBalance = this.getBalance(guildId, listing.sellerId); + if (!Number.isSafeInteger(sellerBalance + total)) throw new RangeError("Satıcının bakiyesi güvenli sayı sınırını aşıyor."); + listing.quantity -= quantity; + if (listing.quantity === 0) this.db.delete(this.listingKey(guildId, listing.id)); + else this.db.set(this.listingKey(guildId, listing.id), listing); + this.db.set(this.moneyKey(guildId, buyerId), buyerBalance - total); + this.db.set(this.moneyKey(guildId, listing.sellerId), sellerBalance + total); + this.inventory.add(guildId, buyerId, listing.itemId, quantity, { source: "player-market" }); + return { error: null, listing, quantity, total, after: buyerBalance - total }; + }); + } + + getInventory(guildId, userId) { + return this.inventory.read(guildId, userId); + } + + getCooldown(guildId, userId, command, durationMs) { + if (!Number.isSafeInteger(durationMs) || durationMs <= 0) throw new RangeError("Geçersiz cooldown süresi."); + const last = Number(this.db.get(this.cooldownKey(guildId, userId, command), 0)); + const remainingMs = durationMs - (Date.now() - last); + return last > 0 && remainingMs > 0 ? { onCooldown: true, remainingMs } : { onCooldown: false, remainingMs: 0 }; + } + + setCooldown(guildId, userId, command) { + this.db.set(this.cooldownKey(guildId, userId, command), Date.now()); + } + + useCooldown(guildId, userId, command, durationMs) { + return this.db.transaction(() => { + const status = this.getCooldown(guildId, userId, command, durationMs); + if (!status.onCooldown) this.setCooldown(guildId, userId, command); + return status; + }); + } + + rollDrop(type) { + const chanceField = type === "daily" ? "dailyDropChance" : "weeklyDropChance"; + const entries = [...this.items.values()].map((item) => ({ item, chance: Number(item[chanceField] || 0) })).filter(({ chance }) => chance > 0); + let roll = Math.random() * 100; + for (const entry of entries) { + if (roll < entry.chance) return entry.item; + roll -= entry.chance; + } + return null; + } + + _claimCooldownReward(guildId, userId, command, durationMs, amount, dropType) { + const cooldown = this.getCooldown(guildId, userId, command, durationMs); + if (cooldown.onCooldown) return cooldown; + const result = this._addMoney(guildId, userId, amount); + const droppedItem = this.rollDrop(dropType); + if (droppedItem) this.inventory.add(guildId, userId, droppedItem.id, 1, { source: `${dropType}-drop` }); + this.setCooldown(guildId, userId, command); + return { ...result, droppedItem }; + } + + daily(guildId, userId, amount) { + this.assertAmount(amount); + return this.db.transaction(() => this._claimCooldownReward(guildId, userId, "daily", DAY, amount, "daily")); + } + + weekly(guildId, userId, amount) { + this.assertAmount(amount); + return this.db.transaction(() => this._claimCooldownReward(guildId, userId, "weekly", WEEK, amount, "weekly")); + } + + work(guildId, userId, amount, options = {}) { + const cooldownMs = Number.isSafeInteger(options.cooldown) && options.cooldown > 0 ? options.cooldown : 2_700_000; + this.assertAmount(amount); + return this.db.transaction(() => { + const cooldown = this.getCooldown(guildId, userId, "work", cooldownMs); + if (cooldown.onCooldown) return cooldown; + const result = this._addMoney(guildId, userId, amount); + this.setCooldown(guildId, userId, "work"); + const jobs = Array.isArray(options.jobs) && options.jobs.length ? options.jobs : ["Developer"]; + return { ...result, workedAs: jobs[Math.floor(Math.random() * jobs.length)] }; + }); + } + + randomEarning(guildId, userId, command, amount, options = {}) { + const cooldownMs = Number.isSafeInteger(options.cooldown) && options.cooldown > 0 ? options.cooldown : 60_000; + this.assertAmount(amount); + return this.db.transaction(() => { + const cooldown = this.getCooldown(guildId, userId, command, cooldownMs); + if (cooldown.onCooldown) return cooldown; + if (options.canLose && Math.random() < 0.2) { + this.setCooldown(guildId, userId, command); + return { onCooldown: false, lost: true, amount: 0, after: this.getBalance(guildId, userId) }; + } + const result = this._addMoney(guildId, userId, amount); + this.setCooldown(guildId, userId, command); + return { onCooldown: false, lost: false, ...result }; + }); + } + + rob(guildId, robberId, targetId, amount) { + return this.transfer(guildId, targetId, robberId, amount); + } + + leaderboard(guildId, limit = 15) { + const prefix = `money:${guildId}:`; + return this.db.startsWith(prefix) + .map(({ ID, data }) => ({ id: ID.slice(prefix.length), money: Number(data) })) + .filter((entry) => /^\d{17,20}$/.test(entry.id) && Number.isSafeInteger(entry.money) && entry.money >= 0) + .sort((a, b) => b.money - a.money || a.id.localeCompare(b.id)) + .slice(0, Math.max(1, Math.min(100, Number(limit) || 15))) + .map((entry, index) => ({ ...entry, position: index + 1 })); + } + + getPosition(guildId, userId) { + const balance = this.getBalance(guildId, userId); + return this.db.startsWith(`money:${guildId}:`).filter(({ ID, data }) => /^money:\d{17,20}:\d{17,20}$/.test(ID) && Number.isSafeInteger(Number(data)) && Number(data) > balance).length + 1; + } +} + +module.exports = EconomyManager; diff --git a/lib/i18n.js b/lib/i18n.js new file mode 100644 index 0000000..f94e455 --- /dev/null +++ b/lib/i18n.js @@ -0,0 +1,239 @@ +const locales = { + en: { + locale: "en-US", + commands: { + addmoney: { description: "Add money to a user." }, + bal: { description: "Show a user's balance." }, + beg: { description: "Try to earn a random amount by begging." }, + buy: { description: "Buy an item from the system market or a member listing." }, + daily: { description: "Claim your daily reward and possible item drop." }, + help: { description: "Show all available bot commands." }, + inventory: { description: "Show the items in your inventory." }, + lb: { description: "Show the server economy leaderboard." }, + ping: { description: "Show the bot latency." }, + prefix: { description: "Change the server prefix." }, + rob: { description: "Try to steal a random amount from another user." }, + search: { description: "Search for a random amount of money." }, + sell: { description: "Create a member shop listing from your inventory." }, + setmoney: { description: "Set a user's balance." }, + shop: { description: "Show the configured market and member shops." }, + transfer: { description: "Transfer money to another user." }, + weekly: { description: "Claim your weekly reward and possible item drop." }, + work: { description: "Work a random job and earn money." } + }, + errors: { + generic: "Something went wrong while running this command. Check the console.", + noUser: "Please provide a valid user.", + noAmount: "Please provide a valid positive amount.", + botEconomy: "Bot accounts do not have an economy balance.", + noPermission: "You need administrator or Manage Server permission to use this command.", + noPermissionRole: "You are not allowed to use this command.", + self: "You cannot target yourself.", + insufficient: "Insufficient balance.", + invalidProduct: "That item or listing does not exist.", + invalidItem: "That item is not defined in the configured item registry.", + invalidListing: "Use a valid item, positive quantity and positive unit price.", + notEnoughItems: "You only have **{available}** of this item in your inventory.", + ownListing: "You cannot buy your own listing.", + noMoneyToSteal: "That user has no money you can steal.", + selfRob: "You cannot rob yourself.", + botsDisabled: "Bot accounts cannot use this command.", + guildOnly: "This command can only be used in a server.", + unavailable: "This command is currently unavailable.", + invalidPrefix: "Please provide a prefix between 1 and 5 characters without spaces.", + samePrefix: "That prefix is already in use.", + cooldown: "You must wait **{time}** before using this command again." + }, + economy: { + balanceTitle: "Balance", + user: "User", + balance: "Balance", + rank: "Rank", + moneyAddedTitle: "Money Added", + added: "Added", + newBalance: "New Balance", + moneySetTitle: "Balance Updated", + newBalanceSet: "New Balance", + moneyRemovedTitle: "Money Removed", + removed: "Removed", + daily: "You received **{amount}** as your daily reward. Your new balance is **{balance}**.", + dailyCooldown: "You already claimed your daily reward. Come back in **{time}**.", + weekly: "You received **{amount}** as your weekly reward. Your new balance is **{balance}**.", + weeklyCooldown: "You already claimed your weekly reward. Come back in **{time}**.", + work: "You worked as a **{job}** and earned **{amount}**. Your new balance is **{balance}**.", + workCooldown: "You are tired. Come back in **{time}**.", + beg: "**{donor}** gave you **{amount}**. Your new balance is **{balance}**.", + begLost: "**{donor}:** Bad luck. You did not earn anything this time.", + begCooldown: "You must wait **{time}** before begging again.", + search: "You searched around and found **{amount}**. Your new balance is **{balance}**.", + searchCooldown: "You must wait **{time}** before searching again.", + purchase: "You bought **{item}** ×**{quantity}** for **{price}**. Remaining balance: **{balance}**.", + purchaseNeed: "You need **{price}** to buy this quantity, but you only have **{balance}**.", + purchaseStock: "Only **{stock}** of this item are currently in stock.", + inventoryEmpty: "Your inventory is empty.", + inventoryTitle: "{user} — Inventory", + inventoryItem: "Quantity: **{count}**\\nReference value: **{price}**", + shopTitle: "Community Shop", + shopItem: "Price: **{price}**", + listingCreated: "Your member shop listing is live: **{item}** ×**{quantity}** at **{price}** per unit. Listing ID: `{listing}`", + listingStock: "Only **{stock}** units remain in that member listing.", + listingPurchase: "You bought **{item}** ×**{quantity}** from a member for **{price}**. Remaining balance: **{balance}**.", + itemDrop: "You also found **{item}** in your reward drop.", + transfer: "You sent **{amount}** to <@{target}>. Your new balance is **{balance}**.", + robSuccess: "You stole **{amount}** from <@{target}>. Your new balance is **{balance}**.", + robCooldown: "You must wait **{time}** before attempting another robbery.", + robFail: [ + "Your robbery attempt failed.", + "The target noticed you and your plan failed.", + "That user was too careful. Better luck next time." + ], + leaderboardTitle: "Economy Leaderboard", + leaderboardEmpty: "There are no economy accounts on this server yet.", + leaderboardLine: "**#{position}** <@{id}> — **{money}**", + prefixUpdated: "Server prefix changed to **{prefix}**.", + helpTitle: "EconomyBot Commands", + helpDescription: "There are **{count}** commands. Current prefix: `{prefix}`", + helpFooter: "Requested by {user}", + pingTitle: "Pong!", + pingApi: "API Latency", + pingClient: "Client Latency", + adminMoney: "You changed <@{user}> by **{amount}**. New balance: **{balance}**.", + setMoney: "You set <@{user}>'s balance to **{balance}**." + } + }, + tr: { + locale: "tr-TR", + commands: { + addmoney: { description: "Bir kullanıcıya para ekler." }, + bal: { description: "Bir kullanıcının bakiyesini gösterir." }, + beg: { description: "Dilenerek rastgele miktarda para kazanmaya çalışır." }, + buy: { description: "Sistem marketinden veya bir oyuncu ilanından item satın alır." }, + daily: { description: "Günlük ödülünü ve olası item drop'unu alır." }, + help: { description: "Kullanılabilir tüm bot komutlarını gösterir." }, + inventory: { description: "Envanterindeki item'ları gösterir." }, + lb: { description: "Sunucunun ekonomi sıralamasını gösterir." }, + ping: { description: "Bot gecikmesini gösterir." }, + prefix: { description: "Sunucu prefix'ini değiştirir." }, + rob: { description: "Başka bir kullanıcıdan rastgele miktarda para çalmayı dener." }, + search: { description: "Rastgele miktarda para arar." }, + sell: { description: "Envanterindeki item ile oyuncu marketinde satış ilanı açar." }, + setmoney: { description: "Bir kullanıcının bakiyesini ayarlar." }, + shop: { description: "Yapılandırılmış marketi ve oyuncu mağazalarını gösterir." }, + transfer: { description: "Başka bir kullanıcıya para gönderir." }, + weekly: { description: "Haftalık ödülünü ve olası item drop'unu alır." }, + work: { description: "Rastgele bir iş yaparak para kazanır." } + }, + errors: { + generic: "Komut çalıştırılırken beklenmeyen bir hata oluştu. Konsolu kontrol edin.", + noUser: "Lütfen geçerli bir kullanıcı belirt.", + noAmount: "Lütfen geçerli ve pozitif bir miktar belirt.", + botEconomy: "Bot hesaplarının ekonomi bakiyesi bulunmaz.", + noPermission: "Bu komutu kullanmak için Yönetici veya Sunucuyu Yönet yetkisine sahip olmalısın.", + noPermissionRole: "Bu komutu kullanmaya yetkin bulunmuyor.", + self: "Kendini hedefleyemezsin.", + insufficient: "Yetersiz bakiye.", + invalidProduct: "Bu item veya ilan bulunamadı.", + invalidItem: "Bu item yapılandırılmış item registry içinde tanımlı değil.", + invalidListing: "Geçerli bir item, pozitif miktar ve pozitif birim fiyat kullanmalısın.", + notEnoughItems: "Envanterinde bu item'dan yalnızca **{available}** adet bulunuyor.", + ownListing: "Kendi ilanından alışveriş yapamazsın.", + noMoneyToSteal: "Bu kullanıcının çalınabilecek parası yok.", + selfRob: "Kendini soyamazsın.", + botsDisabled: "Bot hesapları bu komutu kullanamaz.", + guildOnly: "Bu komut yalnızca bir sunucuda kullanılabilir.", + unavailable: "Bu komut şu anda kullanılamıyor.", + invalidPrefix: "Lütfen boşluk içermeyen, 1-5 karakter uzunluğunda bir prefix belirt.", + samePrefix: "Bu prefix zaten kullanılıyor.", + cooldown: "Bu komutu tekrar kullanmak için **{time}** beklemelisin." + }, + economy: { + balanceTitle: "Bakiye", + user: "Kullanıcı", + balance: "Bakiye", + rank: "Sıralama", + moneyAddedTitle: "Para Eklendi", + added: "Eklenen", + newBalance: "Yeni Bakiye", + moneySetTitle: "Bakiye Güncellendi", + newBalanceSet: "Yeni Bakiye", + moneyRemovedTitle: "Para Çıkarıldı", + removed: "Çıkarılan", + daily: "Günlük ödül olarak **{amount}** kazandın. Yeni bakiyen **{balance}**.", + dailyCooldown: "Günlük ödülünü zaten aldın. Tekrar almak için **{time}** beklemelisin.", + weekly: "Haftalık ödül olarak **{amount}** kazandın. Yeni bakiyen **{balance}**.", + weeklyCooldown: "Haftalık ödülünü zaten aldın. Tekrar almak için **{time}** beklemelisin.", + work: "**{job}** olarak çalıştın ve **{amount}** kazandın. Yeni bakiyen **{balance}**.", + workCooldown: "Çalışmak için biraz dinlenmelisin. **{time}** sonra tekrar dene.", + beg: "**{donor}** sana **{amount}** verdi. Yeni bakiyen **{balance}**.", + begLost: "**{donor}:** Şansın yaver gitmedi; bu sefer para kazanamadın.", + begCooldown: "Tekrar dilenebilmek için **{time}** beklemelisin.", + search: "Etrafta arama yaptın ve **{amount}** buldun. Yeni bakiyen **{balance}**.", + searchCooldown: "Tekrar arama yapmak için **{time}** beklemelisin.", + purchase: "**{item}** ×**{quantity}** ürününü **{price}** karşılığında satın aldın. Kalan bakiyen **{balance}**.", + purchaseNeed: "Bu miktarı almak için **{price}** gerekiyor fakat bakiyen **{balance}**.", + purchaseStock: "Bu item'dan şu anda yalnızca **{stock}** adet stokta var.", + inventoryEmpty: "Envanterin boş.", + inventoryTitle: "{user} — Envanter", + inventoryItem: "Miktar: **{count}**\\nReferans değer: **{price}**", + shopTitle: "Topluluk Mağazası", + shopItem: "Fiyat: **{price}**", + listingCreated: "Oyuncu mağazandaki ilan açıldı: **{item}** ×**{quantity}**, birim fiyat **{price}**. İlan ID: `{listing}`", + listingStock: "Bu oyuncu ilanında yalnızca **{stock}** adet kaldı.", + listingPurchase: "Bir oyuncudan **{item}** ×**{quantity}** ürününü **{price}** karşılığında aldın. Kalan bakiyen **{balance}**.", + itemDrop: "Ayrıca ödül drop'undan **{item}** elde ettin.", + transfer: "<@{target}> kullanıcısına **{amount}** gönderdin. Yeni bakiyen **{balance}**.", + robSuccess: "<@{target}> kullanıcısından **{amount}** çaldın. Yeni bakiyen **{balance}**.", + robCooldown: "Tekrar soygun deneyebilmek için **{time}** beklemelisin.", + robFail: [ + "Soygun girişimin başarısız oldu.", + "Hedef seni fark etti ve planın bozuldu.", + "Soymaya çalıştığın kişi dikkatliymiş. Bu sefer olmadı." + ], + leaderboardTitle: "Ekonomi Sıralaması", + leaderboardEmpty: "Bu sunucuda henüz ekonomi hesabı bulunmuyor.", + leaderboardLine: "**#{position}** <@{id}> — **{money}**", + prefixUpdated: "Sunucu prefix'i **{prefix}** olarak değiştirildi.", + helpTitle: "EconomyBot Komutları", + helpDescription: "Toplam **{count}** komut bulunuyor. Güncel prefix: `{prefix}`", + helpFooter: "{user} tarafından istendi", + pingTitle: "Pong!", + pingApi: "API Gecikmesi", + pingClient: "İstemci Gecikmesi", + adminMoney: "<@{user}> kullanıcısının bakiyesine **{amount}** eklendi. Yeni bakiye: **{balance}**.", + setMoney: "<@{user}> kullanıcısının bakiyesi **{balance}** olarak ayarlandı." + } + } +}; + +function normalizeLanguage(language) { + const normalized = String(language || "en").toLowerCase(); + if (normalized === "tr" || normalized === "turkish" || normalized === "tr-tr") return "tr"; + return "en"; +} + +function createTranslator(language) { + const lang = normalizeLanguage(language); + const catalog = locales[lang]; + const translate = (key, variables = {}) => { + const parts = String(key).split("."); + let value = catalog; + for (const part of parts) value = value?.[part]; + if (Array.isArray(value)) value = value[Math.floor(Math.random() * value.length)]; + if (typeof value !== "string") return key; + return value.replace(/\{(\w+)\}/g, (_, name) => String(variables[name] ?? `{${name}}`)); + }; + translate.language = lang; + translate.locale = catalog.locale; + return translate; +} + +function getLanguage(config) { + return normalizeLanguage(config?.language); +} + +function commandDescription(commandName, language) { + const lang = normalizeLanguage(language); + return locales[lang].commands[commandName]?.description || locales.en.commands[commandName]?.description || commandName; +} + +module.exports = { locales, normalizeLanguage, createTranslator, getLanguage, commandDescription }; diff --git a/lib/inventory.js b/lib/inventory.js new file mode 100644 index 0000000..3597019 --- /dev/null +++ b/lib/inventory.js @@ -0,0 +1,78 @@ +class InventoryManager { + constructor(database, items) { + if (!database) throw new TypeError("InventoryManager bir veritabanı örneği gerektirir."); + this.db = database; + this.setRegistry(items); + } + + setRegistry(items) { + if (!Array.isArray(items)) throw new TypeError("Item registry bir dizi olmalı."); + this.registry = new Map(items.map((item) => [String(item.id).toLowerCase(), Object.freeze({ ...item })])); + } + + getItemRegistry() { + return Object.fromEntries([...this.registry.entries()].map(([id, item]) => [id, { ...item }])); + } + + getItem(itemId) { + return this.registry.get(String(itemId || "").trim().toLowerCase()) || null; + } + + key(guildId, userId) { + if (!/^\d{17,20}$/.test(String(guildId)) || !/^\d{17,20}$/.test(String(userId))) throw new TypeError("Geçersiz envanter kimliği."); + return `inventory:${guildId}:${userId}`; + } + + read(guildId, userId) { + const value = this.db.get(this.key(guildId, userId), []); + if (!Array.isArray(value)) throw new TypeError("Envanter verisi bozuk."); + return value; + } + + count(guildId, userId, itemId) { + const id = String(itemId).trim().toLowerCase(); + return this.read(guildId, userId).filter((item) => String(item.id).toLowerCase() === id).length; + } + + add(guildId, userId, itemId, quantity = 1, metadata = {}) { + const item = this.getItem(itemId); + if (!item) throw new Error(`Bilinmeyen item: ${itemId}`); + if (!Number.isSafeInteger(quantity) || quantity <= 0) throw new TypeError("Item miktarı pozitif bir tam sayı olmalı."); + const inventory = this.read(guildId, userId); + for (let index = 0; index < quantity; index += 1) { + inventory.push({ + id: item.id, + name: item.name, + description: item.description, + obtainedAt: Date.now(), + source: metadata.source || "system" + }); + } + this.db.set(this.key(guildId, userId), inventory); + return inventory; + } + + remove(guildId, userId, itemId, quantity = 1) { + const id = String(itemId).trim().toLowerCase(); + if (!Number.isSafeInteger(quantity) || quantity <= 0) throw new TypeError("Item miktarı pozitif bir tam sayı olmalı."); + const inventory = this.read(guildId, userId); + const matches = []; + for (let index = 0; index < inventory.length; index += 1) { + if (String(inventory[index].id).toLowerCase() === id) matches.push(index); + } + if (matches.length < quantity) return { removed: false, available: matches.length, inventory }; + const removeSet = new Set(matches.slice(0, quantity)); + const next = inventory.filter((_, index) => !removeSet.has(index)); + this.db.set(this.key(guildId, userId), next); + return { removed: true, available: matches.length, inventory: next }; + } + + transferWithinTransaction(guildId, fromUserId, toUserId, itemId, quantity = 1) { + const removed = this.remove(guildId, fromUserId, itemId, quantity); + if (!removed.removed) return removed; + this.add(guildId, toUserId, itemId, quantity, { source: "transfer" }); + return { removed: true, inventory: this.read(guildId, fromUserId) }; + } +} + +module.exports = InventoryManager; diff --git a/package.json b/package.json index fe3351e..5beaa20 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,24 @@ { - "name": "EconomyBot", - "version": "1.0.0", - "description": "Economy bot created with quick.eco package in discord.js", + "name": "economybot", + "version": "3.1.0", + "description": "Professional Discord economy bot with Node.js 20+, discord.js v14, SQLite persistence, English/Turkish localization, prefix and slash commands.", "main": "index.js", "scripts": { - "start": "node index.js" + "start": "node index.js", + "test": "node tests/test.js" }, - "author": "Zero", + "author": "Zero / LoiFragola", "license": "Nginx", - "keywords": [ - "discord", - "Easy", - "Economy", - "bot", - "discord.js", - "discordjs" - ], + "keywords": ["discord", "economy", "bot", "discordjs", "discord.js", "sqlite", "economy-bot"], "dependencies": { - "discord.js": "^12.5.3", - "quick.eco": "^2.0.3" + "better-sqlite3": "12.11.1", + "discord.js": "14.27.0" }, "engines": { - "node": "12.x" + "node": ">=20.0.0" }, "repository": { - "url": "https://github.com/ZeroDiscord/EconomyBot" + "type": "git", + "url": "https://github.com/LoiFragola/EconomyBot.git" } } diff --git a/tests/test.js b/tests/test.js new file mode 100644 index 0000000..c30cef4 --- /dev/null +++ b/tests/test.js @@ -0,0 +1,260 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { Collection, PermissionsBitField } = require("discord.js"); +const { KeyValueStore } = require("../lib/database"); +const EconomyManager = require("../lib/economy"); +const InventoryManager = require("../lib/inventory"); +const { createInteractionContext } = require("../lib/context"); +const { createTranslator } = require("../lib/i18n"); +const { isAdmin } = require("../lib/commandUtils"); + +const root = path.join(__dirname, ".."); +const commandFiles = fs.readdirSync(path.join(root, "commands")).filter((file) => file.endsWith(".js")).sort(); +assert.equal(commandFiles.length, 18, "Tam olarak 18 komut dosyası bulunmalı."); + +const commands = commandFiles.map((file) => ({ file, command: require(path.join(root, "commands", file)) })); +const commandMap = new Map(); +for (const { file, command } of commands) { + assert.equal(typeof command.execute, "function", `${file}: execute eksik.`); + assert.equal(typeof command.name, "string", `${file}: name eksik.`); + assert.equal(command.help?.name, command.name, `${file}: help.name farklı.`); + assert.ok(Array.isArray(command.aliases), `${file}: aliases dizi değil.`); + assert.equal(typeof command.data?.toJSON, "function", `${file}: slash builder eksik.`); + const data = command.data.toJSON(); + assert.equal(data.name, command.name); + assert.match(data.name, /^[a-z0-9_-]{1,32}$/); + assert.ok(data.description.length >= 1 && data.description.length <= 100); + assert.ok(!commandMap.has(command.name), `Yinelenen komut: ${command.name}`); + commandMap.set(command.name, command); +} +assert.equal(commandMap.size, 18); + +const aliasMap = new Map(); +for (const command of commandMap.values()) { + for (const alias of command.aliases) { + const normalized = alias.toLowerCase(); + assert.ok(typeof alias === "string" && alias.length > 0 && alias.length <= 32); + assert.ok(!commandMap.has(normalized), `Alias komut adıyla çakışıyor: ${alias}`); + assert.ok(!aliasMap.has(normalized), `Yinelenen alias: ${alias}`); + aliasMap.set(normalized, command.name); + } +} + +function user(id, tag) { + return { id, tag, username: tag.split("#")[0], bot: false, displayAvatarURL: () => "https://cdn.discordapp.com/embed/avatars/0.png" }; +} + +const tempPath = path.join(os.tmpdir(), `economybot-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite`); +const db = new KeyValueStore(tempPath); +const items = [ + { id: "apple", name: "Apple", description: "A test item.", dailyDropChance: 100, weeklyDropChance: 100 }, + { id: "gem", name: "Gem", description: "A rare test item.", dailyDropChance: 0, weeklyDropChance: 0 }, + { id: "book", name: "Book", description: "Another test item.", dailyDropChance: 0, weeklyDropChance: 0 } +]; +const market = [ + { item: "apple", price: 250, stock: 2 }, + { item: "gem", price: 1000, stock: 3 } +]; +const inventory = new InventoryManager(db, items); +const eco = new EconomyManager(db, inventory, items, market); +const guildId = "123456789012345678"; +const adminId = "123456789012345679"; +const sellerId = "123456789012345680"; +const buyerId = "123456789012345681"; +const botId = "123456789012345682"; +const ADMIN_ROLE_ID = "999999999999999999"; + +const client = { + config: { prefix: "!", language: "en", admins: [], adminRoles: [ADMIN_ROLE_ID], adminRoleNames: [], countChannel: "" }, + db, + inventory, + eco, + commands: new Collection(), + aliases: new Collection(aliasMap), + users: { cache: new Collection(), fetch: async (id) => client.users.cache.get(id) }, + user: user(botId, "EconomyBot#0001"), + ws: { ping: 42 } +}; +for (const command of commandMap.values()) client.commands.set(command.name, command); +for (const id of [adminId, sellerId, buyerId, botId]) client.users.cache.set(id, user(id, `User${id.slice(-3)}#0001`)); +eco.setMoney(guildId, adminId, 20_000); +eco.setMoney(guildId, sellerId, 1_000); +eco.setMoney(guildId, buyerId, 5_000); + +function makeContext(userId, options = {}, language = client.config.language, memberMode = "collection", args = []) { + const currentUser = client.users.cache.get(userId) || user(userId, `User${String(userId).slice(-3)}#0001`); + const roleCache = new Collection(); + roleCache.set(ADMIN_ROLE_ID, { id: ADMIN_ROLE_ID, name: "Economy Admin" }); + const permissions = new PermissionsBitField(); + const member = memberMode === "array" ? { permissions, roles: [ADMIN_ROLE_ID] } : { permissions, roles: { cache: roleCache } }; + const interaction = { + createdTimestamp: Date.now(), + user: currentUser, + guildId, + guild: { id: guildId, name: "Test Guild", iconURL: () => null }, + member, + options: { + getUser: (name) => (options[name] && typeof options[name] === "object" ? options[name] : null), + getInteger: (name) => options[name] ?? null, + getString: (name) => options[name] ?? null, + getMember: () => null, + get: (name) => (options[name] === undefined ? null : { value: options[name] }) + }, + memberPermissions: permissions + }; + const replies = []; + const ctx = createInteractionContext(interaction, { ...client, config: { ...client.config, language } }); + ctx.member = member; + ctx.reply = async (payload) => { replies.push(payload); return payload; }; + ctx.args = args; + return { ctx, replies }; +} + +async function runSlash(name, userId = adminId, options = {}, language = client.config.language) { + const command = commandMap.get(name); + const { ctx, replies } = makeContext(userId, options, language); + await command.execute(ctx); + assert.ok(replies.length > 0, `/${name} yanıt üretmedi.`); + return replies.at(-1); +} + +(async () => { + assert.equal(db.connection.open, true); + + assert.equal(eco.getMarket(guildId).find((item) => item.id === "apple").stock, 2); + const purchase = eco.purchase(guildId, adminId, eco.getMarketItem(guildId, "apple"), 2); + assert.equal(purchase.error, null); + assert.equal(eco.getMarketItem(guildId, "apple").stock, 0); + assert.equal(inventory.count(guildId, adminId, "apple"), 2); + const soldOut = eco.purchase(guildId, adminId, eco.getMarketItem(guildId, "apple"), 1); + assert.equal(soldOut.error, "Yetersiz stok."); + + const transfer = eco.transfer(guildId, adminId, sellerId, 500); + assert.equal(transfer.fromBalance, 19_000); + assert.equal(eco.transfer(guildId, adminId, adminId, 1).error, "Kendine para gönderemezsin."); + assert.equal(eco.transfer(guildId, adminId, buyerId, 999_999).error, "Yetersiz bakiye."); + + const listing = eco.createListing(guildId, adminId, "apple", 1, 700); + assert.equal(listing.error, null); + assert.equal(inventory.count(guildId, adminId, "apple"), 1); + const listingPurchase = eco.buyListing(guildId, buyerId, listing.listing.id, 1); + assert.equal(listingPurchase.error, null); + assert.equal(inventory.count(guildId, buyerId, "apple"), 1); + assert.equal(eco.getListings(guildId).length, 0); + assert.equal(eco.getBalance(guildId, adminId), 19_700); + assert.equal(eco.getBalance(guildId, buyerId), 4_300); + + const failedListing = eco.createListing(guildId, sellerId, "gem", 1, 2000); + assert.equal(failedListing.error, "Envanterinde bu üründen yeterli miktar yok."); + assert.equal(eco.createListing(guildId, sellerId, "not-configured", 1, 10).error, "Bilinmeyen item."); + + const cooldown1 = eco.useCooldown(guildId, adminId, "test", 60_000); + const cooldown2 = eco.useCooldown(guildId, adminId, "test", 60_000); + assert.equal(cooldown1.onCooldown, false); + assert.equal(cooldown2.onCooldown, true); + assert.ok(cooldown2.remainingMs > 0); + + const dailyUser = "123456789012345683"; + const daily = eco.daily(guildId, dailyUser, 100); + assert.equal(daily.droppedItem.id, "apple"); + assert.equal(inventory.count(guildId, dailyUser, "apple"), 1); + const weeklyUser = "123456789012345684"; + const weekly = eco.weekly(guildId, weeklyUser, 500); + assert.equal(weekly.droppedItem.id, "apple"); + assert.equal(inventory.count(guildId, weeklyUser, "apple"), 1); + + const adminCtx = makeContext(adminId).ctx; + assert.equal(isAdmin(adminCtx), true); + const arrayRoleCtx = makeContext(adminId, {}, "en", "array").ctx; + assert.equal(isAdmin(arrayRoleCtx), true); + client.config.adminRoles = []; + adminCtx.member.permissions.add(PermissionsBitField.Flags.Administrator); + assert.equal(isAdmin(adminCtx), true); + adminCtx.member.permissions.remove(PermissionsBitField.Flags.Administrator); + client.config.adminRoles = [ADMIN_ROLE_ID]; + + await runSlash("addmoney", adminId, { kullanici: client.users.cache.get(sellerId), miktar: 100 }, "en"); + await runSlash("bal"); + await runSlash("beg"); + await runSlash("buy", adminId, { urun: "gem", miktar: 1 }); + await runSlash("daily", dailyUser); + await runSlash("help"); + await runSlash("inventory", dailyUser); + await runSlash("lb"); + await runSlash("ping"); + await runSlash("prefix", adminId, { yeni_prefix: "$" }); + await runSlash("rob", adminId, { kullanici: client.users.cache.get(sellerId) }); + await runSlash("search", sellerId); + await runSlash("sell", adminId, { urun: "apple", miktar: 1, fiyat: 900 }); + await runSlash("setmoney", adminId, { kullanici: client.users.cache.get(sellerId), miktar: 2500 }); + await runSlash("shop"); + await runSlash("transfer", adminId, { kullanici: client.users.cache.get(sellerId), miktar: 100 }); + await runSlash("weekly", weeklyUser); + await runSlash("work"); + assert.equal(db.getPrefix(guildId, "!"), "$"); + + const en = createTranslator("en"); + const tr = createTranslator("tr"); + assert.equal(en("economy.balanceTitle"), "Balance"); + assert.equal(tr("economy.balanceTitle"), "Bakiye"); + const enHelp = await runSlash("help", adminId, {}, "en"); + const trHelp = await runSlash("help", adminId, {}, "tr"); + assert.match(enHelp.embeds?.[0]?.data?.title || "", /EconomyBot Commands/); + assert.match(trHelp.embeds?.[0]?.data?.title || "", /EconomyBot Komutları/); + + const originalRoles = client.config.adminRoles; + client.config.adminRoles = []; + const unauthorized = await runSlash("addmoney", sellerId, { kullanici: client.users.cache.get(adminId), miktar: 100 }, "en"); + assert.equal(unauthorized.ephemeral, true); + client.config.adminRoles = originalRoles; + + const eventReplies = []; + const interaction = { + commandName: "ping", + isChatInputCommand: () => true, + inGuild: () => true, + guild: { id: guildId, name: "Test Guild" }, + guildId, + user: client.users.cache.get(adminId), + member: { permissions: new PermissionsBitField(), roles: [ADMIN_ROLE_ID] }, + memberPermissions: new PermissionsBitField(), + options: { get: () => null, getUser: () => null, getInteger: () => null, getString: () => null, getMember: () => null }, + reply: async (payload) => { eventReplies.push(payload); return payload; }, + followUp: async (payload) => { eventReplies.push(payload); return payload; }, + get replied() { return eventReplies.length > 0; }, + get deferred() { return false; } + }; + await require("../events/interactionCreate")(client, interaction); + assert.ok(eventReplies.length > 0, "interactionCreate /ping yanıt üretmedi."); + + const prefixReplies = []; + const prefixMessage = { + guild: { id: guildId }, + inGuild: () => true, + author: client.users.cache.get(adminId), + content: "$sat apple 1 700", + createdTimestamp: Date.now(), + channel: { id: "not-counter" }, + member: { permissions: new PermissionsBitField(), roles: { cache: new Collection([[ADMIN_ROLE_ID, { id: ADMIN_ROLE_ID, name: "Economy Admin" }]]) } }, + mentions: { users: { first: () => null }, members: { first: () => null } }, + reply: async (payload) => { prefixReplies.push(payload); return payload; } + }; + await require("../events/messageCreate")(client, prefixMessage); + assert.ok(prefixReplies.length > 0, "Prefix sell aliası gerçek messageCreate akışında yanıt üretmedi."); + + db.close(); + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(`${tempPath}${suffix}`, { force: true }); + assert.equal(db.connection, null); + db.get("after-close", "reopened"); + assert.equal(db.connection.open, true); + db.close(); + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(`${tempPath}${suffix}`, { force: true }); + console.log("TÜM TESTLER BAŞARILI: 18 slash komutu, config item registry, persistent stock, member marketplace, inventory transfers, daily/weekly drops, locale, permissions, events ve DB lifecycle doğrulandı."); +})().catch((error) => { + try { db.close(); } catch {} + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(`${tempPath}${suffix}`, { force: true }); + console.error(error); + process.exitCode = 1; +});