commit e1415fc5acfb325a40b228baf93481656801eb13 Author: AlacrisDevs Date: Fri Mar 20 17:35:35 2026 +0200 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae4e597 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Discord bot token (from https://discord.com/developers/applications) +DISCORD_TOKEN=your-bot-token-here + +# Google Sheets spreadsheet ID (the long string in the sheet URL) +SHEET_ID=your-google-sheet-id-here + +# Path to Google service account credentials JSON +GOOGLE_CREDS_PATH=credentials.json + +# Guild (server) ID - right-click your server with dev mode on +GUILD_ID=your-guild-id-here + +# Channel ID where birthday announcements are posted (bot sends @here on the birthday at 09:00 Tallinn time) +BIRTHDAY_CHANNEL_ID=your-channel-id-here + +# How many days before a birthday the on-join check counts as "coming up" +BIRTHDAY_WINDOW_DAYS=7 + +# PocketBase backend (https://pocketbase.io) +PB_URL=http://127.0.0.1:8090 +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=your-pb-admin-password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80b6677 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +credentials.json +__pycache__/ +*.pyc +.venv/ +venv/ +data/restart_channel.json +data/economy.json +pocketbase.exe +pocketbase +pb_data/ +pb_migrations/ diff --git a/DEV_NOTES.md b/DEV_NOTES.md new file mode 100644 index 0000000..27470de --- /dev/null +++ b/DEV_NOTES.md @@ -0,0 +1,195 @@ +# TipiLAN Bot - Developer Reference + +## File Structure + +| File | Purpose | +|---|---| +| `bot.py` | All Discord commands, views (UI components), event handlers, reminder system | +| `economy.py` | All economy logic, data model, constants (SHOP, COOLDOWNS, LEVEL_ROLES, etc.) | +| `pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection | +| `strings.py` | **Single source of truth for all user-facing text.** Edit here to change any message. | +| `sheets.py` | Google Sheets integration (member sync) | +| `member_sync.py` | Birthday/member sync background task | +| `config.py` | Environment variables (TOKEN, GUILD_ID, PB_URL, etc.) | +| `migrate_to_pb.py` | One-time utility: migrate `data/economy.json` → PocketBase | +| `add_stats_fields.py` | Schema migration: add new fields to `economy_users` PocketBase collection | + +--- + +## Adding a New Economy Command + +Checklist - do all of these, in order: + +1. **`economy.py`** - add the `do_` async function with cooldown check, logic, `_commit`, and `_txn` logging +2. **`economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one +3. **`economy.py`** - add the EXP reward to `EXP_REWARDS` dict +3a. **PocketBase** - if the function stores new fields, add them as columns via `python scripts/add_stats_fields.py` (or manually in the PB admin UI at `http://127.0.0.1:8090/_/`). Fields not in the PB schema are silently dropped on PATCH. +4. **`strings.py` `CMD`** - add the slash command description +5. **`strings.py` `OPT`** - add any parameter descriptions +6. **`strings.py` `TITLE`** - add embed title(s) for success/fail states +7. **`strings.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`) +8. **`strings.py` `CD_MSG`** - add cooldown message if command has a cooldown +9. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed +10. **`bot.py`** - implement the `cmd_` function, handle all `res["reason"]` cases +11. **`bot.py`** - call `_maybe_remind` if the command has a cooldown and reminders make sense +12. **`bot.py`** - call `_award_exp(interaction, economy.EXP_REWARDS[""])` on success +13. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one +14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch + +--- + +## Adding a New Shop Item + +Checklist: + +1. **`economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}` +2. **`economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3) +3. **`economy.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≥10) or T3 (≥20) +4. **`strings.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect) +5. **`strings.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost) +6. If the item modifies a cooldown: + - **`economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_` function + - **`bot.py` `_maybe_remind`** - add `elif cmd == "" and "" in items:` branch with the new delay + - **`bot.py` `cmd_cooldowns`** - add the item annotation to the relevant status line + +--- + +## Adding a New Level Role + +1. **`economy.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first) +2. **`bot.py` `_ensure_level_role`** - no changes needed (uses `LEVEL_ROLES` dynamically) +3. Run **`/economysetup`** in the server to create the role and set its position + +--- + +## Adding a New Admin Command + +1. **`strings.py` `CMD`** - add `"[Admin] ..."` description +2. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry +3. **`bot.py`** - add `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()` + +--- + +## Economy System Design + +### Storage + +All economy state is stored in **PocketBase** (`economy_users` collection). `pb_client.py` owns all reads/writes. Each `do_*` function in `economy.py` calls `get_user()` → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase. + +### Currency & Income Sources + +| Command | Cooldown | Base Earn | Notes | +|---|---|---|---| +| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop | +| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook | +| `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur | +| `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail | +| `/rob` | 2h | 10–25% of target | 45% success (60% w/ jellyfin); fail = fine; cannot rob TipiBOT | +| `/heist` | 4h personal + 1h global | 20–55% of house / n players | solo allowed, max 8; 35%+5%/player success (cap 65%); fail = 3h jail + ~15% balance fine | +| `/slots` | - | varies | pair=+0.5× bet; triple tiered: heart×4, fire×5, troll×7, cry×10, skull×15, karikas×25 (jackpot); ×1.5 w/ monitor_360; miss=lose bet; house edge ~5% | +| `/roulette` | - | 2× red/black, 14× green | 1/37 green chance | +| `/blackjack` | - | 1:1 win, 3:2 BJ, 2:1 double | Dealer stands on 17+; double down on first action only | + +### "all" Keyword +Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` to mean the user's full current balance. Parsed by `_parse_amount(value, balance)` in `bot.py`. + +### Daily Streak Multipliers +- 1-2 days: ×1.0 (150⬡) +- 3-6 days: ×1.5 (225⬡) +- 7-13 days: ×2.0 (300⬡) +- 14+ days: ×3.0 (450⬡) +- `karikas` item: streak survives missed days + +### Jail +- Normal duration: 30 minutes (`JAIL_DURATION`) +- Heist fail duration: 3 hours (`HEIST_JAIL`) + fine 15% of balance (min 150⨡, max 1000⨡) +- `gaming_tool`: prevents jail on crime fail +- `/jailbreak`: 3 dice rolls, need doubles to escape free. On fail - bail = 20-30% of balance, min 350⬡. If balance < 350⬡, player stays jailed until timer. + +### EXP Rewards (from `EXP_REWARDS` in economy.py) +EXP is awarded on every successful command use. Level formula: `floor(sqrt(exp / 10))`, so Level 5 = 250 EXP, Level 10 = 1000, Level 20 = 4000, Level 30 = 9000. + +--- + +## Role Hierarchy (Discord) + +Order top to bottom in server roles: + +``` +[Bot managed role] ← bot's own role, always at top of our stack +ECONOMY ← given to everyone who uses any economy command +TipiLEGEND ← level 30+ +TipiCHAD ← level 20+ +TipiHUSTLER ← level 10+ +TipiGRINDER ← level 5+ +TipiNOOB ← level 1+ +``` + +Run `/economysetup` to auto-create all roles and set their positions. The command is idempotent - safe to run multiple times. + +Role assignment: +- **ECONOMY** role: given automatically on first EXP award (i.e. first successful economy command) +- **Level roles**: given/swapped automatically on level-up; synced on `/rank` + +--- + +## Shop Tiers & Level Requirements + +| Tier | Level Required | Items | +|---|---|---| +| T1 | 0 (any) | gaming_hiir, hiirematt, korvaklapid, lan_pass, anticheat, energiajook, gaming_laptop | +| T2 | 10 | reguleeritav_laud, jellyfin, mikrofon, klaviatuur, monitor, cat6 | +| T3 | 20 | monitor_360, karikas, gaming_tool | + +Shop display is sorted by cost (ascending) within each tier. +The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds. + +--- + +## strings.py Organisation + +| Section | Dict | Usage in bot.py | +|---|---|---| +| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions | +| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` | +| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` | +| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` | +| Banned message | `MSG_BANNED` | All banned checks | +| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown | +| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` | +| Embed titles | `TITLE["key"]` | `discord.Embed(title=S.TITLE["key"])` | +| Error messages | `ERR["key"]` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts | +| Cooldown messages | `CD_MSG["cmd"].format(ts=_cd_ts(...))` | Cooldown responses | +| Shop UI | `SHOP_UI["key"]` | `_shop_embed` | +| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.SHOP[key]["description"]` | + +--- + +## Constants Location Quick-Reference + +| Constant | File | Description | +|---|---|---| +| `SHOP` | `economy.py` | All shop items (name, emoji, cost, description) | +| `SHOP_TIERS` | `economy.py` | Which items are in T1/T2/T3 | +| `SHOP_LEVEL_REQ` | `economy.py` | Min level per item | +| `COOLDOWNS` | `economy.py` | Base cooldown per command | +| `JAIL_DURATION` | `economy.py` | How long jail lasts | +| `LEVEL_ROLES` | `economy.py` | `[(min_level, "RoleName"), ...]` highest first | +| `ECONOMY_ROLE` | `economy.py` | Name of the base economy participation role | +| `EXP_REWARDS` | `economy.py` | EXP per command | +| `HOUSE_ID` | `economy.py` | Bot's user ID (house account for /rob) | +| `MIN_BAIL` | `economy.py` | Minimum bail payment (350⬡) | +| `COIN` | `economy.py` | The coin emoji string | + +--- + +## Balance Notes (as of current version) + +- **Beg** is most efficient for active players (3min cooldown + 2× multiplier w/ `klaviatuur` = high ⬡/hr) +- **Work** is best for passive players (1h cooldown, fire and forget) +- **Crime** is high risk/reward - best with `cat6` + `mikrofon` +- **`lan_pass`** (1200⬡) doubles daily - good long-term investment +- **`gaming_laptop`** (1500⬡) 5% interest, capped 500⬡/day - snowballs with large balance +- `anticheat` is consumable (2 uses) - only item that can be re-bought +- `karikas` (T3) is the only item that preserves a daily streak across missed days +- `reguleeritav_laud` (T2) stacks with `gaming_hiir`: combined ×1.5 × ×1.25 = ×1.875 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..e3b8465 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,17 @@ +The MIT License (MIT) +Copyright (c) 2022 - present, Gani Georgiev + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ee8a51 --- /dev/null +++ b/README.md @@ -0,0 +1,429 @@ +# TipiLAN Bot + +Discord bot for the TipiLAN community. Manages member roles and nicknames via Google Sheets, announces birthdays, and runs the TipiCOIN economy. + +--- + +## Table of Contents + +1. [Setup](#setup) +2. [Member Management](#member-management) +3. [Admin Commands](#admin-commands) +4. [Birthday System](#birthday-system) +5. [TipiCOIN Economy](#tipicoin-economy) +6. [Project Structure](#project-structure) + +--- + +## Setup + +### 1. Discord Application + +- **Bot token** - Settings → Bot → Token → Copy +- **Server Members Intent** must be **ON** - Settings → Bot → Privileged Gateway Intents +- Bot invite scopes: `bot` + `applications.commands` +- Required permissions: Manage Roles, Manage Nicknames, View Channels, Send Messages, Embed Links, Read Message History + +> Permissions integer: `402738176` + +### 2. Google Service Account + +1. [Google Cloud Console](https://console.cloud.google.com/) → create/select project +2. Enable **Google Sheets API** and **Google Drive API** +3. Credentials → Create Credentials → Service Account +4. Download JSON key → save as `credentials.json` in the project root +5. Share your Google Sheet with the service account `client_email` - give **Editor** access + +### 3. Google Sheet Format + +Row 1 = headers (exact names). Row 2 = formula/stats row (skipped by bot). Data starts row 3. + +| Column | What the bot does with it | +|---|---| +| **Nimi** | Sets Discord nickname: first name + last initial (`Mari-Liis Tamm` → `Mari-Liis T`) | +| **Organisatsioon** | Maps to a Discord role (comma-separated for multiple) | +| **Meil** | Read-only | +| **Discord** | Username used for initial matching | +| **User ID** | Bot writes numeric Discord ID here once matched | +| **Sünnipäev** | Birthday - accepts `DD/MM/YYYY`, `YYYY-MM-DD`, `MM-DD`. Years outside 1920-now ignored | +| **Telefon** | Read-only | +| **Valdkond** | Maps to a Discord role (comma-separated) | +| **Roll** | Maps to a Discord role (comma-separated) | +| **Discordis synced?** | Bot writes `TRUE`/`FALSE` checkbox after each sync | +| **Groupi lisatud?** | Managed externally | + +**Empty cell values** - the bot treats blank cells, `"-"`, `"x"`, `"n/a"`, `"none"`, `"ei"` as empty/skipped. + +**Role name mapping** - some sheet values map to different Discord role names. Trailing punctuation (`.`, `,`, `;` etc.) is stripped from sheet values before lookup, so `"Messiala."` correctly matches the `Messiala` Discord role: + +| Sheet value | Discord role | +|---|---| +| `Juht` | `Tiimijuht` | +| `Admin` | `+` | + +**Base roles** - two role IDs in `config.py → BASE_ROLE_IDS` are added to every synced member automatically. + +### 4. PocketBase (Economy Database) + +The economy system stores all player data in [PocketBase](https://pocketbase.io/). + +1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root. +2. Start PocketBase: `.\pocketbase.exe serve` +3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account. +4. Create a collection named `economy_users` - see `docs/POCKETBASE_SETUP.md` for the full schema. +5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`. +6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py` + +> PocketBase must be running before the bot starts. `pocketbase.exe` and `pb_data/` are gitignored. + +### 5. Environment Variables + +```bash +# Windows +copy .env.example .env + +# macOS/Linux +cp .env.example .env +``` + +| Variable | Description | +|---|---| +| `DISCORD_TOKEN` | Bot token from Discord Developer Portal | +| `SHEET_ID` | ID from the Google Sheet URL | +| `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) | +| `GUILD_ID` | Right-click server → Copy Server ID (Developer Mode on) | +| `BIRTHDAY_CHANNEL_ID` | Channel for birthday `@here` pings | +| `BIRTHDAY_WINDOW_DAYS` | Days before birthday that on-join check flags it (default: 7) | +| `PB_URL` | PocketBase base URL (default: `http://127.0.0.1:8090`) | +| `PB_ADMIN_EMAIL` | PocketBase superuser e-mail | +| `PB_ADMIN_PASSWORD` | PocketBase superuser password | + +### 6. Install & Run + +```bash +python -m venv .venv +.venv\Scripts\activate # Windows +# source .venv/bin/activate # macOS/Linux + +pip install -r requirements.txt + +# Terminal 1 - keep running +.\pocketbase.exe serve + +# Terminal 2 +python bot.py +``` + +--- + +## Member Management + +### On member join +- Bot looks the member up in the sheet by **Discord username** +- If found → sets nickname, assigns roles, writes back User ID, marks synced +- If **not found** → creates a new sheet row with their `Discord` username and `User ID` pre-filled. Admin fills in the rest, then runs `/check` + +### Matching logic +1. Match by **User ID** (numeric, reliable - IDs never change) +2. Fall back to **Discord username** (case-insensitive) if no ID yet +3. Once matched by username, the bot writes the ID back so future matches are by ID + +### Nickname format +`Nimi` column → first name + last name initial. Hyphenated first names preserved. + +| Nimi | Nickname | +|---|---| +| Mari Tamm | Mari T | +| Mari-Liis Tamm | Mari-Liis T | +| Jaan | Jaan | + +### Synced status +A member is marked `Discordis synced? = TRUE` when their sync completes with no errors. +Admins (bot lacks permission to modify them) are silently skipped and still marked synced. + +--- + +## Admin Commands + +> These commands are hidden from users who don't have the required permission. Override per-role in **Server Settings → Integrations → Bot**. + +| Command | Permission | What it does | +|---|---|---| +| `/check` | Manage Roles | Refreshes sheet data, backfills missing User IDs, syncs nicknames + roles for every member, reports stats | +| `/member @user` | Manage Roles | Shows a member's full sheet data + calculated age | +| `/sync` | Manage Guild | Re-registers slash commands with Discord | +| `/restart` | Manage Guild | Gracefully restarts the bot process; posts ✅ in the same channel when back up | +| `/shutdown` | Manage Guild | Shuts the bot down cleanly without restarting | +| `/pause` | Manage Guild | Toggles maintenance mode — blocks all non-admin commands; calling again unpauses | +| `/send #channel message` | Manage Guild | Sends a message to any channel as the bot | +| `/status` | Manage Guild | Bot uptime, RAM, CPU, latency, cache stats, economy user count | +| `/admincoins @user ` | Manage Guild | Give (positive) or take (negative) TipiCOINi. Balance floored at 0. User gets a DM with reason. | +| `/adminjail @user ` | Manage Guild | Manually jail a user for N minutes. User gets a DM. | +| `/adminunjail @user` | Manage Guild | Release a user from jail immediately. | +| `/adminban @user ` | Manage Guild | Ban a user from all economy commands. User gets a DM. | +| `/adminunban @user` | Manage Guild | Lift an economy ban. | +| `/adminreset @user ` | Manage Guild | Wipe a user's balance, items, and streak to zero. User gets a DM. | +| `/adminview @user` | Manage Guild | Inspect a user's full economy profile: balance, streak, items, jail status, ban status. | + +### `/check` output example +``` +🔑 Täideti 3 puuduvat kasutaja ID-d. +✅ Korras: 54 +🔧 Parandatud: 3 +❓ Ei leitud: 1 +⚠️ Vead: 2 + +Üksikasjad: +🔧 Mari T: +rollid: TipiSÕBER +⚠️ Rolli 'Messiala' ei leitud serverist + +📊 Tabeli statistika - 58 liiget +... +``` + +--- + +## Birthday System + +### Daily announcement +Every day at **09:00 Tallinn time** the bot checks all sheet rows and pings `@here` in `BIRTHDAY_CHANNEL_ID` for anyone whose birthday is today. + +**Duplicate prevention** - announcements are logged to `birthday_sent.json` keyed by date. Bot restart on a birthday day does **not** re-ping. Log is auto-cleaned after 2 days. + +### `/birthdays` +Paginated embed with **12 pages** - one per calendar month. Opens on the **current month**. Navigate with ◀/▶. Each entry shows: +- Member mention (if their User ID is known) or name +- Birthday date +- Days until next birthday (or 🎉 if today) + +### On member join +If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a birthday announcement is sent. + +--- + +## TipiCOIN Economy + +All economy data is stored in **PocketBase** (`economy_users` collection - see `pb_client.py`). The currency is **TipiCOIN** (⬡), displayed as a custom Discord emoji configured in `economy.py → COIN`. + +--- + +### House account + +The bot has its own TipiCOIN balance (the "house"). Coins flow **into** the house when players lose: + +- `/roulette` - lost bets +- `/slots` - missed bets +- `/blackjack` - lost bets +- `/rps` - lost bets (vs bot or PvP) +- `/crime` - failure fines +- `/rob` - failure fines and anticheat counter-fines + +The house is listed at **#0** on the leaderboard. Players can attempt to rob it via `/rob @TipiBOT` with special jackpot odds (35% success, 5–40% of the house balance). + +--- + +### Earning coins + +| Command | Cooldown | Base payout | Notes | +|---|---|---|---| +| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h. LAN Pilet doubles the reward. Botikoobas adds 5% interest on your balance (capped at 500 ⬡/day). | +| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. | +| `/beg` | 5min | 10–40 ⬡ | XL hiirematt reduces cooldown to 3min. Mehhaaniline klaviatuur multiplies earnings ×2. | +| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with CAT6). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Mänguritool skips jail on fail. | + +### Daily streak + +The streak increments each time you claim `/daily` within the cooldown window. Missing a day resets it to 1 **unless** you own the TipiLAN trofee item. + +| Streak | Multiplier | Payout (base) | +|---|---|---| +| 1–2 days | ×1.0 | 150 ⬡ | +| 3–6 days | ×1.5 | 225 ⬡ | +| 7–13 days | ×2.0 | 300 ⬡ | +| 14+ days | ×3.0 | 450 ⬡ | + +> With LAN Pilet (×2 daily) and a 14-day streak (×3.0) the base payout reaches **900 ⬡**. Add Botikoobas 5% interest on top. + +--- + +### EXP & levels + +Every successful economy action awards EXP: + +| Action | EXP | +|---|---| +| `/daily` claimed | +50 | +| `/work` completed | +25 | +| `/crime` success | +15 | +| `/rob` success | +15 | +| Gambling win (`/roulette`, `/slots`, `/blackjack`) | Scaled by bet: <10⬡ = 0, 10–99⬡ = +5, 100–999⬡ = +10, 1 000–9 999⬡ = +15, 10 000–99 999⬡ = +20, 100 000+⬡ = +25 | +| `/beg` completed | +5 | + +**Level formula:** `level = floor(√(total_exp ÷ 10))` + +| Level | EXP required | Milestone | +|---|---|---| +| 1 | 10 | TipiNOOB role | +| 5 | 250 | TipiGRINDER role | +| 10 | 1 000 | TipiHUSTLER role · **T2 shop unlocks** | +| 20 | 4 000 | TipiCHAD role · **T3 shop unlocks** | +| 30 | 9 000 | TipiLEGEND role | + +Use `/rank` to see your current EXP, level, progress bar to the next level, and leaderboard position. + +### Level roles + +Roles are assigned automatically on level-up and re-synced when you run `/rank`. + +| Role | Min level | +|---|---| +| TipiNOOB | 1 | +| TipiGRINDER | 5 | +| TipiHUSTLER | 10 | +| TipiCHAD | 20 | +| TipiLEGEND | 30 | + +The **ECONOMY** role is granted on your first EXP award (i.e. first successful economy command). Run `/economysetup` (admin) once to create all roles and position them correctly below the bot's own role. + +--- + +### Gambling + +| Command | Notes | +|---|---| +| `/roulette ` | Red/black pays ×2 (≈50% each). Green pays ×14 at 1/37 chance. Lost bets go to the house. | +| `/slots ` | 3 reels. **Pair** = +50% of bet. **Triple** = tiered by symbol rarity (×4 heart → ×15 skull, ×1.5 with 360hz monitor). **Jackpot** (3 karikas) = ×25 (×37 with 360hz monitor). Miss = lose bet. | +| `/blackjack ` | Standard rules. Dealer stands on 17+. Natural blackjack pays 3:2. Double down on first action only. Split identical rank cards (one extra bet). Lost bets go to the house. | +| `/rps [panus]` | Rock Paper Scissors vs. the bot with optional bet. Bot picks randomly. | +| `/rps [panus] @vastane` | PvP duel - both players pick privately via DM, result posted to the server. Bet transferred to winner. | + +### Social + +| Command | Notes | +|---|---| +| `/rob @user` | 45% success (60% with Jellyfin). Target must have ≥100 ⬡. Steal 10–25% of target's balance. Fail = fine of 100–250 ⬡ to the house. Anticheat blocks the rob and fines the robber (2 charges per purchase). Cannot rob TipiBOT - use `/heist` instead. | +| `/heist` | Start a bank robbery. Solo or group (max 8). 5-minute join window. Success: 35% base + 5% per extra player (cap 65%). Win = split **20–55%** of house balance equally. Fail = **1h 30min jail + ~15% balance fine** for all. 4h personal cooldown + 1h global server cooldown after each event. | +| `/give @user ` | Transfer coins directly to another player. **Jailed users cannot use this command.** | +| `/request [@sihtmärk]` | Post a crowdfunding request. Anyone (or a specific target) can click **Rahasta** to contribute via `/give`. Expires in 5 minutes. | + +### Info commands + +| Command | Notes | +|---|---| +| `/balance [@user]` | Balance, daily streak, owned items (with Anticheat charges remaining), jail status if jailed. | +| `/rank [@user]` | EXP total, current level, progress bar to next level, leaderboard rank. | +| `/stats [@user]` | Lifetime statistics: economy totals, work/beg counts, gambling records, crime/heist history, social totals, best streak. | +| `/cooldowns` | All cooldowns at a glance with live Discord timestamps. Shows jail timer if jailed. | +| `/leaderboard` | Paginated coin leaderboard (10/page). House pinned at #0. ◀/▶ to browse; 📍 **Mina** jumps to your page. Has a separate EXP/level tab. | +| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. | +| `/buy ` | Purchase an item by name (partial match accepted). | +| `/reminders` | Toggle per-command DM notifications. **All reminders are on by default.** Bot DMs you the moment each cooldown expires. | + +--- + +### Jail system + +`/crime` fail (without Mänguritool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked. + +#### `/jailbreak` +Roll two dice - matching values (doubles) free you instantly. **3 attempts** per jail sentence. If all 3 fail you pay bail: + +- **20–30% of your current balance** (scales with wealth) +- **Minimum 350 ⬡** - if your balance is below this you stay jailed until the timer runs out + +Cooldowns and jail release times display as live Discord relative timestamps. + +--- + +### Shop items + +All items are **permanent** once purchased **except Anticheat**, which expires after 2 uses and can be repurchased. + +#### Tier 1 - any level + +| Item | Cost | Effect | +|---|---|---| +| Mängurihiir | 500 ⬡ | `/work` earns +50% | +| XL hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min | +| Anticheat | 750 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. | +| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 | +| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h | +| LAN Pilet | 1 200 ⬡ | `/daily` reward ×2 | +| Botikoobas | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) | + +#### Tier 2 - level 10 required (TipiHUSTLER+) + +| Item | Cost | Effect | +|---|---|---| +| Mehhaaniline klaviatuur | 1 800 ⬡ | `/beg` earns ×2 | +| Ultralai monitor | 2 500 ⬡ | `/work` cooldown 1h → 40min | +| Mikrofon | 2 800 ⬡ | `/crime` win earns +30% | +| Reguleeritav laud | 3 500 ⬡ | `/work` earns +25% (stacks with Mängurihiir → ×1.875 combined) | +| CAT6 netikaabel | 3 500 ⬡ | `/crime` success rate 60% → 75% | +| Jellyfin server | 4 000 ⬡ | `/rob` success rate 45% → 60% | + +#### Tier 3 - level 20 required (TipiCHAD+) + +| Item | Cost | Effect | +|---|---|---| +| TipiLAN trofee | 6 000 ⬡ | Daily streak survives missed days | +| 360hz monitor | 7 500 ⬡ | Slots jackpot 10× → 15×, triple 4× → 6× | +| Mänguritool | 9 000 ⬡ | `/crime` fail never sends you to jail | + +--- + +### Amount shortcuts + +Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` as the amount to wager your entire balance. + +### Custom emoji +Change `COIN` in `economy.py` to any Discord emoji string: +```python +COIN = "<:tipicoin:YOUR_EMOJI_ID>" +``` + +--- + +## Logging + +All logs are written to the `logs/` directory (auto-created on startup). + +| File | Rotation | Contents | +|---|---|---| +| `logs/bot.log` | 5 MB x 5 backups | All INFO+ events: commands, errors, member sync | +| `logs/transactions.log` | Daily, 30 days | Economy transactions only: every balance change with user, amount, new balance | + +The terminal output is **colour-coded** by log level (green = INFO, yellow = WARNING, red = ERROR). + +Every slash command invocation is logged with the user ID, display name, and all options passed. + +--- + +## Project Structure + +``` +├── bot.py # Discord client, all slash commands, event handlers +├── economy.py # TipiCOIN business logic, constants (SHOP, COOLDOWNS, etc.) +├── pb_client.py # Async PocketBase REST client (auth + CRUD for economy_users) +├── strings.py # All user-facing strings, command descriptions, help text +├── member_sync.py # Role/nickname/birthday sync logic +├── sheets.py # Google Sheets read/write + in-memory cache +├── config.py # Environment variable loader +├── requirements.txt # Python dependencies +├── .env.example # Template for secrets +├── .env # Your secrets (gitignored) +├── credentials.json # Google service account key (gitignored) +├── docs/ +│ ├── DEV_NOTES.md # Developer reference (architecture, checklists, constants) +│ ├── CHANGELOG.md # Version history +│ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions +├── scripts/ +│ ├── migrate_to_pb.py # One-time migration: economy.json → PocketBase +│ └── add_stats_fields.py # Schema migration: add new fields to economy_users collection +├── data/ +│ └── birthday_sent.json # Birthday dedup log (auto-created) +├── pb_data/ # PocketBase database files (auto-created, gitignored) +└── logs/ + ├── bot.log # General rotating log (auto-created) + └── transactions.log # Daily economy transaction log (auto-created) +``` diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..3fccd1a --- /dev/null +++ b/bot.py @@ -0,0 +1,3799 @@ +"""TipiLAN Bot - Discord member management powered by Google Sheets.""" + +import asyncio +import collections +import datetime +import json +import logging +import logging.handlers +import math +import os +import random +import re +import subprocess +import sys +import time +from pathlib import Path +from zoneinfo import ZoneInfo + +import discord +from discord import app_commands +from discord.ext import tasks + +import colorlog +import psutil + +import config +import strings as S +import economy +import pb_client +import member_sync +import sheets +from member_sync import SyncResult, sync_member, announce_birthday, is_birthday_today + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +_LOG_DIR = Path("logs") +_LOG_DIR.mkdir(exist_ok=True) + +_fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") +_txn_fmt = logging.Formatter("%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + +# Console handler - coloured output +_color_fmt = colorlog.ColoredFormatter( + "%(log_color)s%(asctime)s [%(levelname)-8s]%(reset)s %(cyan)s%(name)s%(reset)s: %(message)s", + log_colors={ + "DEBUG": "white", + "INFO": "green", + "WARNING": "yellow,bold", + "ERROR": "red,bold", + "CRITICAL": "red,bg_white,bold", + }, +) +_console = logging.StreamHandler() +_console.setFormatter(_color_fmt) +_console.setLevel(logging.INFO) + +# General rotating file: logs/bot.log (5 MB x 5 backups) +_file_h = logging.handlers.RotatingFileHandler( + _LOG_DIR / "bot.log", maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8" +) +_file_h.setFormatter(_fmt) +_file_h.setLevel(logging.INFO) + +logging.getLogger().setLevel(logging.INFO) +logging.getLogger().addHandler(_console) +logging.getLogger().addHandler(_file_h) + +# Transaction log: logs/transactions.log (daily rotation, 30 days) +_txn_h = logging.handlers.TimedRotatingFileHandler( + _LOG_DIR / "transactions.log", when="midnight", backupCount=30, encoding="utf-8" +) +_txn_h.setFormatter(_txn_fmt) +_txn_h.setLevel(logging.INFO) +_txn_logger = logging.getLogger("tipiCOIN.txn") +_txn_logger.addHandler(_txn_h) +_txn_logger.propagate = False # don't double-log to console/bot.log + +log = logging.getLogger("tipilan") + +# --------------------------------------------------------------------------- +# Bot setup +# --------------------------------------------------------------------------- +intents = discord.Intents.default() +intents.members = True # Required: Server Members Intent must be ON in dev portal + +bot = discord.Client(intents=intents) +tree = app_commands.CommandTree(bot) + +GUILD_OBJ = discord.Object(id=config.GUILD_ID) +TALLINN_TZ = ZoneInfo("Europe/Tallinn") +_start_time = datetime.datetime.now() +_process = psutil.Process() +_DATA_DIR = Path("data") +_active_games: set[int] = set() # users with an in-progress interactive game +_active_heist: "HeistLobbyView | None" = None # server-wide singleton +# heist global CD is persisted on the house record in PocketBase (see economy.get/set_heist_global_cd) +_spam_tracker: dict[int, collections.deque] = {} # user_id -> deque of recent income-cmd timestamps +_SPAM_WINDOW = 5.0 # seconds +_SPAM_THRESHOLD = 5 # income commands within window triggers jail +_BDAY_LOG = _DATA_DIR / "birthday_sent.json" +_RESTART_FILE = _DATA_DIR / "restart_channel.json" +_BOT_CONFIG = _DATA_DIR / "bot_config.json" +_PAUSED = False # maintenance mode: blocks non-admin commands when True + + +def _load_bot_config() -> dict: + if _BOT_CONFIG.exists(): + try: + return json.loads(_BOT_CONFIG.read_text(encoding="utf-8")) + except Exception: + pass + return {"allowed_channels": []} + + +def _save_bot_config(cfg: dict) -> None: + _DATA_DIR.mkdir(exist_ok=True) + _BOT_CONFIG.write_text(json.dumps(cfg, indent=2), encoding="utf-8") + + +def _get_allowed_channels() -> list[int]: + return [int(c) for c in _load_bot_config().get("allowed_channels", [])] + + +def _set_allowed_channels(channel_ids: list[int]) -> None: + cfg = _load_bot_config() + cfg["allowed_channels"] = [str(c) for c in channel_ids] + _save_bot_config(cfg) + + +# --------------------------------------------------------------------------- +# EXP / Level role helpers +# --------------------------------------------------------------------------- +def _level_role_name(level: int) -> str: + return economy.level_role_name(level) + + +async def _apply_level_role(member: discord.Member, new_level: int, old_level: int) -> None: + """Swap vanity role when the user crosses a tier boundary.""" + new_role_name = _level_role_name(new_level) + old_role_name = _level_role_name(old_level) + if new_role_name == old_role_name: + return + guild = member.guild + old_role = discord.utils.find(lambda r: r.name == old_role_name, guild.roles) + if old_role and old_role in member.roles: + try: + await member.remove_roles(old_role, reason="Level up") + except discord.Forbidden: + pass + new_role = discord.utils.find(lambda r: r.name == new_role_name, guild.roles) + if new_role: + try: + await member.add_roles(new_role, reason="Level up") + except discord.Forbidden: + pass + + +async def _ensure_level_role(member: discord.Member, level: int) -> None: + """Make sure the user has exactly the right vanity role + ECONOMY base role (idempotent).""" + correct_name = _level_role_name(level) + all_role_names = {name for _, name in economy.LEVEL_ROLES} + guild = member.guild + for role in member.roles: + if role.name in all_role_names and role.name != correct_name: + try: + await member.remove_roles(role, reason="Role sync") + except discord.Forbidden: + pass + correct_role = discord.utils.find(lambda r: r.name == correct_name, guild.roles) + if correct_role and correct_role not in member.roles: + try: + await member.add_roles(correct_role, reason="Role sync") + except discord.Forbidden: + pass + economy_role = discord.utils.find(lambda r: r.name == economy.ECONOMY_ROLE, guild.roles) + if economy_role and economy_role not in member.roles: + try: + await member.add_roles(economy_role, reason="Economy member") + except discord.Forbidden: + pass + + +async def _award_exp(interaction: discord.Interaction, amount: int) -> None: + """Award EXP and post a public level-up notice if the user reaches a new tier.""" + result = await economy.award_exp(interaction.user.id, amount) + member = interaction.guild.get_member(interaction.user.id) if interaction.guild else None + if member: + economy_role = discord.utils.find(lambda r: r.name == economy.ECONOMY_ROLE, interaction.guild.roles) + if economy_role and economy_role not in member.roles: + try: + await member.add_roles(economy_role, reason="Economy member") + except discord.Forbidden: + pass + if result["new_level"] <= result["old_level"]: + return + if member: + await _apply_level_role(member, result["new_level"], result["old_level"]) + new_role = _level_role_name(result["new_level"]) + old_role = _level_role_name(result["old_level"]) + extra = S.MSG_LEVELUP_ROLE.format(role=new_role) if new_role != old_role else "" + try: + await interaction.followup.send( + S.MSG_LEVELUP.format(name=interaction.user.display_name, level=result["new_level"], extra=extra), + ephemeral=False, + ) + except Exception: + pass + + +@tree.interaction_check +async def _log_command(interaction: discord.Interaction) -> bool: + """Log every slash command invocation and enforce allowed-channel restriction.""" + if interaction.command: + opts = interaction.data.get("options", []) + opts_str = " ".join(f"{o['name']}={o.get('value', '?')}" for o in opts) if opts else "" + log.info( + "CMD /%s user=%s (%s)%s", + interaction.command.name, + interaction.user.id, + interaction.user.display_name, + f" [{opts_str}]" if opts_str else "", + ) + + # DMs always pass + if interaction.guild is None: + return True + + # Admins (manage_guild) can use commands in any channel (and bypass pause) + member = interaction.user + if hasattr(member, "guild_permissions") and member.guild_permissions.manage_guild: + return True + + # Maintenance mode: block all non-admin commands + if _PAUSED: + await interaction.response.send_message(S.MSG_MAINTENANCE, ephemeral=True) + return False + + allowed = _get_allowed_channels() + if not allowed: + return True # no restriction configured + + if interaction.channel_id in allowed: + return True + + mentions = " ".join(f"<#{cid}>" for cid in allowed) + await interaction.response.send_message( + S.ERR["channel_only"].format(channels=mentions), + ephemeral=True, + ) + return False + + +def _load_bday_log() -> dict: + try: + return json.loads(_BDAY_LOG.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _has_announced_today(discord_id: int) -> bool: + today = str(datetime.date.today()) + return str(discord_id) in _load_bday_log().get(today, []) + + +def _mark_announced_today(discord_id: int) -> None: + log_data = _load_bday_log() + today = str(datetime.date.today()) + today_list = log_data.setdefault(today, []) + uid = str(discord_id) + if uid not in today_list: + today_list.append(uid) + cutoff = str(datetime.date.today() - datetime.timedelta(days=2)) + log_data = {k: v for k, v in log_data.items() if k >= cutoff} + _BDAY_LOG.write_text(json.dumps(log_data), encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Birthday pages view +# --------------------------------------------------------------------------- +_MONTHS_ET = [ + "Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni", + "Juuli", "August", "September", "Oktoober", "November", "Detsember", +] + + +class BirthdayPages(discord.ui.View): + def __init__(self, pages: list[discord.Embed], start: int = 0): + super().__init__(timeout=120) + self.pages = pages + self.current = start + self._update_buttons() + + def _update_buttons(self): + self.prev_button.disabled = self.current == 0 + self.next_button.disabled = self.current >= len(self.pages) - 1 + + @discord.ui.button(label="◀", style=discord.ButtonStyle.secondary) + async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.current -= 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.pages[self.current], view=self) + + @discord.ui.button(label="▶", style=discord.ButtonStyle.secondary) + async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.current += 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.pages[self.current], view=self) + + +def _build_birthday_pages( + guild: discord.Guild | None = None, +) -> tuple[list[discord.Embed], int]: + """Build 12 monthly embeds (one per calendar month). + + Returns (pages, start_index) where start_index is the current month. + """ + rows = sheets.get_cache() + today = datetime.date.today() + + # Group entries by month (1-12) + by_month: dict[int, list[tuple[int, str, int | None]]] = {m: [] for m in range(1, 13)} + + for row in rows: + name = str(row.get("Nimi", "")).strip() + bday_str = str(row.get("Sünnipäev", "")).strip() + if not name or not bday_str or bday_str.strip().lower() in ("-", "x", "n/a", "none", "ei"): + continue + bday = None + for fmt in ["%d/%m/%Y", "%Y-%m-%d", "%m-%d"]: + try: + bday = datetime.datetime.strptime(bday_str, fmt).date() + break + except ValueError: + continue + if bday is None: + continue + raw_uid = str(row.get("User ID", "")).strip() + try: + uid = int(raw_uid) if raw_uid and raw_uid not in ("0", "-") else None + except ValueError: + uid = None + by_month[bday.month].append((bday.day, name, uid)) + + pages: list[discord.Embed] = [] + for month in range(1, 13): + entries = sorted(by_month[month], key=lambda x: x[0]) + embed = discord.Embed( + title=f"🎂 {_MONTHS_ET[month - 1]}", + color=0xf4a261, + ) + if not entries: + embed.description = S.BIRTHDAY_UI["no_entries"] + else: + lines = [] + for day, name, uid in entries: + try: + this_year = datetime.date(today.year, month, day) + except ValueError: + this_year = datetime.date(today.year, month, day - 1) + next_bday = this_year if this_year >= today else this_year.replace(year=today.year + 1) + days_until = (next_bday - today).days + if days_until == 0: + when = S.BIRTHDAY_UI["today"] + elif days_until == 1: + when = S.BIRTHDAY_UI["tomorrow"] + else: + when = S.BIRTHDAY_UI["in_days"].format(days=days_until) + display = name + if guild and uid: + m = guild.get_member(uid) + if m: + display = m.mention + lines.append(f"{display} - {day:02d}/{month:02d} · {when}") + embed.description = "\n".join(lines) + embed.set_footer(text=S.BIRTHDAY_UI["footer"].format(month=month, month_name=_MONTHS_ET[month - 1])) + pages.append(embed) + + return pages, today.month - 1 # 0-indexed start on current month + + +# --------------------------------------------------------------------------- +# Daily 09:00 Tallinn-time birthday task +# --------------------------------------------------------------------------- +@tasks.loop(time=datetime.time(hour=9, minute=0, tzinfo=ZoneInfo("Europe/Tallinn"))) +async def birthday_daily(): + """Announce birthdays every day at 09:00 Tallinn time.""" + guild = bot.get_guild(config.GUILD_ID) + if guild is None: + log.warning("Birthday task: guild %s not found", config.GUILD_ID) + return + + try: + data = sheets.refresh() + except Exception as e: + log.error("Birthday task: sheet refresh failed: %s", e) + data = sheets.get_cache() + + announced = 0 + for row in data: + bday_str = str(row.get("Sünnipäev", "")).strip() + if not is_birthday_today(bday_str): + continue + + member = None + raw_id = str(row.get("User ID", "")).strip() + if raw_id: + try: + member = guild.get_member(int(raw_id)) + except ValueError: + pass + if member is None: + discord_name = str(row.get("Discord", "")).strip() + if discord_name: + member = discord.utils.find( + lambda m, n=discord_name: m.name.lower() == n.lower(), + guild.members, + ) + if member and not _has_announced_today(member.id): + await announce_birthday(member, bot) + _mark_announced_today(member.id) + announced += 1 + + log.info("Sünnipäeva ülesanne: teavitati %d liiget", announced) + + +@birthday_daily.before_loop +async def before_birthday_daily(): + await bot.wait_until_ready() + + +# --------------------------------------------------------------------------- +# Rotating rich presence +# --------------------------------------------------------------------------- +_presence_index = 0 +_economy_count: int = 0 +_PRESENCES: list = [ + lambda g: discord.Activity( + type=discord.ActivityType.watching, + name="/help - kõik käsklused", + state="Vaata, mida TipiBOTil pakkuda on", + ), + lambda g: discord.Activity( + type=discord.ActivityType.watching, + name="/daily - päevane boonus TipiCOINe", + state="Streak boonused kuni x3.0 rohkem 🔥", + ), + lambda g: discord.Activity( + type=discord.ActivityType.watching, + name=f"{_economy_count - 1 or '?'} mängijat võistlevad", + state="/leaderboard - kes on tipus?", + ), +] + + +@tasks.loop(seconds=20) +async def _rotate_presence() -> None: + global _presence_index, _economy_count + guild = bot.get_guild(config.GUILD_ID) + try: + _economy_count = await pb_client.count_records() + except Exception as e: + log.warning("Presence: failed to fetch economy count: %s", e) + activity = _PRESENCES[_presence_index % len(_PRESENCES)](guild) + await bot.change_presence(status=discord.Status.online, activity=activity) + _presence_index += 1 + + +@_rotate_presence.before_loop +async def _before_rotate_presence(): + await bot.wait_until_ready() + + +# --------------------------------------------------------------------------- +# Events +# --------------------------------------------------------------------------- +@bot.event +async def on_ready(): + """Load sheet data and sync slash commands on startup.""" + log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id) + economy.set_house(bot.user.id) + + # Pull sheet data into cache + try: + data = sheets.refresh() + log.info("Loaded %d member rows from Google Sheets", len(data)) + except Exception as e: + log.error("Failed to load sheet on startup: %s", e) + + # Sync slash commands to the guild only; wipe any leftover global registrations + tree.copy_global_to(guild=GUILD_OBJ) + await tree.sync(guild=GUILD_OBJ) + tree.clear_commands(guild=None) + await tree.sync() + log.info("Slash commands synced to guild %s (global commands cleared)", config.GUILD_ID) + + # Start daily birthday task + if not birthday_daily.is_running(): + birthday_daily.start() + log.info("Birthday daily task started (fires 09:00 Tallinn time)") + + # Start rotating rich presence + if not _rotate_presence.is_running(): + _rotate_presence.start() + log.info("Rich presence rotation started") + + # Re-schedule any reminder tasks lost on restart + await _restore_reminders() + + # Notify the channel where /restart was triggered + if _RESTART_FILE.exists(): + try: + data = json.loads(_RESTART_FILE.read_text(encoding="utf-8")) + ch = await bot.fetch_channel(int(data["channel_id"])) + if ch: + await ch.send(S.MSG_RESTART_DONE) + except Exception as e: + log.warning("Could not send restart notification: %s", e) + finally: + _RESTART_FILE.unlink(missing_ok=True) + + +@bot.event +async def on_disconnect(): + log.warning("Bot disconnected from Discord gateway") + + +@bot.event +async def on_resumed(): + log.info("Bot reconnected to Discord (session resumed)") + + +@bot.event +async def on_member_join(member: discord.Member): + """When someone joins, look them up in the sheet and sync.""" + log.info("Member joined: %s (ID: %s)", member, member.id) + + # Make sure cache is populated + if not sheets.get_cache(): + sheets.refresh() + + result = await sync_member(member, member.guild) + + if result.not_found: + try: + sheets.add_new_member_row(member.name, member.id) + log.info(" → %s not in sheet, added new row (Discord=%s, ID=%s)", + member, member.name, member.id) + except Exception as e: + log.error(" → Failed to add sheet row for %s: %s", member, e) + return + + _log_sync_result(member, result) + sheets.set_synced(member.id, result.synced) + + # Sünnipäeva teavitus + if result.birthday_soon and not _has_announced_today(member.id): + await announce_birthday(member, bot) + _mark_announced_today(member.id) + + +# --------------------------------------------------------------------------- +# Slash commands +# --------------------------------------------------------------------------- +def _sheet_stats(rows: list[dict]) -> str: + """Return a formatted string with sheet completeness statistics.""" + total = len(rows) + missing_uid = [] + missing_discord = [] + missing_birthday = [] + + for row in rows: + name = str(row.get("Nimi", "")).strip() or "(no name)" + uid = str(row.get("User ID", "")).strip() + discord_name = str(row.get("Discord", "")).strip() + bday = str(row.get("Sünnipäev", "")).strip() + + if not uid or uid == "0": + missing_uid.append(name) + if not discord_name: + missing_discord.append(name) + if not bday: + missing_birthday.append(name) + + lines = [S.CHECK_UI["sheet_stats_header"].format(total=total)] + lines.append("") + + def stat_line(label: str, missing: list[str]) -> str: + count = len(missing) + if count == 0: + return S.CHECK_UI["stat_ok"].format(label=label) + names = ", ".join(missing[:5]) + more = S.CHECK_UI["stat_more"].format(count=count - 5) if count > 5 else "" + return S.CHECK_UI["stat_warn"].format(label=label, count=count, names=names, more=more) + + lines.append(stat_line(S.CHECK_UI["stat_uid"], missing_uid)) + lines.append(stat_line(S.CHECK_UI["stat_discord"], missing_discord)) + lines.append(stat_line(S.CHECK_UI["stat_bday"], missing_birthday)) + + return "\n".join(lines) + + +@tree.command(name="ping", description=S.CMD["ping"]) +async def cmd_ping(interaction: discord.Interaction): + await interaction.response.send_message(S.MSG_PONG) + + +# --------------------------------------------------------------------------- +# /help +# --------------------------------------------------------------------------- +_HELP_PAGE_SIZE = 10 + + +def _help_embed(category_key: str, page: int = 0) -> discord.Embed: + cat = S.HELP_CATEGORIES[category_key] + fields = cat["fields"] + total_pages = math.ceil(len(fields) / _HELP_PAGE_SIZE) + page_fields = fields[page * _HELP_PAGE_SIZE : (page + 1) * _HELP_PAGE_SIZE] + title = cat["label"] + if total_pages > 1: + title += f" ({page + 1}/{total_pages})" + embed = discord.Embed(title=title, description=cat["description"], color=cat["color"]) + for name, value in page_fields: + embed.add_field(name=name, value=value, inline=False) + embed.set_footer(text=S.HELP_UI["footer"]) + return embed + + +class HelpView(discord.ui.View): + def __init__(self, is_admin: bool = False, category: str = "üldine", page: int = 0): + super().__init__(timeout=120) + self.is_admin = is_admin + self.category = category + self.page = page + self._rebuild() + + def _rebuild(self) -> None: + self.clear_items() + fields = S.HELP_CATEGORIES[self.category]["fields"] + total_pages = math.ceil(len(fields) / _HELP_PAGE_SIZE) + select_row = 0 + if total_pages > 1: + select_row = 1 + prev_btn = discord.ui.Button( + label="◀", style=discord.ButtonStyle.secondary, + disabled=(self.page == 0), row=0, + ) + prev_btn.callback = self._prev + next_btn = discord.ui.Button( + label="▶", style=discord.ButtonStyle.secondary, + disabled=(self.page >= total_pages - 1), row=0, + ) + next_btn.callback = self._next + self.add_item(prev_btn) + self.add_item(next_btn) + self.add_item(HelpSelect(self.is_admin, self.category, row=select_row)) + + async def _prev(self, interaction: discord.Interaction) -> None: + self.page = max(0, self.page - 1) + self._rebuild() + await interaction.response.edit_message(embed=_help_embed(self.category, self.page), view=self) + + async def _next(self, interaction: discord.Interaction) -> None: + total = math.ceil(len(S.HELP_CATEGORIES[self.category]["fields"]) / _HELP_PAGE_SIZE) + self.page = min(total - 1, self.page + 1) + self._rebuild() + await interaction.response.edit_message(embed=_help_embed(self.category, self.page), view=self) + + +class HelpSelect(discord.ui.Select): + def __init__(self, is_admin: bool = False, current: str = "üldine", row: int = 0): + options = [ + discord.SelectOption( + label=v["label"], value=k, description=v["description"], + default=(k == current), + ) + for k, v in S.HELP_CATEGORIES.items() + if k != "admin" or is_admin + ] + super().__init__(placeholder=S.HELP_UI["select_placeholder"], options=options, row=row) + + async def callback(self, interaction: discord.Interaction) -> None: + view = self.view + view.category = self.values[0] + view.page = 0 + view._rebuild() + await interaction.response.edit_message(embed=_help_embed(view.category, 0), view=view) + + +@tree.command(name="help", description=S.CMD["help"]) +async def cmd_help(interaction: discord.Interaction): + perms = interaction.user.guild_permissions if interaction.guild else None + is_admin = bool(perms and (perms.manage_roles or perms.manage_guild)) + await interaction.response.send_message( + embed=_help_embed("üldine"), view=HelpView(is_admin), ephemeral=True + ) + + +@tree.command(name="status", description=S.CMD["status"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +async def cmd_staatus(interaction: discord.Interaction): + proc = _process + mem = proc.memory_info() + cpu = proc.cpu_percent(interval=0.1) + uptime = datetime.datetime.now() - _start_time + h, rem = divmod(int(uptime.total_seconds()), 3600) + m, s = divmod(rem, 60) + tasks_count = len(asyncio.all_tasks()) + latency_ms = round(bot.latency * 1000, 1) + cache = sheets.get_cache() + + data = await economy.get_leaderboard(top_n=9999) + user_count = len(data) + + embed = discord.Embed(title="🖥️ Boti olek", color=0x57F287) + embed.add_field(name="🕐 Uptime", value=f"{h}t {m}m {s}s", inline=True) + embed.add_field(name="📡 Latency", value=f"{latency_ms} ms", inline=True) + embed.add_field(name="🧠 RAM (RSS)", value=f"{mem.rss / 1024**2:.1f} MB", inline=True) + embed.add_field(name="⚙️ CPU", value=f"{cpu:.1f}%", inline=True) + embed.add_field(name="🔄 Async tasks", value=str(tasks_count), inline=True) + embed.add_field(name="👤 Eco players", value=str(user_count), inline=True) + embed.add_field(name="📋 Liikmed (cache)", value=str(len(cache)), inline=True) + embed.add_field( + name="📂 Log files", + value="\n".join( + f"`{p.name}` - {p.stat().st_size / 1024:.1f} KB" + for p in sorted(_LOG_DIR.glob("*.log*")) + if p.is_file() + ) or "-", + inline=False, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +@tree.command(name="birthdays", description=S.CMD["birthdays"]) +@app_commands.guild_only() +async def cmd_birthdays(interaction: discord.Interaction): + await interaction.response.defer() + + try: + sheets.refresh() + except Exception as e: + await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) + return + + pages, start = _build_birthday_pages(guild=interaction.guild) + await interaction.followup.send(embed=pages[start], view=BirthdayPages(pages, start=start)) + + +@tree.command(name="check", description=S.CMD["check"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_roles=True) +async def cmd_check(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + guild = interaction.guild + if guild is None: + await interaction.followup.send(S.ERR["guild_only"], ephemeral=True) + return + + # Load fresh sheet data + try: + data = sheets.refresh() + except Exception as e: + await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) + return + + # Backfill missing User IDs by matching Discord username + ids_filled = 0 + for row in data: + uid = str(row.get("User ID", "")).strip() + if uid and uid not in ("0", "-"): + continue + discord_name = str(row.get("Discord", "")).strip() + if not discord_name: + continue + gm = discord.utils.find( + lambda m, n=discord_name: m.name.lower() == n.lower(), + guild.members, + ) + if gm: + sheets.set_user_id(discord_name, gm.id) + ids_filled += 1 + + data = sheets.get_cache() + + changed_count = 0 + not_found = 0 + already_ok = 0 + errors_total = 0 + birthday_pings = 0 + details: list[str] = [] + sync_updates: list[tuple[int, bool]] = [] + + members = guild.members + for member in members: + if member.bot: + continue + + result = await sync_member(member, guild) + + if result.not_found: + not_found += 1 + continue + + sync_updates.append((member.id, result.synced)) + + if result.errors: + errors_total += len(result.errors) + for err in result.errors: + details.append(f"⚠️ {err}") + + if result.changed: + changed_count += 1 + parts = [] + if result.nickname_changed: + parts.append("hüüdnimi") + if result.roles_added: + parts.append(f"+rollid: {', '.join(result.roles_added)}") + details.append(f"🔧 **{member.display_name}**: {', '.join(parts)}") + else: + already_ok += 1 + + if result.birthday_soon and not _has_announced_today(member.id): + birthday_pings += 1 + await announce_birthday(member, bot) + _mark_announced_today(member.id) + + # Batch-write synced status (single API call instead of one per member) + if sync_updates: + try: + sheets.batch_set_synced(sync_updates) + except Exception as e: + log.error("/check batch_set_synced failed: %s", e) + + # Build summary + summary_lines = [ + S.CHECK_UI["done"], + S.CHECK_UI["already_ok"].format(count=already_ok), + S.CHECK_UI["fixed"].format(count=changed_count), + S.CHECK_UI["not_found"].format(count=not_found), + S.CHECK_UI["bday_pings"].format(count=birthday_pings), + ] + if errors_total: + summary_lines.append(S.CHECK_UI["errors"].format(count=errors_total)) + + summary = "\n".join(summary_lines) + + if details: + detail_text = "\n".join(details[:20]) # cap at 20 to avoid message limit + summary += f"\n\n{S.CHECK_UI['details_header']}\n{detail_text}" + if len(details) > 20: + summary += "\n" + S.CHECK_UI["details_more"].format(count=len(details) - 20) + + stats = _sheet_stats(data) + id_note = S.CHECK_UI["ids_filled"].format(count=ids_filled) if ids_filled else "" + summary = id_note + "\n" + summary + "\n\n" + stats + + await interaction.followup.send(summary.strip(), ephemeral=True) + log.info("/check - OK=%d, Fixed=%d, NotFound=%d, IDs=%d, Errors=%d", + already_ok, changed_count, not_found, ids_filled, errors_total) + + +@tree.command(name="sync", description=S.CMD["sync"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +async def cmd_sync(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + tree.copy_global_to(guild=GUILD_OBJ) + await tree.sync(guild=GUILD_OBJ) + tree.clear_commands(guild=None) + await tree.sync() + await interaction.followup.send(S.MSG_SYNC_DONE, ephemeral=True) + log.info("/sync triggered by %s", interaction.user) + + +@tree.command(name="adminseason", description=S.CMD["adminseason"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +@app_commands.describe(top_n=S.OPT["adminseason_top_n"]) +async def cmd_adminseason(interaction: discord.Interaction, top_n: int = 10): + await interaction.response.defer(ephemeral=True) + top = await economy.do_season_reset(top_n) + guild = interaction.guild + + # Strip all vanity roles from every guild member + if guild: + all_role_names = {name for _, name in economy.LEVEL_ROLES} + for role_name in all_role_names: + role = discord.utils.find(lambda r: r.name == role_name, guild.roles) + if not role: + continue + for m in list(role.members): + try: + await m.remove_roles(role, reason="Season reset") + except discord.Forbidden: + pass + + medals = ["\U0001f947", "\U0001f948", "\U0001f949"] + lines = [] + for i, (uid, exp, lvl) in enumerate(top): + member = guild.get_member(int(uid)) if guild else None + name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) + prefix = medals[i] if i < 3 else f"**{i + 1}.**" + lines.append(S.SEASON["entry"].format(prefix=prefix, name=name, exp=exp, level=lvl)) + + embed = discord.Embed( + title=S.TITLE["adminseason"], + description=(S.SEASON["top_header"] + "\n" + "\n".join(lines)) if lines else S.SEASON["no_players"], + color=0xF4C430, + ) + embed.set_footer(text=S.SEASON["footer"]) + await interaction.followup.send(embed=embed, ephemeral=False) + await interaction.followup.send(S.SEASON["done"], ephemeral=True) + log.info("/adminseason triggered by %s (top_n=%d)", interaction.user, top_n) + + +@tree.command(name="member", description=S.CMD["member"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_roles=True) +async def cmd_member(interaction: discord.Interaction, user: discord.Member): + row = sheets.find_member(user.id, user.name) + if row is None: + await interaction.response.send_message( + S.ERR["member_not_found"].format(name=user.display_name), + ephemeral=True, + ) + return + + embed = discord.Embed(title=S.MEMBER_UI["title"].format(name=user.display_name), color=user.color) + + # Age from birthday + bday_str = str(row.get("Sünnipäev", "")).strip() + if bday_str and bday_str.lower() not in ("-", "x", "n/a", "none", "ei"): + for fmt in ["%d/%m/%Y", "%Y-%m-%d"]: + try: + bday = datetime.datetime.strptime(bday_str, fmt).date() + if 1920 <= bday.year <= datetime.date.today().year: + today = datetime.date.today() + age = today.year - bday.year - ((today.month, today.day) < (bday.month, bday.day)) + embed.add_field(name=S.MEMBER_UI["age_field"], value=S.MEMBER_UI["age_val"].format(age=age), inline=True) + break + except ValueError: + continue + + for sheet_key, label in S.MEMBER_FIELDS: + val = str(row.get(sheet_key, "")).strip() + if val: + embed.add_field(name=label, value=val, inline=True) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + +@tree.command(name="restart", description=S.CMD["restart"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +async def cmd_restart(interaction: discord.Interaction): + _RESTART_FILE.write_text( + json.dumps({"channel_id": interaction.channel_id}), encoding="utf-8" + ) + await interaction.response.send_message(S.MSG_RESTARTING, ephemeral=True) + log.info("/restart triggered by %s", interaction.user) + subprocess.Popen([sys.executable] + sys.argv, cwd=os.getcwd()) + await bot.close() + + +@tree.command(name="shutdown", description=S.CMD["shutdown"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +async def cmd_shutdown(interaction: discord.Interaction): + await interaction.response.send_message(S.MSG_SHUTTING_DOWN, ephemeral=True) + log.info("/shutdown triggered by %s", interaction.user) + await bot.close() + + +@tree.command(name="pause", description=S.CMD["pause"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +async def cmd_pause(interaction: discord.Interaction): + global _PAUSED + _PAUSED = not _PAUSED + msg = S.MSG_PAUSED if _PAUSED else S.MSG_UNPAUSED + log.info("/pause toggled → %s by %s", "PAUSED" if _PAUSED else "UNPAUSED", interaction.user) + await interaction.response.send_message(msg, ephemeral=True) + + +# --------------------------------------------------------------------------- +# Admin economy commands +# --------------------------------------------------------------------------- +async def _dm_user(user_id: int, msg: str) -> None: + """Best-effort DM to a user.""" + try: + user = bot.get_user(user_id) or await bot.fetch_user(user_id) + await user.send(msg) + except Exception: + pass + + +@tree.command(name="admincoins", description=S.CMD["admincoins"]) +@app_commands.guild_only() +@app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + kogus=S.OPT["admincoins_kogus"], + põhjus=S.OPT["admin_põhjus"], +) +@app_commands.default_permissions(manage_guild=True) +async def cmd_admincoins(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str): + if kogus == 0: + await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True) + return + res = await economy.do_admin_coins(kasutaja.id, kogus, interaction.user.id, põhjus) + verb = f"+{kogus}" if kogus > 0 else str(kogus) + emoji = "💰" if kogus > 0 else "💸" + await interaction.response.send_message( + S.ADMIN["coins_done"].format(emoji=emoji, name=kasutaja.display_name, verb=verb, coin=economy.COIN, balance=f"{res['balance']:,}", reason=põhjus), + ephemeral=True, + ) + await _dm_user(kasutaja.id, + S.ADMIN["coins_dm"].format(emoji=emoji, verb=verb, coin=economy.COIN, reason=põhjus, balance=f"{res['balance']:,}") + ) + log.info("ADMINCOINS %s → %s (%s) by %s", verb, kasutaja, põhjus, interaction.user) + + +@tree.command(name="adminjail", description=S.CMD["adminjail"]) +@app_commands.guild_only() +@app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + minutid=S.OPT["adminjail_minutid"], + põhjus=S.OPT["admin_põhjus"], +) +@app_commands.default_permissions(manage_guild=True) +async def cmd_adminjail(interaction: discord.Interaction, kasutaja: discord.Member, minutid: int, põhjus: str): + if minutid <= 0: + await interaction.response.send_message(S.ERR["positive_duration"], ephemeral=True) + return + res = await economy.do_admin_jail(kasutaja.id, minutid, interaction.user.id, põhjus) + until_ts = _cd_ts(datetime.timedelta(minutes=minutid)) + await interaction.response.send_message( + S.ADMIN["jail_done"].format(name=kasutaja.display_name, minutes=minutid, ts=until_ts, reason=põhjus), + ephemeral=True, + ) + await _dm_user(kasutaja.id, + S.ADMIN["jail_dm"].format(minutes=minutid, reason=põhjus, ts=until_ts) + ) + log.info("ADMINJAIL %s %dmin (%s) by %s", kasutaja, minutid, põhjus, interaction.user) + + +@tree.command(name="adminunjail", description=S.CMD["adminunjail"]) +@app_commands.guild_only() +@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) +@app_commands.default_permissions(manage_guild=True) +async def cmd_adminunjail(interaction: discord.Interaction, kasutaja: discord.Member): + await economy.do_admin_unjail(kasutaja.id, interaction.user.id) + await interaction.response.send_message( + S.ADMIN["unjail_done"].format(name=kasutaja.display_name), ephemeral=True + ) + await _dm_user(kasutaja.id, S.ADMIN["unjail_dm"]) + log.info("ADMINUNJAIL %s by %s", kasutaja, interaction.user) + + +@tree.command(name="adminban", description=S.CMD["adminban"]) +@app_commands.guild_only() +@app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + põhjus=S.OPT["admin_põhjus"], +) +@app_commands.default_permissions(manage_guild=True) +async def cmd_adminban(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): + if bot.user and kasutaja.id == bot.user.id: + await interaction.response.send_message(S.ERR["admin_ban_bot"], ephemeral=True) + return + await economy.do_admin_ban(kasutaja.id, interaction.user.id, põhjus) + await interaction.response.send_message( + S.ADMIN["ban_done"].format(name=kasutaja.display_name, reason=põhjus), + ephemeral=True, + ) + await _dm_user(kasutaja.id, + S.ADMIN["ban_dm"].format(reason=põhjus) + ) + log.info("ADMINBAN %s (%s) by %s", kasutaja, põhjus, interaction.user) + + +@tree.command(name="adminunban", description=S.CMD["adminunban"]) +@app_commands.guild_only() +@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) +@app_commands.default_permissions(manage_guild=True) +async def cmd_adminunban(interaction: discord.Interaction, kasutaja: discord.Member): + await economy.do_admin_unban(kasutaja.id, interaction.user.id) + await interaction.response.send_message( + S.ADMIN["unban_done"].format(name=kasutaja.display_name), ephemeral=True + ) + await _dm_user(kasutaja.id, S.ADMIN["unban_dm"]) + log.info("ADMINUNBAN %s by %s", kasutaja, interaction.user) + + +@tree.command(name="adminreset", description=S.CMD["adminreset"]) +@app_commands.guild_only() +@app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + põhjus=S.OPT["admin_põhjus"], +) +@app_commands.default_permissions(manage_guild=True) +async def cmd_adminreset(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): + if bot.user and kasutaja.id == bot.user.id: + await interaction.response.send_message(S.ERR["admin_reset_bot"], ephemeral=True) + return + await economy.do_admin_reset(kasutaja.id, interaction.user.id) + await interaction.response.send_message( + S.ADMIN["reset_done"].format(name=kasutaja.display_name, reason=põhjus), + ephemeral=True, + ) + await _dm_user(kasutaja.id, + S.ADMIN["reset_dm"].format(reason=põhjus) + ) + log.info("ADMINRESET %s (%s) by %s", kasutaja, põhjus, interaction.user) + + +@tree.command(name="adminview", description=S.CMD["adminview"]) +@app_commands.guild_only() +@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) +@app_commands.default_permissions(manage_guild=True) +async def cmd_adminview(interaction: discord.Interaction, kasutaja: discord.Member): + res = await economy.do_admin_inspect(kasutaja.id) + d = res["data"] + items_str = ", ".join(d.get("items", [])) or "-" + uses = d.get("item_uses", {}) + uses_str = ", ".join(f"{k}:{v}" for k, v in uses.items()) if uses else "-" + jailed = d.get("jailed_until") or "-" + banned = S.ADMINVIEW_UI["banned_yes"] if d.get("eco_banned") else S.ADMINVIEW_UI["banned_no"] + embed = discord.Embed( + title=S.ADMINVIEW_UI["title"].format(name=kasutaja.display_name), + color=0x5865F2, + ) + embed.add_field(name=S.ADMINVIEW_UI["f_balance"], value=f"{d.get('balance', 0):,} {economy.COIN}", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(d.get("daily_streak", 0)), inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_banned"], value=banned, inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_jailed"], value=jailed, inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_items"], value=items_str, inline=False) + embed.add_field(name=S.ADMINVIEW_UI["f_uses"], value=uses_str, inline=False) + embed.add_field(name=S.ADMINVIEW_UI["f_last_daily"], value=d.get("last_daily") or "-", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_last_work"], value=d.get("last_work") or "-", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_last_crime"], value=d.get("last_crime") or "-", inline=True) + embed.set_footer(text=S.ADMINVIEW_UI["footer"].format(uid=kasutaja.id)) + await interaction.response.send_message(embed=embed, ephemeral=True) + log.info("ADMINVIEW %s by %s", kasutaja, interaction.user) + + +# --------------------------------------------------------------------------- +# TipiBOT economy commands +# --------------------------------------------------------------------------- + +def _coin(amount: int) -> str: + return f"**{amount:,}** {economy.COIN}" + + +def _cd_ts(remaining: datetime.timedelta) -> str: + """Discord relative timestamp string for when a cooldown expires.""" + expiry = int((datetime.datetime.now(datetime.timezone.utc) + remaining).timestamp()) + return f"" + + +async def _check_cmd_rate(interaction: discord.Interaction) -> bool: + """Record an income command use. Jails the user and returns True if spam is detected.""" + uid = interaction.user.id + now = time.monotonic() + q = _spam_tracker.setdefault(uid, collections.deque()) + q.append(now) + while q and now - q[0] > _SPAM_WINDOW: + q.popleft() + if len(q) >= _SPAM_THRESHOLD: + q.clear() + await economy.do_spam_jail(uid) + await interaction.response.send_message(S.MSG_SPAM_JAIL, ephemeral=True) + return True + return False + + +def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]: + """Parse an amount string; 'all' resolves to the user's full balance. + Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000). + Rejects decimals and ambiguous inputs like 1,1 or 1.5. + Returns (amount, None) on success or (None, error_msg) on failure.""" + v = value.strip() + if v.lower() == "all": + return balance, None + # Strip valid thousand separators: groups of exactly 3 digits after separator + if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v): + v = re.sub(r'[,. ]', '', v) + try: + return int(v), None + except ValueError: + return None, S.ERR["invalid_amount"] + + +# --------------------------------------------------------------------------- +# Reminder system +# --------------------------------------------------------------------------- +_reminder_tasks: dict[tuple[int, str], asyncio.Task] = {} + + +def _schedule_reminder(user_id: int, cmd: str, delay: datetime.timedelta) -> None: + """DM the user when their cooldown expires. Replaces any existing task.""" + async def _remind(): + await asyncio.sleep(delay.total_seconds()) + user = bot.get_user(user_id) + if user is None: + try: + user = await bot.fetch_user(user_id) + except (discord.NotFound, discord.HTTPException): + user = None + if user: + try: + await user.send( + S.MSG_REMINDER.format(cmd=cmd) + ) + except (discord.Forbidden, discord.HTTPException): + pass + _reminder_tasks.pop((user_id, cmd), None) + + key = (user_id, cmd) + existing = _reminder_tasks.get(key) + if existing and not existing.done(): + existing.cancel() + _reminder_tasks[key] = asyncio.create_task(_remind()) + + +_REMINDER_COOLDOWN_KEYS: dict[str, str] = { + "daily": "last_daily", + "work": "last_work", + "beg": "last_beg", + "crime": "last_crime", + "rob": "last_rob", +} + + +async def _restore_reminders() -> None: + """Re-schedule reminder tasks lost when the bot restarted.""" + now = datetime.datetime.now(datetime.timezone.utc) + restored = 0 + for uid_str, user in (await economy.get_all_users_raw()).items(): + reminders = user.get("reminders", []) + if not reminders: + continue + user_id = int(uid_str) + for cmd in reminders: + last_key = _REMINDER_COOLDOWN_KEYS.get(cmd) + if not last_key: + continue + last_str = user.get(last_key) + if not last_str: + continue + items = user.get("items", []) + if cmd == "work" and "monitor" in items: + cooldown = datetime.timedelta(minutes=40) + elif cmd == "beg" and "hiirematt" in items: + cooldown = datetime.timedelta(minutes=3) + elif cmd == "daily" and "korvaklapid" in items: + cooldown = datetime.timedelta(hours=18) + else: + cooldown = economy.COOLDOWNS.get(cmd) + if not cooldown: + continue + last_dt = datetime.datetime.fromisoformat(last_str) + if last_dt.tzinfo is None: + last_dt = last_dt.replace(tzinfo=datetime.timezone.utc) + remaining = (last_dt + cooldown) - now + if remaining.total_seconds() > 0: + _schedule_reminder(user_id, cmd, remaining) + restored += 1 + if restored: + log.info("Restored %d reminder task(s) after restart", restored) + + +async def _maybe_remind(user_id: int, cmd: str) -> None: + """Schedule a DM reminder if the user has opted in for this command.""" + user_data = await economy.get_user(user_id) + if cmd not in user_data.get("reminders", []): + return + items = set(user_data.get("items", [])) + if cmd == "work" and "monitor" in items: + delay = datetime.timedelta(minutes=40) + elif cmd == "beg" and "hiirematt" in items: + delay = datetime.timedelta(minutes=3) + elif cmd == "daily" and "korvaklapid" in items: + delay = datetime.timedelta(hours=18) + else: + delay = economy.COOLDOWNS.get(cmd, datetime.timedelta(hours=1)) + _schedule_reminder(user_id, cmd, delay) + + +def _balance_embed(user: discord.User | discord.Member, data: dict) -> discord.Embed: + embed = discord.Embed( + title=f"{economy.COIN} {user.display_name}", + color=0xF4C430, + ) + embed.add_field(name=S.BALANCE_UI["saldo"], value=_coin(data["balance"]), inline=True) + streak = data.get("daily_streak", 0) + if streak: + embed.add_field(name=S.BALANCE_UI["streak"], value=S.BALANCE_UI["streak_val"].format(streak=streak), inline=True) + # Jail status + jail_remaining = economy._is_jailed(data) + if jail_remaining: + embed.add_field(name=S.BALANCE_UI["jailed_until"], value=_cd_ts(jail_remaining), inline=True) + # Items with uses info + item_lines = [] + uses_map = data.get("item_uses", {}) + for i in data.get("items", []): + if i not in economy.SHOP: + continue + line = f"{economy.SHOP[i]['emoji']} {economy.SHOP[i]['name']}" + if i in uses_map: + u = uses_map[i] + line += S.BALANCE_UI["uses_one"].format(uses=u) if u == 1 else S.BALANCE_UI["uses_many"].format(uses=u) + item_lines.append(line) + if item_lines: + embed.add_field(name=S.BALANCE_UI["items"], value="\n".join(item_lines), inline=False) + return embed + + +@tree.command(name="balance", description=S.CMD["balance"]) +async def cmd_balance(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + await interaction.response.send_message(embed=_balance_embed(target, data)) + + +@tree.command(name="cooldowns", description=S.CMD["cooldowns"]) +async def cmd_cooldowns(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + now = datetime.datetime.now(datetime.timezone.utc) + items = set(data.get("items", [])) + + def _status(last_key: str, cd: datetime.timedelta) -> str: + raw = data.get(last_key) + if not raw: + return S.COOLDOWNS_UI["ready"] + last = economy._parse_dt(raw) + if last is None: + return S.COOLDOWNS_UI["ready"] + expires = last + cd + if expires <= now: + return S.COOLDOWNS_UI["ready"] + ts = int(expires.timestamp()) + return f"⏳ " + + work_cd = datetime.timedelta(minutes=40) if "monitor" in items else economy.COOLDOWNS["work"] + beg_cd = datetime.timedelta(minutes=3) if "hiirematt" in items else economy.COOLDOWNS["beg"] + daily_cd = datetime.timedelta(hours=18) if "korvaklapid" in items else economy.COOLDOWNS["daily"] + + lines = [ + S.COOLDOWNS_UI["daily_line"].format(status=_status("last_daily", daily_cd), note=S.COOLDOWNS_UI["note_korvak"] if "korvaklapid" in items else ""), + S.COOLDOWNS_UI["work_line"].format(status=_status("last_work", work_cd), note=S.COOLDOWNS_UI["note_monitor"] if "monitor" in items else ""), + S.COOLDOWNS_UI["beg_line"].format(status=_status("last_beg", beg_cd), note=S.COOLDOWNS_UI["note_hiirematt"] if "hiirematt" in items else ""), + S.COOLDOWNS_UI["crime_line"].format(status=_status("last_crime", economy.COOLDOWNS["crime"])), + S.COOLDOWNS_UI["rob_line"].format(status=_status("last_rob", economy.COOLDOWNS["rob"])), + ] + + jailed = data.get("jailed_until") + if jailed: + jail_dt = datetime.datetime.fromisoformat(jailed) + if jail_dt > now: + ts = int(jail_dt.timestamp()) + lines.append(S.COOLDOWNS_UI["jailed"].format(ts=ts)) + else: + lines.append(S.COOLDOWNS_UI["jail_expired"]) + + embed = discord.Embed( + title=S.TITLE["cooldowns"], + description="\n".join(lines), + color=0x5865F2, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +@tree.command(name="jailed", description=S.CMD["jailed"]) +@app_commands.guild_only() +async def cmd_jailed(interaction: discord.Interaction): + await interaction.response.defer() + jailed = await economy.do_get_jailed() + if not jailed: + embed = discord.Embed( + title=S.JAILED_UI["title"], + description=S.JAILED_UI["empty"], + color=0x57F287, + ) + await interaction.followup.send(embed=embed) + return + + now = datetime.datetime.now(datetime.timezone.utc) + lines = [] + for uid, remaining in jailed: + ts = int((now + remaining).timestamp()) + member = interaction.guild.get_member(uid) if interaction.guild else None + mention = member.mention if member else f"<@{uid}>" + lines.append(S.JAILED_UI["entry"].format(mention=mention, ts=ts)) + + plural = "" if len(jailed) == 1 else "i" + embed = discord.Embed( + title=S.JAILED_UI["title"], + description="\n".join(lines), + color=0xED4245, + ) + embed.set_footer(text=S.JAILED_UI["footer"].format(count=len(jailed), plural=plural)) + await interaction.followup.send(embed=embed) + + +@tree.command(name="rank", description=S.CMD["rank"]) +@app_commands.describe(kasutaja=S.OPT["rank_kasutaja"]) +async def cmd_rank(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + exp = data.get("exp", 0) + level = economy.get_level(exp) + role_name = economy.level_role_name(level) + next_level = level + 1 + exp_this = economy.exp_for_level(level) + exp_next = economy.exp_for_level(next_level) + progress = exp - exp_this + needed = exp_next - exp_this + pct = progress / needed if needed > 0 else 1.0 + filled = int(pct * 12) + bar = "█" * filled + "░" * (12 - filled) + embed = discord.Embed( + title=S.RANK_UI["title"].format(name=target.display_name, level=level), + color=0x5865F2, + ) + embed.add_field(name=S.RANK_UI["field_title"], value=f"**{role_name}**", inline=True) + embed.add_field(name=S.RANK_UI["field_exp"], value=str(exp), inline=True) + embed.add_field( + name=S.RANK_UI["field_progress"].format(next=next_level), + value=S.RANK_UI["progress_val"].format(bar=bar, progress=progress, needed=needed), + inline=False, + ) + if level < 10: + embed.set_footer(text=S.RANK_UI["footer_t1"]) + elif level < 20: + embed.set_footer(text=S.RANK_UI["footer_t2"]) + else: + embed.set_footer(text=S.RANK_UI["footer_t3"]) + await interaction.response.send_message(embed=embed, ephemeral=True) + if not kasutaja and interaction.guild: + member = interaction.guild.get_member(target.id) + if member: + asyncio.create_task(_ensure_level_role(member, level)) + + +@tree.command(name="stats", description=S.CMD["stats"]) +@app_commands.describe(kasutaja=S.OPT["stats_kasutaja"]) +async def cmd_stats(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + d = await economy.get_user(target.id) + + def _s(key: str) -> int: + return d.get(key, 0) + + embed = discord.Embed( + title=f"{S.TITLE['stats']} - {target.display_name}", + color=0x5865F2, + ) + embed.add_field( + name=S.STATS_UI["economy_field"], + value=S.STATS_UI["economy_val"].format(peak=_coin(_s("peak_balance")), earned=_coin(_s("lifetime_earned")), lost=_coin(_s("lifetime_lost"))), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["work_field"], + value=S.STATS_UI["work_val"].format(work=_s("work_count"), beg=_s("beg_count")), + inline=True, + ) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field( + name=S.STATS_UI["gamble_field"], + value=S.STATS_UI["gamble_val"].format(wagered=_coin(_s("total_wagered")), win=_coin(_s("biggest_win")), loss=_coin(_s("biggest_loss")), jackpots=_s("slots_jackpots")), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["crime_field"], + value=S.STATS_UI["crime_val"].format(crimes=_s("crimes_attempted"), succeeded=_s("crimes_succeeded"), heists=_s("heists_joined"), heists_won=_s("heists_won"), jailed=_s("times_jailed"), bail=_coin(_s("total_bail_paid"))), + inline=True, + ) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field( + name=S.STATS_UI["social_field"], + value=S.STATS_UI["social_val"].format(given=_coin(_s("total_given")), received=_coin(_s("total_received"))), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["records_field"], + value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")), + inline=True, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +@tree.command(name="daily", description=S.CMD["daily"]) +async def cmd_daily(interaction: discord.Interaction): + res = await economy.do_daily(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["daily"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + return + + streak = res["streak"] + streak_str = f"🔥 {streak}p" + ( + " (+200%)" if res["streak_mult"] >= 3.0 else + " (+100%)" if res["streak_mult"] >= 2.0 else + " (+50%)" if res["streak_mult"] >= 1.5 else "" + ) + lines = [S.DAILY_UI["earned"].format(earned=_coin(res["earned"]))] + if res["interest"]: + lines.append(S.DAILY_UI["interest"].format(interest=_coin(res["interest"]))) + if res["vip"]: + lines.append(S.DAILY_UI["vip"]) + lines.append(S.DAILY_UI["footer"].format(streak_str=streak_str, balance=_coin(res["balance"]))) + + embed = discord.Embed(title=S.TITLE["daily"], description="\n".join(lines), color=0xF4C430) + await interaction.response.send_message(embed=embed) + asyncio.create_task(_maybe_remind(interaction.user.id, "daily")) + asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["daily"])) + + +@tree.command(name="work", description=S.CMD["work"]) +async def cmd_work(interaction: discord.Interaction): + if await _check_cmd_rate(interaction): + return + res = await economy.do_work(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["work"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + return + + desc = S.WORK_UI["desc"].format(job=res["job"], earned=_coin(res["earned"])) + if res["lucky"]: + desc += S.WORK_UI["redbull"] + if res["hiir"]: + desc += S.WORK_UI["hiir"] + if res["laud"]: + desc += S.WORK_UI["laud"] + desc += S.WORK_UI["balance"].format(balance=_coin(res["balance"])) + embed = discord.Embed(title=S.TITLE["work"], description=desc, color=0x57F287) + await interaction.response.send_message(embed=embed) + asyncio.create_task(_maybe_remind(interaction.user.id, "work")) + asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["work"])) + + +@tree.command(name="beg", description=S.CMD["beg"]) +async def cmd_beg(interaction: discord.Interaction): + if await _check_cmd_rate(interaction): + return + res = await economy.do_beg(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["beg"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + return + + if res["jailed"]: + title = "🔒 " + S.TITLE["beg"] + color = 0xE67E22 + else: + title = S.TITLE["beg"] + color = 0x99AAB5 + beg_lines = [S.BEG_UI["desc"].format(text=res["text"], earned=_coin(res["earned"]))] + if res["klaviatuur"]: + beg_lines.append(S.BEG_UI["klaviatuur"]) + beg_lines.append(S.BEG_UI["balance"].format(balance=_coin(res["balance"]))) + embed = discord.Embed(title=title, description="\n".join(beg_lines), color=color) + await interaction.response.send_message(embed=embed) + asyncio.create_task(_maybe_remind(interaction.user.id, "beg")) + asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["beg"])) + + +@tree.command(name="crime", description=S.CMD["crime"]) +async def cmd_crime(interaction: discord.Interaction): + if await _check_cmd_rate(interaction): + return + res = await economy.do_crime(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["crime"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + return + + if res["success"]: + crime_lines = [S.CRIME_UI["win_desc"].format(text=res["text"], earned=_coin(res["earned"]))] + if res["mikrofon"]: + crime_lines.append(S.CRIME_UI["mikrofon"].lstrip("\n")) + crime_lines.append(S.CRIME_UI["balance"].lstrip("\n").format(balance=_coin(res["balance"]))) + embed = discord.Embed( + title=S.TITLE["crime_win"], + description="\n".join(crime_lines), + color=0x57F287, + ) + else: + jail_part = S.CRIME_UI["fail_jailed"].format(ts=_cd_ts(economy.JAIL_DURATION)) if res.get("jailed") else S.CRIME_UI["fail_shield"] + embed = discord.Embed( + title=S.TITLE["crime_fail"], + description=S.CRIME_UI["fail_base"].format(text=res["text"], fine=_coin(res["fine"])) + jail_part + S.CRIME_UI["balance"].format(balance=_coin(res["balance"])), + color=0xED4245, + ) + await interaction.response.send_message(embed=embed) + asyncio.create_task(_maybe_remind(interaction.user.id, "crime")) + if res["success"]: + asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["crime_win"])) + + +@tree.command(name="rob", description=S.CMD["rob"]) +async def cmd_rob(interaction: discord.Interaction, sihtmärk: discord.Member): + if await _check_cmd_rate(interaction): + return + if sihtmärk.id == interaction.user.id: + await interaction.response.send_message(S.ERR["rob_self"], ephemeral=True) + return + if sihtmärk.bot and (bot.user is None or sihtmärk.id != bot.user.id): + await interaction.response.send_message(S.ERR["rob_bot"], ephemeral=True) + return + if bot.user and sihtmärk.id == bot.user.id: + await interaction.response.send_message(S.ERR["rob_house_blocked"], ephemeral=True) + return + + res = await economy.do_rob(interaction.user.id, sihtmärk.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["rob"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + elif res["reason"] == "broke": + await interaction.response.send_message( + S.ERR["rob_too_poor"].format(name=sihtmärk.display_name), ephemeral=True + ) + elif res["reason"] == "target_jailed": + await interaction.response.send_message( + S.ERR["rob_target_jailed"].format(name=sihtmärk.display_name), ephemeral=True + ) + return + + if res["success"]: + if res.get("jackpot"): + desc = S.ROB_UI["jackpot_desc"].format(stolen=_coin(res["stolen"]), balance=_coin(res["balance"])) + color = 0xF4C430 + else: + desc = S.ROB_UI["win_desc"].format(stolen=_coin(res["stolen"]), name=sihtmärk.display_name, balance=_coin(res["balance"])) + color = 0x57F287 + embed = discord.Embed(title=S.TITLE["rob_win"], description=desc, color=color) + elif res["reason"] == "valvur": + embed = discord.Embed( + title=S.TITLE["rob_anticheat"], + description=S.ROB_UI["anticheat_desc"].format(name=sihtmärk.display_name, fine=_coin(res["fine"])), + color=0xED4245, + ) + # Notify target if anticheat fully depleted + target_data = await economy.get_user(sihtmärk.id) + if "anticheat" not in target_data.get("items", []): + try: + await sihtmärk.send(S.ROB_UI["anticheat_worn"]) + except discord.Forbidden: + pass + else: + embed = discord.Embed( + title=S.TITLE["rob_fail"], + description=S.ROB_UI["fail_desc"].format(fine=_coin(res["fine"]), balance=_coin(res["balance"])), + color=0xED4245, + ) + await interaction.response.send_message(embed=embed) + asyncio.create_task(_maybe_remind(interaction.user.id, "rob")) + if res["success"]: + asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["rob_win"])) + + +# --------------------------------------------------------------------------- +# /heist - multiplayer group robbery of the house +# --------------------------------------------------------------------------- +_HEIST_JOIN_WINDOW = 300 # seconds players have to join +_HEIST_MIN_PLAYERS = 2 +_HEIST_GLOBAL_CD = 14400 # seconds between heist events server-wide (4h) +_HEIST_MAX_PLAYERS = 8 +_HEIST_BASE_CHANCE = 0.35 # 35% solo +_HEIST_CHANCE_STEP = 0.05 # +5% per extra player +_HEIST_MAX_CHANCE = 0.65 # cap at 65% + + +def _build_heist_story(participants: list[discord.Member], success: bool) -> list[str]: + """Return a list of story lines for the heist narrative reveal.""" + story = S.HEIST_STORY + leader = participants[0].display_name + member = participants[1].display_name if len(participants) > 1 else participants[0].display_name + if len(participants) == 1: + names = f"**{leader}**" + elif len(participants) == 2: + names = S.HEIST_UI["names_duo"].format(a=participants[0].display_name, b=participants[1].display_name) + elif len(participants) <= 4: + names = S.HEIST_UI["names_sep"].join(f"**{p.display_name}**" for p in participants) + else: + names = S.HEIST_UI["names_crew"].format(leader=participants[0].display_name) + + vehicle = random.choice(story["vehicles"]) + approach = random.choice(["sneaky", "loud"]) + non_leaders = participants[1:] if len(participants) > 1 else participants + + def fill(tmpl: str) -> str: + picked = random.choice(non_leaders).display_name + return tmpl.format( + leader=f"**{leader}**", member=f"**{picked}**", + names=names, vehicle=vehicle, + ) + + getaway_pool = "getaway_success" if success else "getaway_fail" + + return [ + fill(random.choice(story["arrival"])), + fill(random.choice(story[f"entry_{approach}"])), + fill(random.choice(story["inside"])), + fill(random.choice(story["vault"])), + fill(random.choice(story["vault_open"])), + fill(random.choice(story["police_inbound"])), + fill(random.choice(story[getaway_pool])), + fill(random.choice(story["escape_success" if success else "escape_fail"])), + ] + + +class HeistLobbyView(discord.ui.View): + def __init__(self, organizer: discord.Member): + super().__init__(timeout=_HEIST_JOIN_WINDOW) + self.organizer = organizer + self.participants: list[discord.Member] = [organizer] + self.message: discord.Message | None = None + self.resolved = False + + def _chance(self) -> float: + n = len(self.participants) + return min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE) + + def _lobby_embed(self) -> discord.Embed: + names = "\n".join(f"• {p.display_name}" for p in self.participants) + desc = S.HEIST_UI["lobby_desc"].format( + n=len(self.participants), max=_HEIST_MAX_PLAYERS, + names=names, chance=int(self._chance() * 100), + ts=int(self._timeout_expiry()), + ) + return discord.Embed(title=S.TITLE["heist_lobby"], description=desc, color=0xE67E22) + + def _timeout_expiry(self) -> float: + import time + return time.time() + (self.timeout or 0) + + @discord.ui.button(label=S.HEIST_UI["btn_join"], style=discord.ButtonStyle.danger) + async def join(self, interaction: discord.Interaction, _: discord.ui.Button): + if any(p.id == interaction.user.id for p in self.participants): + await interaction.response.send_message(S.HEIST_UI["already_joined"], ephemeral=True) + return + if len(self.participants) >= _HEIST_MAX_PLAYERS: + await interaction.response.send_message(S.ERR["heist_full"], ephemeral=True) + return + if interaction.user.id in _active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + res = await economy.do_heist_check(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + else: + await interaction.response.send_message( + S.CD_MSG["heist"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + return + self.participants.append(interaction.user) + _active_games.add(interaction.user.id) + await interaction.response.edit_message(embed=self._lobby_embed()) + + @discord.ui.button(label=S.HEIST_UI["btn_start"], style=discord.ButtonStyle.success) + async def start_now(self, interaction: discord.Interaction, _: discord.ui.Button): + if interaction.user.id != self.organizer.id: + await interaction.response.send_message(S.HEIST_UI["only_organizer"], ephemeral=True) + return + if len(self.participants) < _HEIST_MIN_PLAYERS: + await interaction.response.send_message( + S.ERR["heist_min_players"].format(min=_HEIST_MIN_PLAYERS), ephemeral=True + ) + return + await self._resolve(interaction) + + async def _resolve(self, interaction: discord.Interaction | None = None) -> None: + global _active_heist + if self.resolved: + return + self.resolved = True + _active_heist = None + self.stop() + self.clear_items() + + for p in self.participants: + _active_games.discard(p.id) + + n = len(self.participants) + channel = (interaction.channel if interaction + else self.message.channel if self.message else None) + + if n < _HEIST_MIN_PLAYERS: + embed = discord.Embed( + title=S.TITLE["heist_cancel"], + description=S.HEIST_UI["cancel_desc"].format(min=_HEIST_MIN_PLAYERS), + color=0x99AAB5, + ) + if interaction and not interaction.response.is_done(): + await interaction.response.edit_message(embed=embed, view=self) + elif self.message: + try: + await self.message.edit(embed=embed, view=self) + except discord.HTTPException: + pass + return + + # Pre-roll outcome so story ending matches result + success = random.random() < self._chance() + story_lines = _build_heist_story(self.participants, success) + + # Close lobby message - remove buttons, mark as started + lobby_done = discord.Embed( + title=S.HEIST_UI["started_title"], + description=S.HEIST_UI["started_desc"].format(n=n), + color=0x99AAB5, + ) + if interaction and not interaction.response.is_done(): + await interaction.response.edit_message(embed=lobby_done, view=self) + elif self.message: + try: + await self.message.edit(embed=lobby_done, view=self) + except discord.HTTPException: + pass + + # Send story message and reveal line by line + if channel: + story_embed = discord.Embed(title=S.HEIST_UI["story_title"], description="", color=0xE67E22) + story_msg = await channel.send(embed=story_embed) + accumulated = "" + for i, line in enumerate(story_lines): + await asyncio.sleep(random.uniform(3.0, 4.5)) + accumulated += ("\n\n" if i > 0 else "") + line + story_embed.description = accumulated + try: + await story_msg.edit(embed=story_embed) + except discord.HTTPException: + pass + await asyncio.sleep(2.0) + + # Apply economy changes + res = await economy.do_heist_resolve([p.id for p in self.participants], success) + payout_each = res["payout_each"] + names_str = "\n".join(f"• {p.display_name}" for p in self.participants) + guild = (interaction.guild if interaction + else self.message.guild if self.message else None) + + if success: + result_desc = S.HEIST_UI["win_desc"].format(names=names_str, payout=_coin(payout_each)) + result_embed = discord.Embed( + title=S.TITLE["heist_win"], description=result_desc, color=0x57F287 + ) + for p in self.participants: + exp_res = await economy.award_exp(p.id, economy.EXP_REWARDS["heist_win"]) + if exp_res["old_level"] != exp_res["new_level"] and guild: + gm = guild.get_member(p.id) + if gm: + asyncio.create_task(_ensure_level_role(gm, exp_res["new_level"])) + else: + result_desc = S.HEIST_UI["fail_desc"].format(names=names_str) + result_embed = discord.Embed( + title=S.TITLE["heist_fail"], description=result_desc, color=0xED4245 + ) + + # Set global server cooldown (persisted to PocketBase via house record) + await economy.set_heist_global_cd(time.time() + _HEIST_GLOBAL_CD) + + # Post result as a NEW message so it appears at the bottom of the channel + if channel: + await channel.send(embed=result_embed) + elif self.message: + try: + await self.message.channel.send(embed=result_embed) + except discord.HTTPException: + pass + + async def on_timeout(self) -> None: + await self._resolve() + + +@tree.command(name="heist", description=S.CMD["heist"]) +@app_commands.guild_only() +async def cmd_heist(interaction: discord.Interaction): + global _active_heist + if _active_heist is not None: + await interaction.response.send_message(S.ERR["heist_active"], ephemeral=True) + return + _heist_cd = await economy.get_heist_global_cd() + if time.time() < _heist_cd: + await interaction.response.send_message( + S.CD_MSG["heist_global"].format(ts=_cd_ts(datetime.timedelta(seconds=_heist_cd - time.time()))), + ephemeral=True, + ) + return + if interaction.user.id in _active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + res = await economy.do_heist_check(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + return + + view = HeistLobbyView(interaction.user) + _active_heist = view + _active_games.add(interaction.user.id) + await interaction.response.send_message(embed=view._lobby_embed(), view=view) + view.message = await interaction.original_response() + + +# --------------------------------------------------------------------------- +# /jailbreak - Monopoly-style dice escape +# --------------------------------------------------------------------------- +_DICE_EMOJI = [ + "<:TipiYKS:1483103190491856916>", + "<:TipiKAKS:1483103215841972404>", + "<:TipiKOLM:1483103217846980781>", + "<:TipiNELI:1483103237585240114>", + "<:TipiVIIS:1483103239036469289>", + "<:TipiKUUS:1483103253163020348>", +] + + +class JailbreakView(discord.ui.View): + MAX_TRIES = 3 + + def __init__(self, user_id: int): + super().__init__(timeout=120) + self.user_id = user_id + self.tries = 0 + self._die1: int | None = None + self._refresh() + + def _refresh(self): + self.clear_items() + if self._die1 is None: + label = S.JAILBREAK_UI["btn_die1"].format(try_=self.tries + 1, max=self.MAX_TRIES) + else: + label = S.JAILBREAK_UI["btn_die2"] + btn = discord.ui.Button(label=label, style=discord.ButtonStyle.primary) + btn.callback = self._on_roll + self.add_item(btn) + + async def _on_roll(self, interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + + if self._die1 is None: + self._die1 = random.randint(1, 6) + e1 = _DICE_EMOJI[self._die1 - 1] + self._refresh() + embed = discord.Embed( + title=S.TITLE["jailbreak"], + description=S.JAILBREAK_UI["die1_desc"].format(die=e1), + color=0xF4C430, + ) + await interaction.response.edit_message(embed=embed, view=self) + else: + d1, d2 = self._die1, random.randint(1, 6) + e1, e2 = _DICE_EMOJI[d1 - 1], _DICE_EMOJI[d2 - 1] + double = d1 == d2 + self._die1 = None + self.tries += 1 + tries_left = self.MAX_TRIES - self.tries + + if double: + await economy.do_jail_free(self.user_id) + self.clear_items() + embed = discord.Embed( + title=S.TITLE["jailbreak_free"], + description=S.JAILBREAK_UI["free_desc"].format(d1=e1, d2=e2), + color=0x57F287, + ) + await interaction.response.edit_message(embed=embed, view=self) + self.stop() + elif tries_left == 0: + self.stop() + user_data = await economy.get_user(self.user_id) + bal = user_data["balance"] + if bal >= economy.MIN_BAIL: + min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) + max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) + desc = S.JAILBREAK_UI["fail_bail_offer"].format( + d1=e1, d2=e2, min=_coin(min_fine), max=_coin(max_fine), bal=_coin(bal) + ) + embed = discord.Embed(title=S.TITLE["jailbreak_fail"], description=desc, color=0xED4245) + await interaction.response.edit_message(embed=embed, view=BailView(self.user_id)) + else: + embed = discord.Embed( + title=S.TITLE["jailbreak_fail"], + description=S.JAILBREAK_UI["fail_broke_desc"].format(d1=e1, d2=e2, balance=_coin(bal)), + color=0xED4245, + ) + await interaction.response.edit_message(embed=embed, view=None) + else: + self._refresh() + embed = discord.Embed( + title=S.TITLE["jailbreak_miss"].format(tries=self.tries, max=self.MAX_TRIES), + description=S.JAILBREAK_UI["miss_desc"].format(d1=e1, d2=e2, left=tries_left), + color=0xF4C430, + ) + await interaction.response.edit_message(embed=embed, view=self) + + +class BailView(discord.ui.View): + def __init__(self, user_id: int): + super().__init__(timeout=60) + self.user_id = user_id + + @discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger) + async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + res = await economy.do_bail(self.user_id) + self.clear_items() + self.stop() + if not res["ok"] and res.get("reason") == "broke": + embed = discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=S.JAILBREAK_UI["bail_broke_desc"].format(min=_coin(economy.MIN_BAIL), balance=_coin(res["balance"])), + color=0xED4245, + ) + else: + embed = discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=S.JAILBREAK_UI["bail_paid_desc"].format(fine=_coin(res["fine"]), balance=_coin(res["balance"])), + color=0x57F287, + ) + await interaction.response.edit_message(embed=embed, view=self) + + +@tree.command(name="jailbreak", description=S.CMD["jailbreak"]) +async def cmd_jailbreak(interaction: discord.Interaction): + user_data = await economy.get_user(interaction.user.id) + remaining = economy._is_jailed(user_data) + if not remaining: + await interaction.response.send_message(S.ERR["not_jailed"], ephemeral=True) + return + + if user_data.get("jailbreak_used", False): + bal = user_data["balance"] + min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) + max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) + if bal >= economy.MIN_BAIL: + desc = S.JAILBREAK_UI["already_bail"].format(min=_coin(min_fine), max=_coin(max_fine), bal=_coin(bal), ts=_cd_ts(remaining)) + await interaction.response.send_message( + embed=discord.Embed(title=S.TITLE["jailbreak_bail"], description=desc, color=0xED4245), + view=BailView(interaction.user.id), ephemeral=True, + ) + else: + desc = S.JAILBREAK_UI["already_broke"].format(min=_coin(economy.MIN_BAIL), bal=_coin(bal), ts=_cd_ts(remaining)) + await interaction.response.send_message( + embed=discord.Embed(title=S.TITLE["jailbreak_bail"], description=desc, color=0xED4245), + ephemeral=True, + ) + return + + await economy.set_jailbreak_used(interaction.user.id) + embed = discord.Embed( + title=S.TITLE["jailbreak"], + description=S.JAILBREAK_UI["intro_desc"].format(ts=_cd_ts(remaining), tries=JailbreakView.MAX_TRIES), + color=0xF4C430, + ) + await interaction.response.send_message( + embed=embed, view=JailbreakView(interaction.user.id), ephemeral=True + ) + + +# --------------------------------------------------------------------------- +# /roulette animation +# --------------------------------------------------------------------------- +_ROULETTE_R = "\U0001f534" # 🔴 +_ROULETTE_B = "\u26ab" # ⚫ +_ROULETTE_G = "\U0001f7e2" # 🟢 +# delays between frames, fast → slow (12 transitions = 13 total viewport positions) +_ROULETTE_WHEEL_DELAYS = [0.15, 0.15, 0.18, 0.20, 0.22, 0.25, 0.28, 0.35, 0.45, 0.60, 0.80, 1.00] + + +def _build_roulette_strip(result_emoji: str) -> list[str]: + """Build a 17-symbol wheel strip obeying strict transition rules: + R → B or G (R can go to either) + B → R (B must go to R) + G → B (G must go to B) + Result is at strip[14] = center of the final viewport. + Prefix (0-13) is generated backward from the result; + suffix (15-16) is generated forward from the result. + Greens appear randomly in the prefix as near-miss elements (up to 2). + """ + R, B, G = _ROULETTE_R, _ROULETTE_B, _ROULETTE_G + strip: list[str] = [None] * 17 # type: ignore[list-item] + + # ── Suffix: positions 15-16 (deterministic, no greens after result) ── + strip[14] = result_emoji + strip[15] = R if result_emoji == B else B # B→R, R→B, G→B + strip[16] = B if strip[15] == R else R # R→B, B→R + + # ── Prefix: positions 0-13 built backward from result ── + # Inverse transitions: pred(R)=B, pred(B)=R or G, pred(G)=R + # First pass: collect positions where a green is valid (cur == B, with room for pred). + # Green is only relevant when result is not green itself. + green_pos: int | None = None + if result_emoji != G: + candidates: list[int] = [] + cur = result_emoji + for i in range(13, -1, -1): + if cur == B and 2 <= i <= 11: + candidates.append(i) + cur = B if cur == R else (R if cur == G else R) + if candidates: + green_pos = random.choice(candidates) + + # Second pass: generate strip, inserting green at the chosen position. + cur = result_emoji + for i in range(13, -1, -1): + if cur == R: + strip[i] = B + elif cur == G: + strip[i] = R + else: # cur == B + strip[i] = G if i == green_pos else R + cur = strip[i] + + return strip + + +def _roulette_frame_embed(symbols: list[str], stopped: bool = False) -> discord.Embed: + title = S.ROULETTE["spin_stop"] if stopped else S.ROULETTE["spin_title"] + desc = S.ROULETTE["spin_strip"].format( + s0=symbols[0], s1=symbols[1], s2=symbols[2], s3=symbols[3], s4=symbols[4] + ) + return discord.Embed(title=title, description=desc, color=0x99AAB5) + + +@tree.command(name="roulette", description=S.CMD["roulette"]) +@app_commands.describe(panus=S.OPT["roulette_panus"], värv=S.OPT["roulette_värv"]) +@app_commands.choices(värv=[ + app_commands.Choice(name="🔴 Punane", value="punane"), + app_commands.Choice(name="⚫ Must", value="must"), + app_commands.Choice(name="🟢 Roheline", value="roheline"), +]) +async def cmd_roulette(interaction: discord.Interaction, panus: str, värv: app_commands.Choice[str]): + _data = await economy.get_user(interaction.user.id) + panus_int, _err = _parse_amount(panus, _data["balance"]) + if _err: + await interaction.response.send_message(_err, ephemeral=True) + return + if panus_int <= 0: + await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) + return + + if interaction.user.id in _active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + _active_games.add(interaction.user.id) + res = await economy.do_roulette(interaction.user.id, panus_int, värv.value) + if not res["ok"]: + _active_games.discard(interaction.user.id) + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + else: + await interaction.response.send_message( + S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True + ) + return + + # ── Spin animation ──────────────────────────────────────────────────── + result_emoji = S.ROULETTE["emoji"][res["result"]] + strip = _build_roulette_strip(result_emoji) + + try: + await interaction.response.send_message(embed=_roulette_frame_embed(strip[0:5])) + spin_msg = await interaction.original_response() + + for i, delay in enumerate(_ROULETTE_WHEEL_DELAYS, 1): + await asyncio.sleep(delay) + stopped = i == len(_ROULETTE_WHEEL_DELAYS) + await spin_msg.edit(embed=_roulette_frame_embed(strip[i:i + 5], stopped=stopped)) + + await asyncio.sleep(0.55) + + # ── Final result embed ──────────────────────────────────────────────── + emoji = S.ROULETTE["emoji"].get(res["result"], "🎰") + genitive = S.ROULETTE["genitive"].get(res["result"], res["result"]) + if res["won"]: + mult_str = f" · **{res['mult']}x**" if res["mult"] > 1 else "" + embed = discord.Embed( + title=S.ROULETTE["win_title"].format(emoji=emoji), + description=S.ROULETTE["win_desc"].format( + genitive=genitive, mult=mult_str, + change=_coin(res["change"]), balance=_coin(res["balance"]), + ), + color=0x57F287, + ) + asyncio.create_task(_award_exp(interaction, economy.gamble_exp(panus_int))) + else: + embed = discord.Embed( + title=S.ROULETTE["lose_title"].format(emoji=emoji), + description=S.ROULETTE["lose_desc"].format( + genitive=genitive, + change=_coin(abs(res["change"])), balance=_coin(res["balance"]), + ), + color=0xED4245, + ) + await spin_msg.edit(embed=embed) + finally: + _active_games.discard(interaction.user.id) + + +@tree.command(name="give", description=S.CMD["give"]) +@app_commands.describe(kasutaja=S.OPT["give_kasutaja"], summa=S.OPT["give_summa"]) +async def cmd_give(interaction: discord.Interaction, kasutaja: discord.Member, summa: str): + _data = await economy.get_user(interaction.user.id) + summa_int, _err = _parse_amount(summa, _data["balance"]) + if _err: + await interaction.response.send_message(_err, ephemeral=True) + return + if summa_int <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + if kasutaja.id == interaction.user.id: + await interaction.response.send_message(S.ERR["give_self"], ephemeral=True) + return + if kasutaja.bot: + await interaction.response.send_message(S.ERR["give_bot"], ephemeral=True) + return + + res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message(S.ERR["give_jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True) + else: + await interaction.response.send_message( + S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True + ) + return + + embed = discord.Embed( + title=f"{economy.COIN} {S.TITLE['give']}", + description=S.GIVE_UI["desc"].format(giver=interaction.user.display_name, amount=_coin(summa_int), receiver=kasutaja.display_name), + color=0xF4C430, + ) + await interaction.response.send_message(embed=embed) + + +class LeaderboardView(discord.ui.View): + PER_PAGE = 10 + + def __init__( + self, + regular: list[tuple[str, int]], + house_entry: tuple[str, int] | None, + guild: discord.Guild | None, + bot_user: discord.ClientUser | None, + exp_entries: list[tuple[str, int, int]] | None = None, + ): + super().__init__(timeout=120) + self.regular = regular + self.house_entry = house_entry + self.guild = guild + self.bot_user = bot_user + self.exp_entries = exp_entries or [] + self.page = 0 + self.mode = "coins" # "coins" or "exp" + self.max_page = max(0, (len(regular) - 1) // self.PER_PAGE) if regular else 0 + self._update_buttons() + + def _current_list(self): + return self.regular if self.mode == "coins" else self.exp_entries + + def _update_buttons(self): + current = self._current_list() + self.max_page = max(0, (len(current) - 1) // self.PER_PAGE) if current else 0 + self.prev_btn.disabled = self.page == 0 + self.next_btn.disabled = self.page >= self.max_page + self.coins_btn.style = discord.ButtonStyle.primary if self.mode == "coins" else discord.ButtonStyle.secondary + self.exp_btn.style = discord.ButtonStyle.primary if self.mode == "exp" else discord.ButtonStyle.secondary + + def _make_embed(self, highlight_uid: int | None = None) -> discord.Embed: + if self.mode == "coins": + embed = discord.Embed(title=f"{economy.COIN} {S.TITLE['leaderboard_coins']}", color=0xF4C430) + else: + embed = discord.Embed(title=S.TITLE["leaderboard_exp"], color=0x5865F2) + lines = [] + + if self.mode == "coins" and self.page == 0 and self.house_entry: + _, bal = self.house_entry + house_name = self.bot_user.display_name if self.bot_user else "TipiBOT" + lines.append(S.LEADERBOARD_UI["house_entry"].format(name=house_name, balance=_coin(bal))) + lines.append("") + + start = self.page * self.PER_PAGE + medals = ["🥇", "🥈", "🥉"] + current = self._current_list() + slice_ = current[start:start + self.PER_PAGE] + if not slice_: + lines.append(S.LEADERBOARD_UI["no_entries"]) + else: + for i, entry in enumerate(slice_): + rank = start + i + uid = entry[0] + prefix = medals[rank] if rank < 3 else f"**{rank + 1}.**" + if self.guild: + member = self.guild.get_member(int(uid)) + name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) + else: + name = S.LEADERBOARD_UI["unknown_user"].format(uid=uid) + if highlight_uid and int(uid) == highlight_uid: + name = f"**› {name} ‹**" + if self.mode == "coins": + lines.append(f"{prefix} {name} - {_coin(entry[1])}") + else: + lines.append(S.LEADERBOARD_UI["exp_entry"].format(prefix=prefix, name=name, exp=entry[1], level=entry[2])) + + total = self.max_page + 1 + embed.description = "\n".join(lines) + embed.set_footer(text=S.LEADERBOARD_UI["footer"].format(page=self.page + 1, total=total, count=len(current))) + return embed + + @discord.ui.button(label="◄", style=discord.ButtonStyle.secondary) + async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page -= 1 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label="►", style=discord.ButtonStyle.secondary) + async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page += 1 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_coins"], style=discord.ButtonStyle.primary) + async def coins_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "coins" + self.page = 0 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_exp"], style=discord.ButtonStyle.secondary) + async def exp_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "exp" + self.page = 0 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_find_me"], style=discord.ButtonStyle.secondary) + async def find_me_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + uid = interaction.user.id + for i, entry in enumerate(self._current_list()): + if int(entry[0]) == uid: + self.page = i // self.PER_PAGE + self._update_buttons() + await interaction.response.edit_message( + embed=self._make_embed(highlight_uid=uid), view=self + ) + return + await interaction.response.send_message( + S.ERR["not_in_leaderboard"], ephemeral=True + ) + + async def on_timeout(self): + for child in self.children: + child.disabled = True + + +@tree.command(name="leaderboard", description=S.CMD["leaderboard"]) +async def cmd_leaderboard(interaction: discord.Interaction): + await interaction.response.defer() + all_entries = await economy.get_leaderboard(top_n=None) + exp_entries_raw = await economy.get_leaderboard_exp(top_n=None) + + house_entry = None + regular = [] + for uid, bal in all_entries: + if bot.user and int(uid) == bot.user.id: + house_entry = (uid, bal) + else: + regular.append((uid, bal)) + + exp_entries = [e for e in exp_entries_raw if not (bot.user and int(e[0]) == bot.user.id)] + view = LeaderboardView(regular, house_entry, interaction.guild, bot.user, exp_entries) + await interaction.followup.send(embed=view._make_embed(), view=view) + + +def _shop_embed(tier: int, user_data: dict) -> discord.Embed: + owned = set(user_data.get("items", [])) + item_uses = user_data.get("item_uses", {}) + _tier_names = {1: S.SHOP_UI["tier_1"], 2: S.SHOP_UI["tier_2"], 3: S.SHOP_UI["tier_3"]} + embed = discord.Embed( + title=f"{economy.COIN} TipiBOTi pood · {_tier_names[tier]}", + description=S.SHOP_UI["desc"].format(bal=_coin(user_data["balance"])), + color=[0x57F287, 0xF4C430, 0xED4245][tier - 1], + ) + for item_id in sorted(economy.SHOP_TIERS[tier], key=lambda k: economy.SHOP[k]["cost"]): + item = economy.SHOP[item_id] + anticheat_uses = item_uses.get("anticheat", 0) if item_id == "anticheat" else 0 + min_lvl = economy.SHOP_LEVEL_REQ.get(item_id, 0) + user_lvl = economy.get_level(user_data.get("exp", 0)) + if item_id in owned and not (item_id == "anticheat" and anticheat_uses <= 0): + if item_id == "anticheat": + _key = "owned_uses_1" if anticheat_uses == 1 else "owned_uses_n" + status = S.SHOP_UI[_key].format(uses=anticheat_uses) + else: + status = S.SHOP_UI["owned"] + elif min_lvl > 0 and user_lvl < min_lvl: + status = S.SHOP_UI["locked"].format(min_lvl=min_lvl, user_lvl=user_lvl) + else: + status = f"{item['cost']} {economy.COIN}" + embed.add_field( + name=f"{item['emoji']} {item['name']} · {status}", + value=item["description"], + inline=False, + ) + return embed + + +class ShopView(discord.ui.View): + def __init__(self, user_data: dict, tier: int = 1): + super().__init__(timeout=120) + self._user_data = user_data + self._tier = tier + self._update_buttons() + + def _update_buttons(self): + self.clear_items() + for t, label in [(1, S.SHOP_BTN[1]), (2, S.SHOP_BTN[2]), (3, S.SHOP_BTN[3])]: + btn = discord.ui.Button( + label=label, + style=discord.ButtonStyle.primary if t == self._tier else discord.ButtonStyle.secondary, + custom_id=f"shop_tier_{t}", + ) + btn.callback = self._make_callback(t) + self.add_item(btn) + + def _make_callback(self, tier: int): + async def callback(interaction: discord.Interaction): + self._tier = tier + self._update_buttons() + self._user_data = await economy.get_user(interaction.user.id) + await interaction.response.edit_message( + embed=_shop_embed(self._tier, self._user_data), view=self + ) + return callback + + +@tree.command(name="shop", description=S.CMD["shop"]) +async def cmd_shop(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + await interaction.response.send_message( + embed=_shop_embed(1, data), view=ShopView(data, tier=1), ephemeral=True + ) + + +@tree.command(name="buy", description=S.CMD["buy"]) +@app_commands.describe(ese=S.OPT["buy_ese"]) +@app_commands.choices(ese=[ + app_commands.Choice(name=f"{v['name']} ({v['cost']} TipiCOINi)", value=k) + for k, v in economy.SHOP.items() +]) +async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]): + res = await economy.do_buy(interaction.user.id, ese.value) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "owned": + await interaction.response.send_message(S.ERR["item_owned"], ephemeral=True) + elif res["reason"] == "level_required": + await interaction.response.send_message( + S.ERR["item_level_req"].format(min_level=res["min_level"], user_level=res["user_level"]), + ephemeral=True, + ) + elif res["reason"] == "insufficient": + await interaction.response.send_message( + S.ERR["broke_need"].format(need=_coin(res["need"])), ephemeral=True + ) + else: + await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True) + return + + item = res["item"] + embed = discord.Embed( + title=S.BUY_UI["title"].format(emoji=item["emoji"], name=item["name"]), + description=S.BUY_UI["desc"].format(description=item["description"], balance=_coin(res["balance"])), + color=0x57F287, + ) + await interaction.response.send_message(embed=embed) + + +# --------------------------------------------------------------------------- +# Rock Paper Scissors (vs Bot OR PvP) +# --------------------------------------------------------------------------- +_RPS_CHOICES = S.RPS_CHOICES +_RPS_BEATS = {"🪨": "✂️", "📄": "🪨", "✂️": "📄"} + + +# ── Single-player (vs bot) ────────────────────────────────────────────────── +class RPSView(discord.ui.View): + def __init__(self, challenger: discord.User, bet: int = 0): + super().__init__(timeout=60) + self.challenger = challenger + self.bet = bet + + async def _resolve(self, interaction: discord.Interaction, player_pick: str): + if interaction.user.id != self.challenger.id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + self.stop() + _active_games.discard(self.challenger.id) + bot_pick = random.choice(list(_RPS_CHOICES)) + p_name = _RPS_CHOICES[player_pick] + b_name = _RPS_CHOICES[bot_pick] + if player_pick == bot_pick: + outcome, result, color = "tie", S.RPS_UI["result_tie"], 0x99AAB5 + elif _RPS_BEATS[player_pick] == bot_pick: + outcome, result, color = "win", S.RPS_UI["result_win"], 0x57F287 + else: + outcome, result, color = "lose", S.RPS_UI["result_lose"], 0xED4245 + + bet_line = "" + if self.bet > 0: + res = await economy.do_game_bet(interaction.user.id, self.bet, outcome) + if outcome == "win": + bet_line = S.RPS_UI["bet_win"].format(amount=_coin(self.bet), balance=_coin(res["balance"])) + elif outcome == "lose": + bet_line = S.RPS_UI["bet_lose"].format(amount=_coin(self.bet), balance=_coin(res["balance"])) + else: + bet_line = S.RPS_UI["bet_tie"].format(balance=_coin(res["balance"])) + + embed = discord.Embed( + title=S.TITLE["rps"], + description=S.RPS_UI["result_desc"].format(player_pick=player_pick, player_name=p_name, bot_pick=bot_pick, bot_name=b_name, result=result, bet_line=bet_line), + color=color, + ) + await interaction.response.edit_message(embed=embed, view=None) + + @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) + async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._resolve(interaction, "🪨") + + @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) + async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._resolve(interaction, "📄") + + @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) + async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._resolve(interaction, "✂️") + + async def on_timeout(self): + _active_games.discard(self.challenger.id) + for item in self.children: + item.disabled = True + + +# ── PvP ───────────────────────────────────────────────────────────────────── +class RpsGame: + """Shared mutable state for a PvP RPS match.""" + + def __init__(self, player_a: discord.Member, player_b: discord.Member, bet: int): + self.player_a = player_a + self.player_b = player_b + self.bet = bet + self.choice_a: str | None = None + self.choice_b: str | None = None + self.dm_msg_a: discord.Message | None = None + self.dm_msg_b: discord.Message | None = None + self.server_message: discord.Message | None = None + self._resolved = False + self._lock = asyncio.Lock() + + async def maybe_resolve(self) -> None: + async with self._lock: + if self._resolved or self.choice_a is None or self.choice_b is None: + return + self._resolved = True + + a, b = self.choice_a, self.choice_b + if a == b: + winner, color = None, 0x99AAB5 + result_a = result_b = S.RPS_UI["result_tie"] + elif _RPS_BEATS[a] == b: + winner, color = "a", 0x57F287 + result_a = S.RPS_UI["result_win"] + result_b = f"❌ {self.player_a.display_name} võitis." + else: + winner, color = "b", 0xED4245 + result_a = f"❌ {self.player_b.display_name} võitis." + result_b = S.RPS_UI["result_win"] + + bet_line_a = bet_line_b = "" + if self.bet > 0: + if winner == "a": + res = await economy.do_give(self.player_b.id, self.player_a.id, self.bet) + elif winner == "b": + res = await economy.do_give(self.player_a.id, self.player_b.id, self.bet) + else: + res = {"ok": True} + + if self.bet > 0 and winner is not None: + if res.get("ok"): + bet_line_a = f"\n{'+' if winner == 'a' else '-'}{_coin(self.bet)}" + bet_line_b = f"\n{'+' if winner == 'b' else '-'}{_coin(self.bet)}" + else: + bet_line_a = bet_line_b = S.RPS_UI["duel_broke"] + + data_a = await economy.get_user(self.player_a.id) + data_b = await economy.get_user(self.player_b.id) + bal_a, bal_b = data_a["balance"], data_b["balance"] + + if self.dm_msg_a: + await self.dm_msg_a.edit( + content=S.RPS_UI["duel_result_a"].format( + opponent=self.player_b.display_name, pick_a=a, name_a=_RPS_CHOICES[a], + pick_b=b, name_b=_RPS_CHOICES[b], result=result_a, bet_line=bet_line_a, balance=_coin(bal_a) + ), + view=None, + ) + if self.dm_msg_b: + await self.dm_msg_b.edit( + content=S.RPS_UI["duel_result_a"].format( + opponent=self.player_a.display_name, pick_a=b, name_a=_RPS_CHOICES[b], + pick_b=a, name_b=_RPS_CHOICES[a], result=result_b, bet_line=bet_line_b, balance=_coin(bal_b) + ), + view=None, + ) + + if self.server_message: + if winner == "a": + verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_a.display_name) + elif winner == "b": + verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_b.display_name) + else: + verdict = S.RPS_UI["duel_verdict_tie"] + embed = discord.Embed( + title=S.TITLE["rps_duel_done"], + description=S.RPS_UI["duel_done_desc"].format( + a=self.player_a.mention, pick_a=a, pick_b=b, b=self.player_b.mention, + verdict=verdict, name_a=self.player_a.display_name, bal_a=_coin(bal_a), + name_b=self.player_b.display_name, bal_b=_coin(bal_b) + ), + color=color, + ) + await self.server_message.edit(embed=embed, view=None) + _active_games.discard(self.player_a.id) + _active_games.discard(self.player_b.id) + + +class RpsDmView(discord.ui.View): + """DM view for each player to make their pick in a PvP match.""" + + def __init__(self, game: RpsGame, side: str): + super().__init__(timeout=120) + self.game = game + self.side = side + + async def _pick(self, interaction: discord.Interaction, choice: str) -> None: + if self.side == "a": + self.game.choice_a = choice + else: + self.game.choice_b = choice + for item in self.children: + item.disabled = True + self.stop() + await interaction.response.edit_message( + content=S.RPS_UI["duel_waiting"].format(choice=choice, name=_RPS_CHOICES[choice]), + view=self, + ) + await self.game.maybe_resolve() + + async def on_timeout(self) -> None: + async with self.game._lock: + if self.game._resolved: + return + self.game._resolved = True + _active_games.discard(self.game.player_a.id) + _active_games.discard(self.game.player_b.id) + for item in self.children: + item.disabled = True + for player in (self.game.player_a, self.game.player_b): + try: + await player.send(S.RPS_UI["duel_expire_dm"]) + except discord.Forbidden: + pass + if self.game.server_message: + embed = discord.Embed( + title=S.TITLE["rps_duel_expire"], + description=S.RPS_UI["duel_expire_desc"].format(a=self.game.player_a.mention, b=self.game.player_b.mention), + color=0x99AAB5, + ) + await self.game.server_message.edit(embed=embed, view=None) + + @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) + async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._pick(interaction, "🪨") + + @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) + async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._pick(interaction, "📄") + + @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) + async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._pick(interaction, "✂️") + + +class RpsChallengeView(discord.ui.View): + """Server-side accept/decline view for PvP RPS challenge.""" + + def __init__(self, game: RpsGame): + super().__init__(timeout=60) + self.game = game + + def _disable_all(self) -> None: + for item in self.children: + item.disabled = True + + @discord.ui.button(label=S.RPS_UI["btn_accept"], style=discord.ButtonStyle.success) + async def accept(self, interaction: discord.Interaction, _: discord.ui.Button): + if interaction.user.id != self.game.player_b.id: + await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) + return + if self.game.player_b.id in _active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + self.stop() + self._disable_all() + _active_games.add(self.game.player_b.id) + + if self.game.bet > 0: + data_a = await economy.get_user(self.game.player_a.id) + data_b = await economy.get_user(self.game.player_b.id) + for player, data in ((self.game.player_a, data_a), (self.game.player_b, data_b)): + if data["balance"] < self.game.bet: + embed = discord.Embed( + title=S.TITLE["rps_duel_cancel"], + description=S.RPS_UI["duel_insufficient"].format(mention=player.mention), + color=0xED4245, + ) + await interaction.response.edit_message(embed=embed, view=None) + async with self.game._lock: + self.game._resolved = True + _active_games.discard(self.game.player_a.id) + _active_games.discard(self.game.player_b.id) + return + + bet_str = S.RPS_UI["duel_active_bet"].format(bet=_coin(self.game.bet)) if self.game.bet > 0 else "" + embed = discord.Embed( + title=S.TITLE["rps_duel_active"], + description=S.RPS_UI["duel_active_desc"].format(a=self.game.player_a.mention, b=self.game.player_b.mention, bet=bet_str), + color=0x5865F2, + ) + await interaction.response.edit_message(embed=embed, view=self) + + bet_dm = S.RPS_UI["duel_dm_bet"].format(bet=_coin(self.game.bet)) if self.game.bet > 0 else "" + dm_failed: list[str] = [] + for player, side in ((self.game.player_a, "a"), (self.game.player_b, "b")): + view = RpsDmView(self.game, side) + opponent = self.game.player_b if side == "a" else self.game.player_a + try: + msg = await player.send( + S.RPS_UI["duel_dm"].format(opponent=opponent.display_name, bet=bet_dm), + view=view, + ) + if side == "a": + self.game.dm_msg_a = msg + else: + self.game.dm_msg_b = msg + except discord.Forbidden: + dm_failed.append(player.display_name) + + if dm_failed: + async with self.game._lock: + self.game._resolved = True + _active_games.discard(self.game.player_a.id) + _active_games.discard(self.game.player_b.id) + embed = discord.Embed( + title=S.TITLE["rps_duel_cancel"], + description=S.RPS_UI["duel_dm_fail"].format(names=", ".join(dm_failed)), + color=0xED4245, + ) + await self.game.server_message.edit(embed=embed, view=None) + + @discord.ui.button(label=S.RPS_UI["btn_decline"], style=discord.ButtonStyle.danger) + async def decline(self, interaction: discord.Interaction, _: discord.ui.Button): + if interaction.user.id != self.game.player_b.id: + await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) + return + self.stop() + self._disable_all() + _active_games.discard(self.game.player_a.id) + embed = discord.Embed( + title=S.TITLE["rps_duel_decline"], + description=S.RPS_UI["duel_decline"].format(name=self.game.player_b.display_name), + color=0xED4245, + ) + await interaction.response.edit_message(embed=embed, view=self) + + async def on_timeout(self) -> None: + _active_games.discard(self.game.player_a.id) + self._disable_all() + if self.game.server_message: + embed = discord.Embed( + title=S.TITLE["rps_duel_expire"], + description=S.RPS_UI["duel_no_answer"].format(name=self.game.player_b.display_name), + color=0x99AAB5, + ) + await self.game.server_message.edit(embed=embed, view=self) + + +@tree.command(name="rps", description=S.CMD["rps"]) +@app_commands.describe(panus=S.OPT["rps_panus"], vastane=S.OPT["rps_vastane"]) +async def cmd_rps(interaction: discord.Interaction, panus: str = "0", vastane: discord.Member | None = None): + _data = await economy.get_user(interaction.user.id) + panus_int, _err = _parse_amount(panus, _data["balance"]) + if _err: + await interaction.response.send_message(_err, ephemeral=True) + return + if panus_int < 0: + await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) + return + if panus_int > 0: + if rem := economy.jailed_remaining(_data): + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(rem)), ephemeral=True + ) + return + + # ── PvP mode ─ + if vastane is not None: + if interaction.user.id in _active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + if vastane.id == interaction.user.id: + await interaction.response.send_message(S.ERR["rps_self"], ephemeral=True) + return + if vastane.bot: + await interaction.response.send_message(S.ERR["rps_bot"], ephemeral=True) + return + if panus_int > 0 and _data["balance"] < panus_int: + await interaction.response.send_message( + S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True + ) + return + + game = RpsGame(interaction.user, vastane, panus_int) + bet_challenge = S.RPS_UI["challenge_bet"].format(bet=_coin(panus_int)) if panus_int > 0 else "" + embed = discord.Embed( + title=S.TITLE["rps_duel"], + description=S.RPS_UI["challenge_desc"].format(challenger=interaction.user.mention, opponent=vastane.mention, bet=bet_challenge), + color=0x5865F2, + ) + embed.set_footer(text=S.RPS_UI["challenge_footer"]) + challenge_view = RpsChallengeView(game) + await interaction.response.send_message(embed=embed, view=challenge_view) + _active_games.add(interaction.user.id) + game.server_message = await interaction.original_response() + return + + # ── vs Bot mode ────────────────────────────────────────────────────── + if panus_int > 0 and _data["balance"] < panus_int: + await interaction.response.send_message( + S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True + ) + return + if interaction.user.id in _active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + _active_games.add(interaction.user.id) + bet_str = S.RPS_UI["vs_bot_bet"].format(bet=_coin(panus_int)) if panus_int > 0 else "" + embed = discord.Embed( + title=S.TITLE["rps"], + description=S.RPS_UI["vs_bot_desc"] + bet_str, + color=0x5865F2, + ) + await interaction.response.send_message(embed=embed, view=RPSView(interaction.user, panus_int)) + + +# --------------------------------------------------------------------------- +# /slots +# --------------------------------------------------------------------------- +_SLOTS_SPIN = "" +_SLOTS_DELAY = 0.7 + + +def _slots_embed(r1: str, r2: str, r3: str, + title: str = "", # set dynamically + color: int = 0x5865F2, + footer: str = "") -> discord.Embed: + desc = f"{r1} | {r2} | {r3}" + if footer: + desc += f"\n\n{footer}" + return discord.Embed(title=title, description=desc, color=color) + + +@tree.command(name="slots", description=S.CMD["slots"]) +@app_commands.describe(panus=S.OPT["slots_panus"]) +async def cmd_slots(interaction: discord.Interaction, panus: str): + _data = await economy.get_user(interaction.user.id) + panus_int, _err = _parse_amount(panus, _data["balance"]) + if _err: + await interaction.response.send_message(_err, ephemeral=True) + return + if panus_int <= 0: + await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) + return + if interaction.user.id in _active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + _active_games.add(interaction.user.id) + res = await economy.do_slots(interaction.user.id, panus_int) + if not res["ok"]: + _active_games.discard(interaction.user.id) + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + return + if res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + return + await interaction.response.send_message( + S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True + ) + return + + reels = res["reels"] + tier = res["tier"] + change = res["change"] + sp = _SLOTS_SPIN + + # ── Animated reveal ──────────────────────────────────────────────────── + try: + await interaction.response.send_message(embed=_slots_embed(sp, sp, sp, title=S.SLOTS_UI["playing"])) + msg = await interaction.original_response() + + await asyncio.sleep(_SLOTS_DELAY) + await msg.edit(embed=_slots_embed(reels[0], sp, sp, title=S.SLOTS_UI["playing"])) + await asyncio.sleep(_SLOTS_DELAY) + await msg.edit(embed=_slots_embed(reels[0], reels[1], sp, title=S.SLOTS_UI["playing"])) + await asyncio.sleep(_SLOTS_DELAY) + await msg.edit(embed=_slots_embed(reels[0], reels[1], reels[2], title=S.SLOTS_UI["playing"])) + await asyncio.sleep(_SLOTS_DELAY * 0.6) + + # ── Final verdict ───────────────────────────────────────────────────── + tier_key = tier if tier in S.SLOTS_TIERS else "miss" + title, color = S.SLOTS_TIERS[tier_key] + if tier == "jackpot": + footer = S.SLOTS_UI["jackpot_footer"].format(change=_coin(change)) + elif tier == "triple": + footer = S.SLOTS_UI["triple_footer"].format(change=_coin(change)) + elif tier == "pair": + footer = S.SLOTS_UI["pair_footer"].format(change=_coin(change)) + else: + footer = S.SLOTS_UI["miss_footer"].format(amount=_coin(panus_int)) + footer += S.SLOTS_UI["balance_line"].format(balance=_coin(res["balance"])) + + await msg.edit(embed=_slots_embed(reels[0], reels[1], reels[2], + title=title, color=color, footer=footer)) + if tier in ("jackpot", "triple", "pair"): + asyncio.create_task(_award_exp(interaction, economy.gamble_exp(panus_int))) + finally: + _active_games.discard(interaction.user.id) + + +# --------------------------------------------------------------------------- +# /blackjack +# --------------------------------------------------------------------------- +_BJ_RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] +_BJ_SUITS = ["♠", "♥", "♦", "♣"] +_BJ_DEAL_DELAY = 0.65 + + +def _bj_deck() -> list[tuple[str, str]]: + deck = [(r, s) for r in _BJ_RANKS for s in _BJ_SUITS] + random.shuffle(deck) + return deck + + +def _bj_value(hand: list[tuple[str, str]]) -> int: + total, aces = 0, 0 + for rank, _ in hand: + if rank == "A": + total += 11 + aces += 1 + elif rank in ("J", "Q", "K", "10"): + total += 10 + else: + total += int(rank) + while total > 21 and aces: + total -= 10 + aces -= 1 + return total + + +def _bj_hand_str(hand: list[tuple[str, str]], hide_second: bool = False) -> str: + if hide_second and len(hand) >= 2: + return f"`{hand[0][0]}{hand[0][1]}` `🂠`" + return " ".join(f"`{r}{s}`" for r, s in hand) + + +def _bj_is_blackjack(hand: list[tuple[str, str]]) -> bool: + return len(hand) == 2 and _bj_value(hand) == 21 + + +def _bj_embed( + player_hand: list, + dealer_hand: list, + title: str, + color: int, + *, + hide_dealer: bool = True, + doubled_total: int = 0, + result_field: tuple | None = None, +) -> discord.Embed: + p_str = _bj_hand_str(player_hand) if player_hand else "-" + p_val = f" `{_bj_value(player_hand)}`" if player_hand else "" + if not dealer_hand: + d_str, d_val = "-", "" + elif hide_dealer: + d_str = _bj_hand_str(dealer_hand, hide_second=True) + d_val = f" `{_bj_value([dealer_hand[0]])}`" + else: + d_str = _bj_hand_str(dealer_hand) + d_val = f" `{_bj_value(dealer_hand)}`" + desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}\n**{S.BJ_UI['player']}:** {p_str}{p_val}" + if doubled_total: + desc += "\n" + S.BJ["doubled_label"].format(total=_coin(doubled_total)) + embed = discord.Embed(title=title, description=desc, color=color) + if result_field: + embed.add_field(name=result_field[0], value=result_field[1], inline=False) + return embed + + +class BlackjackView(discord.ui.View): + def __init__( + self, + user_id: int, + bet: int, + player_hand: list, + dealer_hand: list, + deck: list, + ): + super().__init__(timeout=120) + self.user_id = user_id + self.bet = bet # original per-hand bet + self.hands: list[list] = [player_hand] + self.bets: list[int] = [bet] + self.hand_idx: int = 0 + self.dealer_hand = dealer_hand + self.deck = deck + self._doubled_hands: set[int] = set() + self._split_aces: bool = False + self.message: discord.Message | None = None + self._refresh_buttons() + + @property + def _cur_hand(self) -> list: + return self.hands[self.hand_idx] + + def _can_split(self) -> bool: + return ( + len(self.hands) == 1 + and len(self._cur_hand) == 2 + and self._cur_hand[0][0] == self._cur_hand[1][0] + ) + + def _refresh_buttons(self) -> None: + self.clear_items() + is_split = len(self.hands) > 1 + can_double = ( + not is_split + and 0 not in self._doubled_hands + and len(self._cur_hand) == 2 + ) + hit_btn = discord.ui.Button(label=S.BJ["btn_hit"], style=discord.ButtonStyle.primary) + hit_btn.callback = self._hit + stand_btn = discord.ui.Button(label=S.BJ["btn_stand"], style=discord.ButtonStyle.secondary) + stand_btn.callback = self._stand + double_btn = discord.ui.Button( + label=S.BJ["btn_double"].format(bet=self.bet), + style=discord.ButtonStyle.success, + disabled=not can_double, + ) + double_btn.callback = self._double + self.add_item(hit_btn) + self.add_item(stand_btn) + self.add_item(double_btn) + if self._can_split(): + split_btn = discord.ui.Button( + label=S.BJ["btn_split"].format(bet=self.bet), + style=discord.ButtonStyle.danger, + ) + split_btn.callback = self._split_hand + self.add_item(split_btn) + + def _cur_embed(self, game_over: bool = False, hand_results: list | None = None) -> discord.Embed: + if not self.dealer_hand: + d_str, d_val = "-", "" + elif not game_over: + d_str = _bj_hand_str(self.dealer_hand, hide_second=True) + d_val = f" `{_bj_value([self.dealer_hand[0]])}`" + else: + d_str = _bj_hand_str(self.dealer_hand) + d_val = f" `{_bj_value(self.dealer_hand)}`" + desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}" + + if len(self.hands) == 1: + hand = self.hands[0] + pv = _bj_value(hand) + doubled_str = f" 💰 *{_coin(self.bets[0])}*" if 0 in self._doubled_hands else "" + desc += f"\n**{S.BJ_UI['player']}:** {_bj_hand_str(hand)} `{pv}`{doubled_str}" + else: + for i, hand in enumerate(self.hands): + pv = _bj_value(hand) + if hand_results and i < len(hand_results): + icon = {"win": "✅", "push": "🤝", "lose": "❌"}[hand_results[i]] + label = f"{icon} " + S.BJ_UI["hand_n"].format(n=i + 1) + elif game_over or i < self.hand_idx: + label = S.BJ_UI["hand_n"].format(n=i + 1) + elif i == self.hand_idx: + label = S.BJ_UI["hand_active"].format(n=i + 1) + else: + label = S.BJ_UI["hand_pending"].format(n=i + 1) + bust_str = S.BJ_UI["bust"] if pv > 21 else "" + desc += f"\n**{label}:** {_bj_hand_str(hand)} `{pv}`{bust_str}" + + return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2) + + async def _resolve_all(self, interaction: discord.Interaction) -> None: + _active_games.discard(self.user_id) + self.clear_items() + self.stop() + dv = _bj_value(self.dealer_hand) + total_payout = 0 + hand_results: list[str] = [] + + for hand, bet in zip(self.hands, self.bets): + pv = _bj_value(hand) + if pv > 21: + hand_results.append("lose") + elif dv > 21 or pv > dv: + hand_results.append("win") + total_payout += bet * 2 + elif pv == dv: + hand_results.append("push") + total_payout += bet + else: + hand_results.append("lose") + + total_invested = sum(self.bets) + res = await economy.do_blackjack_payout(self.user_id, total_payout, total_invested) + net = total_payout - total_invested + result_str = ( + f"+{_coin(total_payout)}" + if net > 0 + else (S.BJ["push_result"] if net == 0 else f"-{_coin(total_invested)}") + ) + + if len(self.hands) == 1: + r = hand_results[0] + doubled = 0 in self._doubled_hands + if r == "win": + title_key, color = ("blackjack_dwin" if doubled else "blackjack_win"), 0x57F287 + elif r == "push": + title_key, color = "blackjack_push", 0x99AAB5 + else: + pv = _bj_value(self.hands[0]) + if pv > 21: + title_key = "blackjack_dbust" if doubled else "blackjack_bust" + else: + title_key = "blackjack_lose" + color = 0xED4245 + else: + if net > 0: + title_key, color = "blackjack_win", 0x57F287 + elif net == 0: + title_key, color = "blackjack_push", 0x99AAB5 + else: + title_key, color = "blackjack_lose", 0xED4245 + + embed = self._cur_embed(game_over=True, hand_results=hand_results) + embed.title = S.TITLE[title_key] + embed.color = color + embed.add_field( + name=S.BJ["result_field"], + value=result_str + S.BJ_UI["balance_line"].format(balance=_coin(res["balance"])), + inline=False, + ) + await self.message.edit(embed=embed, view=self) + if total_payout > total_invested: + asyncio.create_task(_award_exp(interaction, economy.gamble_exp(total_invested))) + + async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None: + await self.message.edit(embed=self._cur_embed(game_over=True), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + while _bj_value(self.dealer_hand) < 17: + self.dealer_hand.append(self.deck.pop()) + await self.message.edit(embed=self._cur_embed(game_over=True), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + await self._resolve_all(interaction) + + async def _advance_or_finish(self, interaction: discord.Interaction) -> None: + self.hand_idx += 1 + if self.hand_idx < len(self.hands): + self._refresh_buttons() + await self.message.edit(embed=self._cur_embed(), view=self) + else: + self.hand_idx = len(self.hands) - 1 + await self._do_dealer_reveal(interaction) + + async def _hit(self, interaction: discord.Interaction) -> None: + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + await interaction.response.defer() + self._cur_hand.append(self.deck.pop()) + val = _bj_value(self._cur_hand) + if val > 21: + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + if len(self.hands) > 1: + await self._advance_or_finish(interaction) + else: + await self._resolve_all(interaction) + elif val == 21: + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) + if len(self.hands) > 1: + await self._advance_or_finish(interaction) + else: + await self._do_dealer_reveal(interaction) + else: + self._refresh_buttons() + await self.message.edit(embed=self._cur_embed(), view=self) + + async def _stand(self, interaction: discord.Interaction) -> None: + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + await interaction.response.defer() + if len(self.hands) > 1: + await self._advance_or_finish(interaction) + else: + await self._do_dealer_reveal(interaction) + + async def _double(self, interaction: discord.Interaction) -> None: + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + res = await economy.do_blackjack_bet(self.user_id, self.bet) + if not res["ok"]: + await interaction.response.send_message( + S.ERR["broke"].format(bal=_coin(res.get("balance", 0))), ephemeral=True + ) + return + await interaction.response.defer() + self._doubled_hands.add(0) + self.bets[0] *= 2 + self._cur_hand.append(self.deck.pop()) + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + await self._do_dealer_reveal(interaction) + + async def _split_hand(self, interaction: discord.Interaction) -> None: + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + res = await economy.do_blackjack_bet(self.user_id, self.bet) + if not res["ok"]: + await interaction.response.send_message( + S.ERR["broke"].format(bal=_coin(res.get("balance", 0))), ephemeral=True + ) + return + await interaction.response.defer() + card1, card2 = self._cur_hand[0], self._cur_hand[1] + self._split_aces = card1[0] == "A" + self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]] + self.bets = [self.bet, self.bet] + self.hand_idx = 0 + await self.message.edit(embed=self._cur_embed(), view=None) + await asyncio.sleep(_BJ_DEAL_DELAY) + if self._split_aces: + await self._do_dealer_reveal(interaction) + else: + self._refresh_buttons() + await self.message.edit(embed=self._cur_embed(), view=self) + + async def on_timeout(self) -> None: + _active_games.discard(self.user_id) + try: + await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets)) + except Exception: + pass + self.clear_items() + if self.message: + try: + await self.message.edit(view=self) + except discord.HTTPException: + pass + + +@tree.command(name="blackjack", description=S.CMD["blackjack"]) +@app_commands.describe(panus=S.OPT["blackjack_panus"]) +async def cmd_blackjack(interaction: discord.Interaction, panus: str): + _data = await economy.get_user(interaction.user.id) + bet, _err = _parse_amount(panus, _data["balance"]) + if _err: + await interaction.response.send_message(_err, ephemeral=True) + return + if bet <= 0: + await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) + return + if interaction.user.id in _active_games: + await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) + return + + res = await economy.do_blackjack_bet(interaction.user.id, bet) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True + ) + else: + await interaction.response.send_message( + S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True + ) + return + _active_games.add(interaction.user.id) + + deck = _bj_deck() + player_hand: list = [] + dealer_hand: list = [] + + # ── Animated deal: player, dealer, player, dealer ───────────────────── + await interaction.response.send_message( + embed=discord.Embed(title=S.TITLE["blackjack"], description=S.BJ["dealing"], color=0x5865F2) + ) + msg = await interaction.original_response() + + for target in ["player", "dealer", "player", "dealer"]: + if target == "player": + player_hand.append(deck.pop()) + else: + dealer_hand.append(deck.pop()) + await asyncio.sleep(_BJ_DEAL_DELAY) + await msg.edit(embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=True)) + + await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) + + # ── Immediate blackjack check ───────────────────────────────────────── + if _bj_is_blackjack(player_hand): + # Flip dealer card before resolving so player can see both hands + await msg.edit(embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=False)) + await asyncio.sleep(_BJ_DEAL_DELAY) + if _bj_is_blackjack(dealer_hand): + push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet) + embed = _bj_embed( + player_hand, dealer_hand, S.TITLE["blackjack_push"], 0x99AAB5, + hide_dealer=False, + result_field=(S.BJ["result_field"], S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=_coin(push_res["balance"]))), + ) + else: + payout = bet + int(bet * 1.5) + bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet) + embed = _bj_embed( + player_hand, dealer_hand, S.TITLE["blackjack_bj"], 0xF4C430, + hide_dealer=False, + result_field=(S.BJ["result_field"], f"+{_coin(payout)}" + S.BJ_UI["balance_line"].format(balance=_coin(bj_res["balance"]))), + ) + asyncio.create_task(_award_exp(interaction, economy.gamble_exp(bet))) + _active_games.discard(interaction.user.id) + await msg.edit(embed=embed) + return + + # ── Normal game ─────────────────────────────────────────────────────── + view = BlackjackView(interaction.user.id, bet, player_hand, dealer_hand, deck) + view.message = msg + await msg.edit( + embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=True), + view=view, + ) + + +# --------------------------------------------------------------------------- +# /request - crowdfunding +# --------------------------------------------------------------------------- +class FundModal(discord.ui.Modal): + summa = discord.ui.TextInput( + label=S.REQUEST_UI["modal_label"], + min_length=1, + max_length=10, + ) + + def __init__(self, view: "RequestView"): + super().__init__(title=S.REQUEST_UI["modal_title"]) + self._view = view + self.summa.placeholder = f"1 - {view.remaining}" + + async def on_submit(self, interaction: discord.Interaction): + amount, _err = _parse_amount(self.summa.value, 0) + if _err or amount is None: + await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True) + return + if amount <= 0 or amount > self._view.remaining: + await interaction.response.send_message( + S.ERR["fund_range"].format(max=self._view.remaining), ephemeral=True + ) + return + + res = await economy.do_give(interaction.user.id, self._view.requester.id, amount) + if not res["ok"]: + data = await economy.get_user(interaction.user.id) + await interaction.response.send_message( + S.ERR["broke"].format(bal=_coin(data["balance"])), ephemeral=True + ) + return + + self._view.remaining -= amount + funded_line = S.REQUEST_UI["funded_line"].format(name=interaction.user.display_name, amount=_coin(amount)) + if self._view.remaining <= 0: + self._view.fund_btn.disabled = True + self._view.fund_btn.label = S.REQUEST_UI["btn_funded"] + self._view.fund_btn.style = discord.ButtonStyle.secondary + self._view.stop() + funded_line += S.REQUEST_UI["funded_full"] + else: + self._view.fund_btn.label = S.REQUEST_UI["btn_fund_remaining"].format(remaining=self._view.remaining) + funded_line += S.REQUEST_UI["funded_partial"].format(remaining=_coin(self._view.remaining)) + + await interaction.response.send_message(funded_line) + if self._view.message: + await self._view.message.edit(view=self._view) + + +class RequestView(discord.ui.View): + def __init__(self, requester: discord.Member, amount: int, target: discord.Member | None): + super().__init__(timeout=300) + self.requester = requester + self.remaining = amount + self.target = target + self.message: discord.Message | None = None + self.fund_btn = discord.ui.Button(label=S.REQUEST_UI["btn_fund"], style=discord.ButtonStyle.success) + self.fund_btn.callback = self._fund + self.add_item(self.fund_btn) + + async def _fund(self, interaction: discord.Interaction): + if interaction.user.id == self.requester.id: + await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True) + return + if self.target and interaction.user.id != self.target.id: + await interaction.response.send_message( + S.ERR["request_targeted"].format(name=self.target.display_name), ephemeral=True + ) + return + await interaction.response.send_modal(FundModal(self)) + + async def on_timeout(self): + for item in self.children: + item.disabled = True + + +_MAX_REQUEST = 1_000_000 + + +@tree.command(name="request", description=S.CMD["request"]) +@app_commands.describe( + summa=S.OPT["request_summa"], + põhjus=S.OPT["request_põhjus"], + sihtmärk=S.OPT["request_sihtmärk"], +) +async def cmd_request( + interaction: discord.Interaction, + summa: str, + põhjus: str, + sihtmärk: discord.Member | None = None, +): + summa_int, _err = _parse_amount(summa, 0) + if _err or summa_int is None: + await interaction.response.send_message(_err or S.ERR["invalid_amount"], ephemeral=True) + return + if summa_int <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + if summa_int > _MAX_REQUEST: + await interaction.response.send_message( + S.ERR["fund_range"].format(max=_coin(_MAX_REQUEST)), ephemeral=True + ) + return + summa = summa_int + if sihtmärk and sihtmärk.id == interaction.user.id: + await interaction.response.send_message(S.ERR["request_self"], ephemeral=True) + return + if sihtmärk and sihtmärk.bot: + await interaction.response.send_message(S.ERR["request_bot"], ephemeral=True) + return + + audience = S.REQUEST_UI["audience_targeted"].format(name=sihtmärk.display_name) if sihtmärk else S.REQUEST_UI["audience_all"] + embed = discord.Embed( + title=S.TITLE["request"], + description=S.REQUEST_UI["desc"].format(requester=interaction.user.display_name, amount=_coin(summa), reason=põhjus, audience=audience), + color=0xF4C430, + ) + embed.set_footer(text=S.REQUEST_UI["footer"]) + view = RequestView(interaction.user, summa, sihtmärk) + await interaction.response.send_message(embed=embed, view=view) + view.message = await interaction.original_response() + +# --------------------------------------------------------------------------- +# /reminders +# --------------------------------------------------------------------------- +class RemindersSelect(discord.ui.Select): + def __init__(self, user_id: int, current: list[str]): + self.user_id = user_id + options = [ + discord.SelectOption( + label=label, + description=desc, + value=cmd, + default=cmd in current, + ) + for cmd, label, desc in S.REMINDER_OPTS + ] + super().__init__( + placeholder=S.REMINDERS_UI["select_placeholder"], + options=options, + min_values=0, + max_values=len(S.REMINDER_OPTS), + ) + + async def callback(self, interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + await economy.do_set_reminders(self.user_id, self.values) + enabled = set(self.values) + for cmd in [opt[0] for opt in S.REMINDER_OPTS]: + if cmd not in enabled: + task = _reminder_tasks.pop((self.user_id, cmd), None) + if task and not task.done(): + task.cancel() + if self.values: + names = " ".join(f"`/{v}`" for v in self.values) + msg = S.REMINDERS_UI["saved_on"].format(names=names) + else: + msg = S.REMINDERS_UI["saved_off"] + await interaction.response.send_message(msg, ephemeral=True) + + +class RemindersView(discord.ui.View): + def __init__(self, user_id: int, current: list[str]): + super().__init__(timeout=60) + self.add_item(RemindersSelect(user_id, current)) + + +@tree.command(name="reminders", description=S.CMD["reminders"]) +async def cmd_reminders(interaction: discord.Interaction): + user_data = await economy.get_user(interaction.user.id) + current = user_data.get("reminders", []) + if current: + status = " ".join(f"`/{c}`" for c in current) + desc = S.REMINDERS_UI["desc_active"].format(status=status) + else: + desc = S.REMINDERS_UI["desc_none"] + embed = discord.Embed( + title=S.TITLE["reminders"], + description=desc, + color=0x5865F2, + ) + embed.set_footer(text=S.REMINDERS_UI["footer"]) + await interaction.response.send_message(embed=embed, view=RemindersView(interaction.user.id, current), ephemeral=True) + + +@tree.command(name="send", description=S.CMD["send"]) +@app_commands.guild_only() +@app_commands.describe( + kanal=S.OPT["send_kanal"], + sõnum=S.OPT["send_sõnum"], +) +@app_commands.default_permissions(manage_guild=True) +async def cmd_send(interaction: discord.Interaction, kanal: discord.TextChannel, sõnum: str): + try: + await kanal.send(sõnum) + await interaction.response.send_message( + S.SEND_UI["sent"].format(channel=kanal.mention), ephemeral=True + ) + except discord.Forbidden: + await interaction.response.send_message( + S.SEND_UI["forbidden"].format(channel=kanal.mention), ephemeral=True + ) + except Exception as e: + await interaction.response.send_message( + S.ERR["send_failed"].format(error=e), ephemeral=True + ) + + +@tree.command(name="economysetup", description=S.CMD["economysetup"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +async def cmd_economysetup(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + guild = interaction.guild + bot_member = guild.get_member(bot.user.id) + bot_top_pos = max((r.position for r in bot_member.roles if r.managed), default=1) + + all_role_names = [economy.ECONOMY_ROLE] + [name for _, name in economy.LEVEL_ROLES] + + created, existing = [], [] + for name in all_role_names: + role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles) + if role is None: + await guild.create_role(name=name, reason="/economysetup") + created.append(name) + else: + existing.append(name) + + positions: dict[discord.Role, int] = {} + base = max(bot_top_pos - 1, 1) + for i, name in enumerate(all_role_names): + role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles) + if role: + positions[role] = max(base - i, 1) + if positions: + try: + await guild.edit_role_positions(positions=positions) + except discord.Forbidden: + pass + + embed = discord.Embed(title=S.TITLE["economysetup"], color=0x57F287) + if created: + embed.add_field(name=S.ECONOMYSETUP_UI["created_field"], value="\n".join(created), inline=True) + if existing: + embed.add_field(name=S.ECONOMYSETUP_UI["existing_field"], value="\n".join(existing), inline=True) + embed.set_footer(text=S.ECONOMYSETUP_UI["footer"]) + await interaction.followup.send(embed=embed, ephemeral=True) + log.info("/economysetup triggered by %s", interaction.user) + + +@tree.command(name="allowchannel", description=S.CMD["allowchannel"]) +@app_commands.guild_only() +@app_commands.describe(kanal=S.OPT["allowchannel_kanal"]) +@app_commands.default_permissions(manage_guild=True) +async def cmd_allowchannel(interaction: discord.Interaction, kanal: discord.TextChannel): + allowed = _get_allowed_channels() + if kanal.id in allowed: + await interaction.response.send_message( + S.CHANNEL_UI["already_allowed"].format(channel=kanal.mention), ephemeral=True + ) + return + allowed.append(kanal.id) + _set_allowed_channels(allowed) + log.info("ALLOWCHANNEL +%s by %s", kanal, interaction.user) + await interaction.response.send_message( + S.CHANNEL_UI["added"].format(channel=kanal.mention), ephemeral=True + ) + + +@tree.command(name="denychannel", description=S.CMD["denychannel"]) +@app_commands.guild_only() +@app_commands.describe(kanal=S.OPT["denychannel_kanal"]) +@app_commands.default_permissions(manage_guild=True) +async def cmd_denychannel(interaction: discord.Interaction, kanal: discord.TextChannel): + allowed = _get_allowed_channels() + if kanal.id not in allowed: + await interaction.response.send_message( + S.CHANNEL_UI["not_in_list"].format(channel=kanal.mention), ephemeral=True + ) + return + allowed.remove(kanal.id) + _set_allowed_channels(allowed) + log.info("DENYCHANNEL -%s by %s", kanal, interaction.user) + if allowed: + await interaction.response.send_message( + S.CHANNEL_UI["removed"].format(channel=kanal.mention), ephemeral=True + ) + else: + await interaction.response.send_message( + S.CHANNEL_UI["removed_last"].format(channel=kanal.mention), ephemeral=True + ) + + +@tree.command(name="channels", description=S.CMD["channels"]) +@app_commands.guild_only() +@app_commands.default_permissions(manage_guild=True) +async def cmd_channels(interaction: discord.Interaction): + allowed = _get_allowed_channels() + if not allowed: + desc = S.CHANNEL_UI["list_empty"] + else: + lines = "\n".join(f"\u2022 <#{cid}>" for cid in allowed) + desc = S.CHANNEL_UI["list_filled"].format(lines=lines) + embed = discord.Embed(title=S.CHANNEL_UI["list_title"], description=desc, color=0x5865F2) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +# --------------------------------------------------------------------------- +# Error handling for slash commands +# --------------------------------------------------------------------------- +@tree.error +async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + if isinstance(error, app_commands.MissingPermissions): + msg = S.ERR["missing_perms"] + else: + log.exception("Unhandled slash command error: %s", error) + msg = S.ERR["generic_error"].format(error=error) + + try: + if interaction.response.is_done(): + await interaction.followup.send(msg, ephemeral=True) + else: + await interaction.response.send_message(msg, ephemeral=True) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _log_sync_result(member: discord.Member, result: SyncResult): + if result.nickname_changed: + log.info(" → Nickname set for %s", member) + if result.roles_added: + log.info(" → Roles added for %s: %s", member, result.roles_added) + if result.birthday_soon: + log.info(" → Birthday coming up for %s", member) + for err in result.errors: + log.warning(" → %s", err) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None: + exc = context.get("exception") + msg = context.get("message", "unknown asyncio error") + if exc: + log.error("Unhandled asyncio exception: %s", msg, exc_info=exc) + else: + log.error("Unhandled asyncio error: %s", msg) + + +if __name__ == "__main__": + if not config.DISCORD_TOKEN: + raise SystemExit("DISCORD_TOKEN pole seadistatud. Kopeeri .env.example failiks .env ja täida see.") + + async def _main() -> None: + loop = asyncio.get_event_loop() + loop.set_exception_handler(_asyncio_exception_handler) + await bot.start(config.DISCORD_TOKEN, reconnect=True) + + asyncio.run(_main()) diff --git a/config.py b/config.py new file mode 100644 index 0000000..f435bcb --- /dev/null +++ b/config.py @@ -0,0 +1,16 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") +SHEET_ID = os.getenv("SHEET_ID") +GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json") +GUILD_ID = int(os.getenv("GUILD_ID", "0")) +BIRTHDAY_CHANNEL_ID = int(os.getenv("BIRTHDAY_CHANNEL_ID", "0")) +BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7")) +BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190] + +PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090") +PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") +PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "") diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/birthday_sent.json b/data/birthday_sent.json new file mode 100644 index 0000000..37cacb4 --- /dev/null +++ b/data/birthday_sent.json @@ -0,0 +1,5 @@ +{ + "2026-03-14": [ + "650046190972305409" + ] +} \ No newline at end of file diff --git a/data/bot_config.json b/data/bot_config.json new file mode 100644 index 0000000..5c8cf2d --- /dev/null +++ b/data/bot_config.json @@ -0,0 +1,5 @@ +{ + "allowed_channels": [ + "1482398641699291357" + ] +} \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..98ce3d5 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,904 @@ +## v0.36.7 + +- Fixed high memory usage with large file uploads ([#7572](https://github.com/pocketbase/pocketbase/discussions/7572)). + +- Updated the rate limiter reset rules to follow a more traditional fixed window strategy _(aka. to be more close to how it is presented in the UI - allow max X user requests under Ys)_ since several users complained that the older algorithm was not intuitive and not suitable for large intervals. + _Approximated sliding window strategy was also suggested as a better compromise option to help minimize traffic spikes right after reset but the additional tracking could introduce some overhead and for now it is left aside until we have more tests._ + +- Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3. + _⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._ + +- Updated other minor Go and npm deps. + _The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._ + + +## v0.36.6 + +- Set `NumberField.OnlyInt:true` for the generated View collection schema fields when a view column expression is known to return int-only values ([#7538](https://github.com/pocketbase/pocketbase/issues/7538)). + +- Documented the `unmarshal` JSVM helper ([#7543](https://github.com/pocketbase/pocketbase/issues/7543)). + +- Added extra read check after the `Store.GetOrSet` write lock to prevent races overwriting an already existing value. + +- Added empty records check for the additional client-side filter's ListRule constraint that was introduced in v0.32.0 ([presentator#206](https://github.com/presentator/presentator/issues/206)). + +- Set a fixed `routine.FireAndForget()` debug stack trace limit to 2KB. + +- Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1). + +- Typos and other minor doc fixes. + + +## v0.36.5 + +- Disabled collection and fields name normalization while in IME mode ([#7532](https://github.com/pocketbase/pocketbase/pull/7532); thanks @miaopan607). + +- Updated `modernc.org/sqlite` to v1.46.1 _(resets connection state on Tx.Commit failure)_. + + +## v0.36.4 + +- Made the optional `Bearer` token prefix case-insensitive ([#7525](https://github.com/pocketbase/pocketbase/pull/7525); thanks @benjamesfleming). + +- Enabled `$filesystem.s3(...)` and `$filesystem.local(...)` JSVM bindings ([#7526](https://github.com/pocketbase/pocketbase/issues/7526)). + + +## v0.36.3 + +- Added `Accept-Encoding: identity` to the S3 requests per the suggestion in [#7523](https://github.com/pocketbase/pocketbase/issues/7523). + _This should help fixing the 0-bytes file response when S3 API compression is enabled._ + +- Bumped min Go GitHub action version to 1.26.0 _(it comes with minor [GC performance improvements](https://go.dev/doc/go1.26#runtime))_. + +- Other minor fixes _(updated `modernc.org/sqlite` to v1.45.0, updated `goja_nodejs` adding `Buffer.concat`, updated the arguments of `app.DeleteTable(...)`, `app.DeleteView(...)` and other similar methods to make it more clear that they are dangerous and shouldn't be used with untrusted input, etc.)_. + + +## v0.36.2 + +- Updated `modernc.org/sqlite` to v1.44.3 _(race check fix)_, `goja` _(circular references fix)_ and other go deps. + +- Other minor fixes _(updated tests to silence some of the race detector errors, updated `FindFirstRecordByData` with more clear error message when missing or invalid key is used, etc.)_. + + +## v0.36.1 + +- Reverted the `DISTINCT` with `GROUP BY` replacement optimization from v0.36.0 as it was reported to negatively impact the indexes utilization for some queries +and the minor performance boost that you may get when used on large records is not enough to justify the more common use ([#7461](https://github.com/pocketbase/pocketbase/discussions/7461)). + _A better generic deduplication optimization for large records (aka. records with large `text`/`json` fields or many small ones) will be researched but there are no ETAs._ + +- Updated `modernc.org/sqlite` to v1.44.2 _(SQLite 3.51.2)_. + +- Fixed code comment typos. + + +## v0.36.0 + +- List query and API rules optimizations: + - Removed unnecessary correlated subquery expression when using back-relations via single `relation` field. + - Replaced `DISTINCT` with `GROUP BY id` when rows deduplication is needed and when deemed safe. + _This should help with having a more stable and predictable performance even if the collection records are on the larger side._ + + For some queries and data sets the above 2 optimizations have shown significant improvements but if you notice a performance degradation after upgrading, + please open a Q&A discussion with export of your collections structure and the problematic request so that it can be analyzed. + +- Added [`strftime(format, timevalue, modifiers...)`](https://pocketbase.io/docs/api-rules-and-filters/#strftimeformat-time-value-modifiers-) date formatting filter and API rules function. + It works similarly to the [SQLite `strftime` builtin function](https://sqlite.org/lang_datefunc.html) + with the main difference that NULL results will be normalized for consistency with the non-nullable PocketBase `text` and `date` fields. + Multi-match expressions are also supported and works the same as if the collection field is referenced, for example: + ```js + // requires ANY/AT-LEAST-ONE-OF multiRel records to have "created" date matching the formatted string "2026-01" + strftime('%Y-%m', multiRel.created) ?= '2026-01' + + // requires ALL multiRel records to have "created" date matching the formatted string "2026-01" + strftime('%Y-%m', multiRel.created) = '2026-01' + ``` + +- ⚠️ Minor changes to the `search.ResolverResult` struct _(mostly used internally)_: + - Replaced `NoCoalesce` field with the more explicit `NullFallback` _(`NullFallbackDisabled` is the same as `NoCoalesce:true`)_. + - Replaced the expression interface of the `MultiMatchSubQuery` field with the concrete struct type `search.MultiMatchSubquery` to avoid excessive type assertions and allow direct mutations of the field. + +- Updated `modernc.org/sqlite` to v1.44.1 _(SQLite 3.51.1)_. + +- Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6). + + +## v0.35.1 + +- Updated `modernc.org/sqlite` to v1.43.0 _(query cancellation race fix)_. + +- Other minor UI fixes (normalized relations picker selection and confirmation message when `maxSelect=0/1`, updated node deps). + + +## v0.35.0 + +- Added `nullString()`, `nullInt()`, `nullFloat()`, `nullBool`, `nullArray()`, `nullObject()` JSVM helpers for scanning nullable columns ([#7396](https://github.com/pocketbase/pocketbase/issues/7396)). + +- Store the correct `image/png` as attrs content type when generating a thumb fallback _(e.g. for `webp`)_. + +- Trimmed custom uploaded file name and extension from leftover `.` characters after `filesystem.File` normalization. + _This was done to prevent issues with external files sync programs that may have special handling for "invisible" files._ + +- Updated `modernc.org/sqlite` _(v1.41.0 includes prepared statements optimization)_ and other minor Go deps. + + +## v0.34.2 + +- Bumped JS SDK to v0.26.5 to fix Safari AbortError detection introduced with the previous release ([#7369](https://github.com/pocketbase/pocketbase/issues/7369)). + + +## v0.34.1 + +- Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353); thanks @ouvreboite). + +- Added "Copy raw JSON" collection dropdown option ([#7357](https://github.com/pocketbase/pocketbase/issues/7357)). + +- Updated Go deps and JS SDK. + +- Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5). + _The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._ + + +## v0.34.0 + +- Added `@request.body.someField:changed` modifier. + It could be used when you want to ensure that a body field either wasn't submitted or was submitted with the same value. + Or in other words, if you want to disallow a field change the below 2 expressions would be equivalent: + ```js + // (old) + (@request.body.someField:isset = false || @request.body.someField = someField) + + // (new) + @request.body.someField:changed = false + ``` + +- Added `MailerRecordEvent.Meta["info"]` property for the `OnMailerRecordAuthAlertSend` hook. + +- Updated the backup restore popup with a short info about the performed restore steps. + +- Updated Go deps. + + +## v0.33.0 + +- Added extra `id` characters validation in addition to the user specified regex pattern ([#7312](https://github.com/pocketbase/pocketbase/issues/7312)). + _The following special characters are always forbidden: `./\|"'``<>:?*%$\n\r\t\0 `. Common reserved Windows file names such as `aux`, `prn`, `con`, `nul`, `com1-9`, `lpt1-9` are also not allowed._ + _The list is not exhaustive but it should help minimizing eventual filesystem compatibility issues in case of wildcards or other loose regex patterns._ + +- Added `{ALERT_INFO}` placeholder to the auth alert mail template ([#7314](https://github.com/pocketbase/pocketbase/issues/7314)). + _⚠️ `mails.SendRecordAuthAlert(app, authRecord, info)` also now accepts a 3rd `info` string argument._ + +- Updated Go deps. + + +## v0.32.0 + +- ⚠️ Added extra List/Search API rules checks for the client-side `filter`/`sort` relations. + + This is continuation of the effort to eliminate the risk of information disclosure _(and eventually the side-channel attacks that may originate from that)_. + + So far this was accepted tradeoff between performance, usability and correctness since the solutions at the time weren't really practical _(especially with the back-relations as mentioned in ["Security and performance" section in #4417](https://github.com/pocketbase/pocketbase/discussions/4417))_, but with v0.23+ changes we can implement the extra checks without littering the code too much, with very little impact on the performance and at the same time ensuring better out of the box security _(especially for the cases where users operate with sensitive fields like "code", "token", "secret", etc.)_. + + Similar to the previous release, probably for most users with already configured API rules this change won't be breaking, but if you have an _intermediate/junction collection_ that is "locked" (superusers-only) we no longer will allow the client-side relation filter to pass through it and you'll have to set its List/Search API rule to enable the current user to search in it. + + For example, if you have a client-side filter that targets `rel1.rel2.token`, the client must have not only List/Search API rule access to the main collection BUT also to the collections referenced by "rel1" and "rel2" relation fields. + + Note that this change is only for the **client-side** `filter`/`sort` and doesn't affect the execution of superuser requests, API rules and `expand` - they continue to work the same as it is. + + An optional environment variable to toggle this behavior was considered but for now I think having 2 ways of resolving client-side filters would introduce maintenance burden and can even cause confusion (this change should actually make things more intuitive and clear because we can simply say something like _"you can search by a collection X field only if you have List/Search API rule access to it"_ no matter whether the targeted collection is the request's main collection, the first or last relation from the filter chain, etc.). + + If you stumble on an error or extreme query performance degradation as a result of the extra checks, please open a Q&A discussion with the failing request and export of your collections configuration as JSON (_Settings > Export collections_) and I'll try to investigate it. + +- Increased the default SQLite `PRAGMA cache_size` to ~32MB. + +- Fixed deadlock when manually triggering the `OnTerminate` hook ([#7305](https://github.com/pocketbase/pocketbase/pull/7305); thanks @yerTools). + +- Fixed some code comment typos, regenerated the JSVM types and updated npm dependencies. + +- Updated `modernc.org/sqlite` to 1.40.0. + + +## v0.31.0 + +- Visualize presentable multiple `relation` fields ([#7260](https://github.com/pocketbase/pocketbase/issues/7260)). + +- Support Ed25519 in the optional OIDC `id_token` signature validation ([#7252](https://github.com/pocketbase/pocketbase/issues/7252); thanks @shynome). + +- Added `ApiScenario.DisableTestAppCleanup` optional field to skip the auto test app cleanup and leave it up to the developers to do the cleanup manually ([#7267](https://github.com/pocketbase/pocketbase/discussions/7267)). + +- Added `FileDownloadRequestEvent.ThumbError` field that is populated in case of a thumb generation failure (e.g. unsupported format, timing out, etc.), allowing developers to reject the thumb fallback and/or supply their own custom thumb generation ([#7268](https://github.com/pocketbase/pocketbase/discussions/7268)). + +- ⚠️ Disallow client-side filtering and sorting of relations where the collection of the last targeted relation field has superusers-only List/Search API rule to further minimize the risk of eventual side-channel attack. + _This should be a non-breaking change for most users, but if you want the old behavior, please open a new Q&A discussion with details about your use case to evaluate making it configurable._ + _Note also that as mentioned in the "Security and performance" section of [#4417](https://github.com/pocketbase/pocketbase/discussions/4417) and [#5863](https://github.com/pocketbase/pocketbase/discussions/5863), the easiest and recommended solution to protect security sensitive fields (tokens, codes, passwords, etc.) is to mark them as "Hidden" (aka. make them non-API filterable)._ + +- Regenerated JSVM types and updated npm and Go deps. + + +## v0.30.4 + +- Fixed `json` field CSS regression introduced with the overflow workaround in v0.30.3 ([#7259](https://github.com/pocketbase/pocketbase/issues/7259)). + + +## v0.30.3 + +- Fixed legacy identitity field priority check when a username is a valid email address ([#7256](https://github.com/pocketbase/pocketbase/issues/7256)). + +- Workaround autocomplete overflow issue with Firefox 144 ([#7223](https://github.com/pocketbase/pocketbase/issues/7223)). + +- Updated `modernc.org/sqlite` to 1.39.1 (SQLite 3.50.4). + + +## v0.30.2 + +- Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved). + + +## v0.30.1 + +- ⚠️ Excluded the `lost+found` directory from the backups ([#7208](https://github.com/pocketbase/pocketbase/pull/7208); thanks @lbndev). + _If for some reason you want to keep it, you can restore it by editing the `e.Exclude` list of the `OnBackupCreate` and `OnBackupRestore` hooks._ + +- Minor tests improvements (disabled initial superuser creation for the test app to avoid cluttering the std output, added more tests for the `s3.Uploader.MaxConcurrency`, etc.). + +- Updated `modernc.org/sqlite` and other Go dependencies. + + +## v0.30.0 + +- Eagerly escape the S3 request path following the same rules as in the S3 signing header ([#7153](https://github.com/pocketbase/pocketbase/issues/7153)). + +- Added Lark OAuth2 provider ([#7130](https://github.com/pocketbase/pocketbase/pull/7130); thanks @mashizora). + +- Increased test tokens `exp` claim to minimize eventual issues with reproducible builds ([#7123](https://github.com/pocketbase/pocketbase/issues/7123)). + +- Added `os.Root` bindings to the JSVM ([`$os.openRoot`](https://pocketbase.io/jsvm/functions/_os.openRoot.html), [`$os.openInRoot`](https://pocketbase.io/jsvm/functions/_os.openInRoot.html)). + +- Added `osutils.IsProbablyGoRun()` helper to loosely check if the program was started using `go run`. + +- Various minor UI improvements (updated collections indexes UI, enabled seconds in the datepicker, updated helper texts, etc.). + +- ⚠️ Updated the minimum package Go version to 1.24.0 and bumped Go dependencies. + + +## v0.29.3 + +- Try to forward Apple OAuth2 POST redirect user's name so that it can be returned (and eventually assigned) with the success response of the all-in-one auth call ([#7090](https://github.com/pocketbase/pocketbase/issues/7090)). + +- Fixed `RateLimitRule.Audience` code comment ([#7098](https://github.com/pocketbase/pocketbase/pull/7098); thanks @iustin05). + +- Mocked `syscall.Exec` when building for WASM ([#7116](https://github.com/pocketbase/pocketbase/pull/7116); thanks @joas8211). + _Note that WASM is not officially supported PocketBase build target and many things may not work as expected._ + +- Registered missing `$filesystem`, `$mails`, `$template` and `__hooks` bindings in the JSVM migrations ([#7125](https://github.com/pocketbase/pocketbase/issues/7125)). + +- Regenerated JSVM types to include methods from structs with single generic parameter. + +- Updated Go dependencies. + + +## v0.29.2 + +- Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved). + + +## v0.29.1 + +- Updated the X/Twitter provider to return the `confirmed_email` field and to use the `x.com` domain ([#7035](https://github.com/pocketbase/pocketbase/issues/7035)). + +- Added Box.com OAuth2 provider ([#7056](https://github.com/pocketbase/pocketbase/pull/7056); thanks @blakepatteson). + +- Updated `modernc.org/sqlite` to 1.38.2 (SQLite 3.50.3). + +- Fixed example List API response ([#7049](https://github.com/pocketbase/pocketbase/pull/7049); thanks @williamtguerra). + + +## v0.29.0 + +- Enabled calling the `/auth-refresh` endpoint with nonrenewable tokens. + _When used with nonrenewable tokens (e.g. impersonate) the endpoint will simply return the same token with the up-to-date user data associated with it._ + +- Added the triggered rate rimit rule in the error log `details`. + +- Added optional `ServeEvent.Listener` field to initialize a custom network listener (e.g. `unix`) instead of the default `tcp` ([#3233](https://github.com/pocketbase/pocketbase/discussions/3233)). + +- Fixed request data unmarshalization for the `DynamicModel` array/object fields ([#7022](https://github.com/pocketbase/pocketbase/discussions/7022)). + +- Fixed Dashboard page title `-` escaping ([#6982](https://github.com/pocketbase/pocketbase/issues/6982)). + +- Other minor improvements (updated first superuser console text when running with `go run`, clarified trusted IP proxy header label, wrapped the backup restore in a transaction as an extra precaution, updated deps, etc.). + + +## v0.28.4 + +- Added global JSVM `toBytes()` helper to return the bytes slice representation of a value such as io.Reader or string, _other types are first serialized to Go string_ ([#6935](https://github.com/pocketbase/pocketbase/issues/6935)). + +- Fixed `security.RandomStringByRegex` random distribution ([#6947](https://github.com/pocketbase/pocketbase/pull/6947); thanks @yerTools). + +- Minor docs and typos fixes. + + +## v0.28.3 + +- Skip sending empty `Range` header when fetching blobs from S3 ([#6914](https://github.com/pocketbase/pocketbase/pull/6914)). + +- Updated Go deps and particularly `modernc.org/sqlite` to 1.38.0 (SQLite 3.50.1). + +- Bumped GitHub action min Go version to 1.23.10 as it comes with some [minor security `net/http` fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.10+label%3ACherryPickApproved). + + +## v0.28.2 + +- Loaded latin-ext charset for the default text fonts ([#6869](https://github.com/pocketbase/pocketbase/issues/6869)). + +- Updated view query CAST regex to properly recognize multiline expressions ([#6860](https://github.com/pocketbase/pocketbase/pull/6860); thanks @azat-ismagilov). + +- Updated Go and npm dependencies. + + +## v0.28.1 + +- Fixed `json_each`/`json_array_length` normalizations to properly check for array values ([#6835](https://github.com/pocketbase/pocketbase/issues/6835)). + + +## v0.28.0 + +- Write the default response body of `*Request` hooks that are wrapped in a transaction after the related transaction completes to allow propagating the transaction error ([#6462](https://github.com/pocketbase/pocketbase/discussions/6462#discussioncomment-12207818)). + +- Updated `app.DB()` to automatically routes raw write SQL statements to the nonconcurrent db pool ([#6689](https://github.com/pocketbase/pocketbase/discussions/6689)). + _For the rare cases when it is needed users still have the option to explicitly target the specific pool they want using `app.ConcurrentDB()`/`app.NonconcurrentDB()`._ + +- ⚠️ Changed the default `json` field max size to 1MB. + _Users still have the option to adjust the default limit from the collection field options but keep in mind that storing large strings/blobs in the database is known to cause performance issues and should be avoided when possible._ + +- ⚠️ Soft-deprecated and replaced `filesystem.System.GetFile(fileKey)` with `filesystem.System.GetReader(fileKey)` to avoid the confusion with `filesystem.File`. + _The old method will still continue to work for at least until v0.29.0 but you'll get a console warning to replace it with `GetReader`._ + +- Added new `filesystem.System.GetReuploadableFile(fileKey, preserveName)` method to return an existing blob as a `*filesystem.File` value ([#6792](https://github.com/pocketbase/pocketbase/discussions/6792)). + _This method could be useful in case you want to clone an existing Record file and assign it to a new Record (e.g. in a Record duplicate action)._ + +- Other minor improvements (updated the GitHub release min Go version to 1.23.9, updated npm and Go deps, etc.) + + +## v0.27.2 + +- Added workers pool when cascade deleting record files to minimize _"thread exhaustion"_ errors ([#6780](https://github.com/pocketbase/pocketbase/discussions/6780)). + +- Updated the `:excerpt` fields modifier to properly account for multibyte characters ([#6778](https://github.com/pocketbase/pocketbase/issues/6778)). + +- Use `rowid` as count column for non-view collections to minimize the need of having the id field in a covering index ([#6739](https://github.com/pocketbase/pocketbase/discussions/6739)) + + +## v0.27.1 + +- Updated example `geoPoint` API preview body data. + +- Added JSVM `new GeoPointField({ ... })` constructor. + +- Added _partial_ WebP thumbs generation (_the thumbs will be stored as PNG_; [#6744](https://github.com/pocketbase/pocketbase/pull/6744)). + +- Updated npm dev dependencies. + + +## v0.27.0 + +- ⚠️ Moved the Create and Manage API rule checks out of the `OnRecordCreateRequest` hook finalizer, **aka. now all CRUD API rules are checked BEFORE triggering their corresponding `*Request` hook**. + This was done to minimize the confusion regarding the firing order of the request operations, making it more predictable and consistent with the other record List/View/Update/Delete request actions. + It could be a minor breaking change if you are relying on the old behavior and have a Go `tests.ApiScenario` that is testing a Create API rule failure and expect `OnRecordCreateRequest` to be fired. In that case for example you may have to update your test scenario like: + ```go + tests.ApiScenario{ + Name: "Example test that checks a Create API rule failure" + Method: http.MethodPost, + URL: "/api/collections/example/records", + ... + // old: + ExpectedEvents: map[string]int{ + "*": 0, + "OnRecordCreateRequest": 1, + }, + // new: + ExpectedEvents: map[string]int{"*": 0}, + } + ``` + If you are having difficulties adjusting your code, feel free to open a [Q&A discussion](https://github.com/pocketbase/pocketbase/discussions) with the failing/problematic code sample. + +- Added [new `geoPoint` field](https://pocketbase.io/docs/collections/#geopoint) for storing `{"lon":x,"lat":y}` geographic coordinates. + In addition, a new [`geoDistance(lonA, lotA, lonB, lotB)` function](https://pocketbase.io/docs/api-rules-and-filters/#geodistancelona-lata-lonb-latb) was also implemented that could be used to apply an API rule or filter constraint based on the distance (in km) between 2 geo points. + +- Updated the `select` field UI to accommodate better larger lists and RTL languages ([#4674](https://github.com/pocketbase/pocketbase/issues/4674)). + +- Updated the mail attachments auto MIME type detection to use `gabriel-vasile/mimetype` for consistency and broader sniffing signatures support. + +- Forced `text/javascript` Content-Type when serving `.js`/`.mjs` collection uploaded files with the `/api/files/...` endpoint ([#6597](https://github.com/pocketbase/pocketbase/issues/6597)). + +- Added second optional JSVM `DateTime` constructor argument for specifying a default timezone as TZ identifier when parsing the date string as alternative to a fixed offset in order to better handle daylight saving time nuances ([#6688](https://github.com/pocketbase/pocketbase/discussions/6688)): + ```js + // the same as with CET offset: new DateTime("2025-10-26 03:00:00 +01:00") + new DateTime("2025-10-26 03:00:00", "Europe/Amsterdam") // 2025-10-26 02:00:00.000Z + + // the same as with CEST offset: new DateTime("2025-10-26 01:00:00 +02:00") + new DateTime("2025-10-26 01:00:00", "Europe/Amsterdam") // 2025-10-25 23:00:00.000Z + ``` + +- Soft-deprecated the `$http.send`'s `result.raw` field in favor of `result.body` that contains the response body as plain bytes slice to avoid the discrepancies between Go and the JSVM when casting binary data to string. + +- Updated `modernc.org/sqlite` to 1.37.0. + +- Other minor improvements (_removed the superuser fields from the auth record create/update body examples, allowed programmatically updating the auth record password from the create/update hooks, fixed collections import error response, etc._). + + +## v0.26.6 + +- Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)). + +- Updated `modernc.org/sqlite` to 1.36.3. + + +## v0.26.5 + +- Fixed canonical URI parts escaping when generating the S3 request signature ([#6654](https://github.com/pocketbase/pocketbase/issues/6654)). + + +## v0.26.4 + +- Fixed `RecordErrorEvent.Error` and `CollectionErrorEvent.Error` sync with `ModelErrorEvent.Error` ([#6639](https://github.com/pocketbase/pocketbase/issues/6639)). + +- Fixed logs details copy to clipboard action. + +- Updated `modernc.org/sqlite` to 1.36.2. + + +## v0.26.3 + +- Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)). + + +## v0.26.2 + +- Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp). + + +## v0.26.1 + +- Removed the wrapping of `io.EOF` error when reading files since currently `io.ReadAll` doesn't check for wrapped errors ([#6600](https://github.com/pocketbase/pocketbase/issues/6600)). + + +## v0.26.0 + +- ⚠️ Replaced `aws-sdk-go-v2` and `gocloud.dev/blob` with custom lighter implementation ([#6562](https://github.com/pocketbase/pocketbase/discussions/6562)). + As a side-effect of the dependency removal, the binary size has been reduced with ~10MB and builds ~30% faster. + _Although the change is expected to be backward-compatible, I'd recommend to test first locally the new version with your S3 provider (if you use S3 for files storage and backups)._ + +- ⚠️ Prioritized the user submitted non-empty `createData.email` (_it will be unverified_) when creating the PocketBase user during the first OAuth2 auth. + +- Load the request info context during password/OAuth2/OTP authentication ([#6402](https://github.com/pocketbase/pocketbase/issues/6402)). + This could be useful in case you want to target the auth method as part of the MFA and Auth API rules. + For example, to disable MFA for the OAuth2 auth could be expressed as `@request.context != "oauth2"` MFA rule. + +- Added `store.Store.SetFunc(key, func(old T) new T)` to set/update a store value with the return result of the callback in a concurrent safe manner. + +- Added `subscription.Message.WriteSSE(w, id)` for writing an SSE formatted message into the provided writer interface (_used mostly to assist with the unit testing_). + +- Added `$os.stat(file)` JSVM helper ([#6407](https://github.com/pocketbase/pocketbase/discussions/6407)). + +- Added log warning for `async` marked JSVM handlers and resolve when possible the returned `Promise` as fallback ([#6476](https://github.com/pocketbase/pocketbase/issues/6476)). + +- Allowed calling `cronAdd`, `cronRemove` from inside other JSVM handlers ([#6481](https://github.com/pocketbase/pocketbase/discussions/6481)). + +- Bumped the default request read and write timeouts to 5mins (_old 3mins_) to accommodate slower internet connections and larger file uploads/downloads. + _If you want to change them you can modify the `OnServe` hook's `ServeEvent.ReadTimeout/WriteTimeout` fields as shown in [#6550](https://github.com/pocketbase/pocketbase/discussions/6550#discussioncomment-12364515)._ + +- Normalized the `@request.auth.*` and `@request.body.*` back relations resolver to always return `null` when the relation field is pointing to a different collection ([#6590](https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581)). + +- Other minor improvements (_fixed query dev log nested parameters output, reintroduced `DynamicModel` object/array props reflect types caching, updated Go and npm deps, etc._) + + +## v0.25.9 + +- Fixed `DynamicModel` object/array props reflect type caching ([#6563](https://github.com/pocketbase/pocketbase/discussions/6563)). + + +## v0.25.8 + +- Added a default leeway of 5 minutes for the Apple/OIDC `id_token` timestamp claims check to account for clock-skew ([#6529](https://github.com/pocketbase/pocketbase/issues/6529)). + It can be further customized if needed with the `PB_ID_TOKEN_LEEWAY` env variable (_the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute_). + + +## v0.25.7 + +- Fixed `@request.body.jsonObjOrArr.*` values extraction ([#6493](https://github.com/pocketbase/pocketbase/discussions/6493)). + + +## v0.25.6 + +- Restore the missing `meta.isNew` field of the OAuth2 success response ([#6490](https://github.com/pocketbase/pocketbase/issues/6490)). + +- Updated npm dependencies. + + +## v0.25.5 + +- Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories. + +- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies. + + +## v0.25.4 + +- Downgraded `aws-sdk-go-v2` to the version before the default data integrity checks because there have been reports for non-AWS S3 providers in addition to Backblaze (IDrive, R2) that no longer or partially work with the latest AWS SDK changes. + + While we try to enforce `when_required` by default, it is not enough to disable the new AWS SDK integrity checks entirely and some providers will require additional manual adjustments to make them compatible with the latest AWS SDK (e.g. removing the `x-aws-checksum-*` headers, unsetting the checksums calculation or reinstantiating the old MD5 checksums for some of the required operations, etc.) which as a result leads to a configuration mess that I'm not sure it would be a good idea to introduce. + + This unfornuatelly is not a PocketBase or Go specific issue and the official AWS SDKs for other languages are in the same situation (even the latest aws-cli). + + For those of you that extend PocketBase with Go: if your S3 vendor doesn't support the [AWS Data integrity checks](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html) and you are updating with `go get -u`, then make sure that the `aws-sdk-go-v2` dependencies in your `go.mod` are the same as in the repo: + ``` + // go.mod + github.com/aws/aws-sdk-go-v2 v1.36.1 + github.com/aws/aws-sdk-go-v2/config v1.28.10 + github.com/aws/aws-sdk-go-v2/credentials v1.17.51 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48 + github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2 + + // after that run + go clean -modcache && go mod tidy + ``` + _The versions pinning is temporary until the non-AWS S3 vendors patch their implementation or until I manage to find time to remove/replace the `aws-sdk-go-v2` dependency (I'll consider prioritizing it for the v0.26 or v0.27 release)._ + + +## v0.25.3 + +- Added a temporary exception for Backblaze S3 endpoints to exclude the new `aws-sdk-go-v2` checksum headers ([#6440](https://github.com/pocketbase/pocketbase/discussions/6440)). + + +## v0.25.2 + +- Fixed realtime delete event not being fired for `RecordProxy`-ies and added basic realtime record resolve automated tests ([#6433](https://github.com/pocketbase/pocketbase/issues/6433)). + + +## v0.25.1 + +- Fixed the batch API Preview success sample response. + +- Bumped GitHub action min Go version to 1.23.6 as it comes with a [minor security fix](https://github.com/golang/go/issues?q=milestone%3AGo1.23.6+label%3ACherryPickApproved) for the ppc64le build. + + +## v0.25.0 + +- ⚠️ Upgraded Google OAuth2 auth, token and userinfo endpoints to their latest versions. + _For users that don't do anything custom with the Google OAuth2 data or the OAuth2 auth URL, this should be a non-breaking change. The exceptions that I could find are:_ + - `/v3/userinfo` auth response changes: + ``` + meta.rawUser.id => meta.rawUser.sub + meta.rawUser.verified_email => meta.rawUser.email_verified + ``` + - `/v2/auth` query parameters changes: + If you are specifying custom `approval_prompt=force` query parameter for the OAuth2 auth URL, you'll have to replace it with **`prompt=consent`**. + +- Added Trakt OAuth2 provider ([#6338](https://github.com/pocketbase/pocketbase/pull/6338); thanks @aidan-) + +- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)). + +- Enforced `when_required` for the new AWS SDK request and response checksum validations to allow other non-AWS vendors to catch up with new AWS SDK changes (see [#6313](https://github.com/pocketbase/pocketbase/discussions/6313) and [aws/aws-sdk-go-v2#2960](https://github.com/aws/aws-sdk-go-v2/discussions/2960)). + _You can set the environment variables `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_supported` if your S3 vendor supports the [new default integrity protections](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)._ + +- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)). + +- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler `osutils.YesNoPrompt(message, fallback)` helper. + +- Upgraded to `golang-jwt/jwt/v5`. + +- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)). + +- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods. + +- Use the non-transactional app instance during the realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved. + +- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, updated API Preview sample error responses, updated UI dependencies, etc._) + + +## v0.24.4 + +- Fixed fields extraction for view query with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)). + +- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5). + + +## v0.24.3 + +- Fixed incorrectly reported unique validator error for fields starting with name of another field ([#6281](https://github.com/pocketbase/pocketbase/pull/6281); thanks @svobol13). + +- Reload the created/edited records data in the RecordsPicker UI. + +- Updated Go dependencies. + + +## v0.24.2 + +- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)). + + +## v0.24.1 + +- Added missing time macros in the UI autocomplete. + +- Fixed JSVM types for structs and functions with multiple generic parameters. + + +## v0.24.0 + +- ⚠️ Removed the "dry submit" when executing the collections Create API rule + (you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073). + For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually. + With this change the "multi-match" operators are also normalized in case the targeted collection doesn't have any records + (_or in other words, `@collection.example.someField != "test"` will result to `true` if `example` collection has no records because it satisfies the condition that all available "example" records mustn't have `someField` equal to "test"_). + As a side-effect of all of the above minor changes, the record create API performance has been also improved ~4x times in high concurrent scenarios (500 concurrent clients inserting total of 50k records - [old (58.409064001s)](https://github.com/pocketbase/benchmarks/blob/54140be5fb0102f90034e1370c7f168fbcf0ddf0/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestdatapublicisset--true) vs [new (13.580098262s)](https://github.com/pocketbase/benchmarks/blob/7df0466ac9bd62fe0a1056270d20ef82012f0234/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestbodypublicisset--true)). + +- ⚠️ Changed the type definition of `store.Store[T any]` to `store.Store[K comparable, T any]` to allow support for custom store key types. + For most users it should be non-breaking change, BUT if you are calling `store.New[any](nil)` instances you'll have to specify the store key type, aka. `store.New[string, any](nil)`. + +- Added `@yesterday` and `@tomorrow` datetime filter macros. + +- Added `:lower` filter modifier (e.g. `title:lower = "lorem"`). + +- Added `mailer.Message.InlineAttachments` field for attaching inline files to an email (_aka. `cid` links_). + +- Added cache for the JSVM `arrayOf(m)`, `DynamicModel`, etc. dynamic `reflect` created types. + +- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)). + +- Added `record.SetRandomPassword()` to simplify random password generation usually used in the OAuth2 or OTP record creation flows. + _The generated ~30 chars random password is assigned directly as bcrypt hash and ignores the `password` field plain value validators like min/max length or regex pattern._ + +- Added option to list and trigger the registered app level cron jobs via the Web API and UI. + +- Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1). + +- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`. + +- Added `app.FindCachedCollectionReferences(collection, excludeIds)` to speedup records cascade delete almost twice for projects with many collections. + +- Added `tests.NewTestAppWithConfig(config)` helper if you need more control over the test configurations like `IsDev`, the number of allowed connections, etc. + +- Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)). + +- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s. + +- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule. + +- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)). + + +## v0.23.12 + +- Added warning logs in case of mismatched `modernc.org/sqlite` and `modernc.org/libc` versions ([#6136](https://github.com/pocketbase/pocketbase/issues/6136#issuecomment-2556336962)). + +- Skipped the default body size limit middleware for the backup upload endpoint ([#6152](https://github.com/pocketbase/pocketbase/issues/6152)). + + +## v0.23.11 + +- Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338). + _PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._ + + +## v0.23.10 + +- Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)). + +- Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems. + _If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._ + + +## v0.23.9 + +- Replaced `strconv.Itoa` with `strconv.FormatInt` to avoid the int64->int conversion overflow on 32-bit platforms ([#6132](https://github.com/pocketbase/pocketbase/discussions/6132)). + + +## v0.23.8 + +- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)). + +- Other minor improvements (updated Go and npm deps, added extra escaping for the default mail record params in case the emails are stored as html files, fixed code comment typos, etc.). + + +## v0.23.7 + +- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)). + + +## v0.23.6 + +- Fixed `$filesystem.fileFromURL` documentation and generated type ([#6058](https://github.com/pocketbase/pocketbase/issues/6058)). + +- Fixed `X-Forwarded-For` header typo in the suggested UI "Common trusted proxy" headers ([#6063](https://github.com/pocketbase/pocketbase/pull/6063)). + +- Updated the `text` field max length validator error message to make it more clear ([#6066](https://github.com/pocketbase/pocketbase/issues/6066)). + +- Other minor fixes (updated Go deps, skipped unnecessary validator check when the default primary key pattern is used, updated JSVM types, etc.). + + +## v0.23.5 + +- Fixed UI logs search not properly accounting for the "Include requests by superusers" toggle when multiple search expressions are used. + +- Fixed `text` field max validation error message ([#6053](https://github.com/pocketbase/pocketbase/issues/6053)). + +- Other minor fixes (comment typos, JSVM types update). + +- Updated Go deps and the min Go release GitHub action version to 1.23.4. + + +## v0.23.4 + +- Fixed `autodate` fields not refreshing when calling `Save` multiple times on the same `Record` instance ([#6000](https://github.com/pocketbase/pocketbase/issues/6000)). + +- Added more descriptive test OTP id and failure log message ([#5982](https://github.com/pocketbase/pocketbase/discussions/5982)). + +- Moved the default UI CSP from meta tag to response header ([#5995](https://github.com/pocketbase/pocketbase/discussions/5995)). + +- Updated Go and npm dependencies. + + +## v0.23.3 + +- Fixed Gzip middleware not applying when serving static files. + +- Fixed `Record.Fresh()`/`Record.Clone()` methods not properly cloning `autodate` fields ([#5973](https://github.com/pocketbase/pocketbase/discussions/5973)). + + +## v0.23.2 + +- Fixed `RecordQuery()` custom struct scanning ([#5958](https://github.com/pocketbase/pocketbase/discussions/5958)). + +- Fixed `--dev` log query print formatting. + +- Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router. + +- Added collection rules change list in the confirmation popup + (_to avoid getting anoying during development, the rules confirmation currently is enabled only when using https_). + + +## v0.23.1 + +- Added `RequestEvent.Blob(status, contentType, bytes)` response write helper ([#5940](https://github.com/pocketbase/pocketbase/discussions/5940)). + +- Added more descriptive error messages. + + +## v0.23.0 + +> [!NOTE] +> You don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing +> your existing app and/or are satisfied with the v0.22.x features set. There are no identified critical issues +> with PocketBase v0.22.x yet and in the case of critical bugs and security vulnerabilities, the fixes +> will be backported for at least until Q1 of 2025 (_if not longer_). +> +> **If you don't plan upgrading make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:** +> - JS SDK: `<0.22.0` +> - Dart SDK: `<0.19.0` + +> [!CAUTION] +> This release introduces many Go/JSVM and Web APIs breaking changes! +> +> Existing `pb_data` will be automatically upgraded with the start of the new executable, +> but custom Go or JSVM (`pb_hooks`, `pb_migrations`) and JS/Dart SDK code will have to be migrated manually. +> Please refer to the below upgrade guides: +> - Go: https://pocketbase.io/v023upgrade/go/. +> - JSVM: https://pocketbase.io/v023upgrade/jsvm/. +> +> If you had already switched to some of the earlier ` - Go: https://pocketbase.io/v023upgrade/go/. +> - JSVM: https://pocketbase.io/v023upgrade/jsvm/. + +#### SDKs changes + +- [JS SDK v0.22.0](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md) +- [Dart SDK v0.19.0](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md) + +#### Web APIs changes + +- New `POST /api/batch` endpoint. + +- New `GET /api/collections/meta/scaffolds` endpoint. + +- New `DELETE /api/collections/{collection}/truncate` endpoint. + +- New `POST /api/collections/{collection}/request-otp` endpoint. + +- New `POST /api/collections/{collection}/auth-with-otp` endpoint. + +- New `POST /api/collections/{collection}/impersonate/{id}` endpoint. + +- ⚠️ If you are constructing requests to `/api/*` routes manually remove the trailing slash (_there is no longer trailing slash removal middleware registered by default_). + +- ⚠️ Removed `/api/admins/*` endpoints because admins are converted to `_superusers` auth collection records. + +- ⚠️ Previously when uploading new files to a multiple `file` field, new files were automatically appended to the existing field values. + This behaviour has changed with v0.23+ and for consistency with the other multi-valued fields when uploading new files they will replace the old ones. If you want to prepend or append new files to an existing multiple `file` field value you can use the `+` prefix or suffix: + ```js + "documents": [file1, file2] // => [file1_name, file2_name] + "+documents": [file1, file2] // => [file1_name, file2_name, old1_name, old2_name] + "documents+": [file1, file2] // => [old1_name, old2_name, file1_name, file2_name] + ``` + +- ⚠️ Removed `GET /records/{id}/external-auths` and `DELETE /records/{id}/external-auths/{provider}` endpoints because this is now handled by sending list and delete requests to the `_externalAuths` collection. + +- ⚠️ Changes to the app settings model fields and response (+new options such as `trustedProxy`, `rateLimits`, `batch`, etc.). The app settings Web APIs are mostly used by the Dashboard UI and rarely by the end users, but if you want to check all settings changes please refer to the [Settings Go struct](https://github.com/pocketbase/pocketbase/blob/develop/core/settings_model.go#L121). + +- ⚠️ New flatten Collection model and fields structure. The Collection model Web APIs are mostly used by the Dashboard UI and rarely by the end users, but if you want to check all changes please refer to the [Collection Go struct](https://github.com/pocketbase/pocketbase/blob/develop/core/collection_model.go#L308). + +- ⚠️ The top level error response `code` key was renamed to `status` for consistency with the Go APIs. + The error field key remains `code`: + ```js + { + "status": 400, // <-- old: "code" + "message": "Failed to create record.", + "data": { + "title": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + ``` + +- ⚠️ New fields in the `GET /api/collections/{collection}/auth-methods` response. + _The old `authProviders`, `usernamePassword`, `emailPassword` fields are still returned in the response but are considered deprecated and will be removed in the future._ + ```js + { + "mfa": { + "duration": 100, + "enabled": true + }, + "otp": { + "duration": 0, + "enabled": false + }, + "password": { + "enabled": true, + "identityFields": ["email", "username"] + }, + "oauth2": { + "enabled": true, + "providers": [{"name": "gitlab", ...}, {"name": "google", ...}] + }, + // old fields... + } + ``` + +- ⚠️ Soft-deprecated the OAuth2 auth success `meta.avatarUrl` field in favour of `meta.avatarURL`. diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md new file mode 100644 index 0000000..9b365c7 --- /dev/null +++ b/docs/DEV_NOTES.md @@ -0,0 +1,194 @@ +# TipiLAN Bot - Developer Reference + +## File Structure + +| File | Purpose | +|---|---| +| `bot.py` | All Discord commands, views (UI components), event handlers, reminder system | +| `economy.py` | All economy logic, data model, constants (SHOP, COOLDOWNS, LEVEL_ROLES, etc.) | +| `pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection | +| `strings.py` | **Single source of truth for all user-facing text.** Edit here to change any message. | +| `sheets.py` | Google Sheets integration (member sync) | +| `member_sync.py` | Birthday/member sync background task | +| `config.py` | Environment variables (TOKEN, GUILD_ID, PB_URL, etc.) | +| `scripts/migrate_to_pb.py` | One-time utility: migrate `data/economy.json` → PocketBase | + +--- + +## Adding a New Economy Command + +Checklist - do all of these, in order: + +1. **`economy.py`** - add the `do_` async function with cooldown check, logic, `_commit`, and `_txn` logging +2. **`economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one +3. **`economy.py`** - add the EXP reward to `EXP_REWARDS` dict +4. **`strings.py` `CMD`** - add the slash command description +5. **`strings.py` `OPT`** - add any parameter descriptions +6. **`strings.py` `TITLE`** - add embed title(s) for success/fail states +7. **`strings.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`) +8. **`strings.py` `CD_MSG`** - add cooldown message if command has a cooldown +9. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed +10. **`bot.py`** - implement the `cmd_` function, handle all `res["reason"]` cases +11. **`bot.py`** - call `_maybe_remind` if the command has a cooldown and reminders make sense +12. **`bot.py`** - call `_award_exp(interaction, economy.EXP_REWARDS[""])` on success +13. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one +14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch + +--- + +## Adding a New Shop Item + +Checklist: + +1. **`economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}` +2. **`economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3) +3. **`economy.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≥10) or T3 (≥20) +4. **`strings.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect) +5. **`strings.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost) +6. If the item modifies a cooldown: + - **`economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_` function + - **`bot.py` `_maybe_remind`** - add `elif cmd == "" and "" in items:` branch with the new delay + - **`bot.py` `cmd_cooldowns`** - add the item annotation to the relevant status line + +--- + +## Adding a New Level Role + +1. **`economy.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first) +2. **`bot.py` `_ensure_level_role`** - no changes needed (uses `LEVEL_ROLES` dynamically) +3. Run **`/economysetup`** in the server to create the role and set its position + +--- + +## Adding a New Admin Command + +1. **`strings.py` `CMD`** - add `"[Admin] ..."` description +2. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry +3. **`bot.py`** - add `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()` + +--- + +## Economy System Design + +### Storage + +All economy state is stored in **PocketBase** (`economy_users` collection). `pb_client.py` owns all reads/writes. Each `do_*` function in `economy.py` calls `get_user()` → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase. + +### Currency & Income Sources + +| Command | Cooldown | Base Earn | Notes | +|---|---|---|---| +| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop | +| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook | +| `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur | +| `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail | +| `/rob` | 2h | 10–25% of target | 45% success (60% w/ jellyfin); fail = fine; house rob: 35% success, 5–40% jackpot | +| `/slots` | - | varies | jackpot=10× (15× w/ monitor_360), triple=4× (6×), pair=1× | +| `/roulette` | - | 2× red/black, 14× green | 1/37 green chance | +| `/blackjack` | - | 1:1 win, 3:2 BJ, 2:1 double | Dealer stands on 17+; double down on first action only | + +### "all" Keyword +Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` to mean the user's full current balance. Parsed by `_parse_amount(value, balance)` in `bot.py`. + +### Daily Streak Multipliers +- 1-2 days: ×1.0 (150⬡) +- 3-6 days: ×1.5 (225⬡) +- 7-13 days: ×2.0 (300⬡) +- 14+ days: ×3.0 (450⬡) +- `karikas` item: streak survives missed days + +### Jail +- Duration: 30 minutes (`JAIL_DURATION`) +- `gaming_tool`: prevents jail on crime fail +- `/jailbreak`: 3 dice rolls, need doubles to escape free. On fail - bail = 20-30% of balance, min 350⬡. If balance < 350⬡, player stays jailed until timer. +- **Blocked while jailed**: `/work`, `/beg`, `/crime`, `/rob`, `/give` (checked in `do_*` functions via `_is_jailed`) + +### EXP Rewards (from `EXP_REWARDS` in economy.py) +EXP is awarded on every successful command use. Level formula: `floor(sqrt(exp / 10))`, so Level 5 = 250 EXP, Level 10 = 1000, Level 20 = 4000, Level 30 = 9000. + +--- + +## Role Hierarchy (Discord) + +Order top to bottom in server roles: + +``` +[Bot managed role] ← bot's own role, always at top of our stack +ECONOMY ← given to everyone who uses any economy command +TipiLEGEND ← level 30+ +TipiCHAD ← level 20+ +TipiHUSTLER ← level 10+ +TipiGRINDER ← level 5+ +TipiNOOB ← level 1+ +``` + +Run `/economysetup` to auto-create all roles and set their positions. The command is idempotent - safe to run multiple times. + +Role assignment: +- **ECONOMY** role: given automatically on first EXP award (i.e. first successful economy command) +- **Level roles**: given/swapped automatically on level-up; synced on `/rank` + +--- + +## Shop Tiers & Level Requirements + +| Tier | Level Required | Items | +|---|---|---| +| T1 | 0 (any) | gaming_hiir, hiirematt, korvaklapid, lan_pass, anticheat, energiajook, gaming_laptop | +| T2 | 10 | reguleeritav_laud, jellyfin, mikrofon, klaviatuur, monitor, cat6 | +| T3 | 20 | monitor_360, karikas, gaming_tool | + +Shop display is sorted by cost (ascending) within each tier. +The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds. + +--- + +## strings.py Organisation + +| Section | Dict | Usage in bot.py | +|---|---|---| +| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions | +| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` | +| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` | +| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` | +| Banned message | `MSG_BANNED` | All banned checks | +| Maintenance mode | `MSG_MAINTENANCE` | Shown when `_PAUSED=True` in bot.py (toggled by `/pause`) | +| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown | +| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` | +| Embed titles | `TITLE["key"]` | `discord.Embed(title=S.TITLE["key"])` | +| Error messages | `ERR["key"]` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts | +| Cooldown messages | `CD_MSG["cmd"].format(ts=_cd_ts(...))` | Cooldown responses | +| Shop UI | `SHOP_UI["key"]` | `_shop_embed` | +| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.SHOP[key]["description"]` | + +--- + +## Constants Location Quick-Reference + +| Constant | File | Description | +|---|---|---| +| `SHOP` | `economy.py` | All shop items (name, emoji, cost, description) | +| `SHOP_TIERS` | `economy.py` | Which items are in T1/T2/T3 | +| `SHOP_LEVEL_REQ` | `economy.py` | Min level per item | +| `COOLDOWNS` | `economy.py` | Base cooldown per command | +| `JAIL_DURATION` | `economy.py` | How long jail lasts | +| `LEVEL_ROLES` | `economy.py` | `[(min_level, "RoleName"), ...]` highest first | +| `ECONOMY_ROLE` | `economy.py` | Name of the base economy participation role | +| `EXP_REWARDS` | `economy.py` | EXP per command | +| `HOUSE_ID` | `economy.py` | Bot's user ID (house account for /rob) | +| `MIN_BAIL` | `economy.py` | Minimum bail payment (350⬡) | +| `COIN` | `economy.py` | The coin emoji string | +| `_PAUSED` | `bot.py` | In-memory maintenance flag; toggled by `/pause`; blocks all non-admin commands | + +--- + +## Balance Notes (as of current version) + +- **Beg** is most efficient for active players (3min cooldown + 2× multiplier w/ `klaviatuur` = high ⬡/hr) +- **Work** is best for passive players (1h cooldown, fire and forget) +- **Crime** is high risk/reward - best with `cat6` + `mikrofon` +- **`lan_pass`** (1200⬡) doubles daily - good long-term investment +- **`gaming_laptop`** (1500⬡) 5% interest, capped 500⬡/day - snowballs with large balance +- `anticheat` is consumable (2 uses) - only item that can be re-bought +- `karikas` (T3) is the only item that preserves a daily streak across missed days +- `reguleeritav_laud` (T2) stacks with `gaming_hiir`: combined ×1.5 × ×1.25 = ×1.875 diff --git a/docs/POCKETBASE_SETUP.md b/docs/POCKETBASE_SETUP.md new file mode 100644 index 0000000..52de860 --- /dev/null +++ b/docs/POCKETBASE_SETUP.md @@ -0,0 +1,72 @@ +# PocketBase Setup + +## 1. Download & run PocketBase + +Download the binary for your OS from https://pocketbase.io/docs/ and run it: + +```bash +./pocketbase serve +# Admin UI: http://127.0.0.1:8090/_/ +``` + +Create your admin account on first launch via the Admin UI. + +--- + +## 2. Create the `economy_users` collection + +In the Admin UI → **Collections** → **New collection** → name it exactly `economy_users`. + +Add the following fields: + +| Field name | Type | Required | Default | +|-------------------|-----------|----------|---------| +| `user_id` | Text | ✅ | - | +| `balance` | Number | | `0` | +| `exp` | Number | | `0` | +| `daily_streak` | Number | | `0` | +| `last_daily` | Text | | - | +| `last_work` | Text | | - | +| `last_beg` | Text | | - | +| `last_crime` | Text | | - | +| `last_rob` | Text | | - | +| `last_streak_date`| Text | | - | +| `jailed_until` | Text | | - | +| `items` | JSON | | `[]` | +| `item_uses` | JSON | | `{}` | +| `reminders` | JSON | | `[]` | +| `eco_banned` | Bool | | `false` | + +> **Tip:** Set `user_id` as a unique index under **Indexes** tab. + +Set **API rules** (all four: list, view, create, update) to admin-only (leave blank / locked). + +--- + +## 3. Configure .env + +Add to your `.env`: + +``` +PB_URL=http://127.0.0.1:8090 +PB_ADMIN_EMAIL=your-admin@email.com +PB_ADMIN_PASSWORD=your-admin-password +``` + +--- + +## 4. Migrate existing data (one-time) + +If you have existing data in `data/economy.json`, run the migration script **once** before starting the bot: + +```bash +python migrate_to_pb.py +``` + +--- + +## 5. Production + +On a server, run PocketBase as a background service (systemd, Docker, etc.) and update `PB_URL` in `.env` to the server's address. + +PocketBase stores all data in `pb_data/` - back this directory up regularly. diff --git a/economy.py b/economy.py new file mode 100644 index 0000000..48d1b33 --- /dev/null +++ b/economy.py @@ -0,0 +1,1288 @@ +"""TipiCOIN economy - data layer and business logic. + +Storage: PocketBase (see pb_client.py). Collection: economy_users. +All public async functions are the single source of truth for mutations. +""" + +from __future__ import annotations + +import logging +import math +import random +from datetime import date, datetime, timedelta, timezone +from typing import TypedDict + +import pb_client + +import strings + +_txn_log = logging.getLogger("tipiCOIN.txn") + + +def _txn(event: str, **fields) -> None: + """Log a single economy transaction to the transactions logger.""" + body = " ".join(f"{k}={v}" for k, v in fields.items()) + _txn_log.info("%-16s %s", event, body) + + +# --------------------------------------------------------------------------- +# Emoji config +# To use your custom Discord emoji replace COIN with the full tag, e.g.: +# COIN = "<:tipicoin:1234567890123456789>" +# --------------------------------------------------------------------------- +COIN = "<:TipiCOIN:1483000209188589628>" + +# --------------------------------------------------------------------------- +# Shop catalogue +# --------------------------------------------------------------------------- +class ShopItem(TypedDict): + name: str + emoji: str + cost: int + description: str + + +SHOP: dict[str, ShopItem] = { + "gaming_hiir": { + "name": "Mängurihiir", + "emoji": "<:TipiHIIR:1483004306012504128>", + "cost": 500, + "description": strings.ITEM_DESCRIPTIONS["gaming_hiir"], + }, + "hiirematt": { + "name": "Hiirematt", + "emoji": "<:TipiMATT:1483387697132208128>", + "cost": 600, + "description": strings.ITEM_DESCRIPTIONS["hiirematt"], + }, + "korvaklapid": { + "name": "K\u00f5rvaklapid", + "emoji": "<:TipiKLAPID:1483387694083084349>", + "cost": 1200, + "description": strings.ITEM_DESCRIPTIONS["korvaklapid"], + }, + "lan_pass": { + "name": "LAN pilet", + "emoji": "<:TipiPILET:1483004308353060904>", + "cost": 1200, + "description": strings.ITEM_DESCRIPTIONS["lan_pass"], + }, + "energiajook": { + "name": "Red Bull", + "emoji": "<:TipiBULL:1483004310924300409>", + "cost": 800, + "description": strings.ITEM_DESCRIPTIONS["energiajook"], + }, + "gaming_laptop": { + "name": "Bot Farm", + "emoji": "<:TipiLAP:1483004307161874566>", + "cost": 1500, + "description": strings.ITEM_DESCRIPTIONS["gaming_laptop"], + }, + "anticheat": { + "name": "Anticheat", + "emoji": "<:TipiVAC:1483004309510819860>", + "cost": 1000, + "description": strings.ITEM_DESCRIPTIONS["anticheat"], + }, + # ----- Tier 2 ----- + "reguleeritav_laud": { + "name": "Reguleeritav laud", + "emoji": "<:TipiLAUD:1483387695576125440>", + "cost": 3500, + "description": strings.ITEM_DESCRIPTIONS["reguleeritav_laud"], + }, + "jellyfin": { + "name": "Jellyfin server", + "emoji": "<:TipiSERVER:1483387701032910969>", + "cost": 4000, + "description": strings.ITEM_DESCRIPTIONS["jellyfin"], + }, + "mikrofon": { + "name": "Eraldiseisev mikrofon", + "emoji": "<:TipiMIC:1483387698499551313>", + "cost": 2800, + "description": strings.ITEM_DESCRIPTIONS["mikrofon"], + }, + "klaviatuur": { + "name": "Mehaaniline klaviatuur", + "emoji": "<:TipiKLAVA:1483014339228078140>", + "cost": 1800, + "description": strings.ITEM_DESCRIPTIONS["klaviatuur"], + }, + "monitor": { + "name": "Ultralai monitor", + "emoji": "<:TipiMONITOR:1483014340327243908>", + "cost": 2500, + "description": strings.ITEM_DESCRIPTIONS["monitor"], + }, + "cat6": { + "name": "Cat6 kaabel", + "emoji": "<:TipiCAT:1483014337663602718>", + "cost": 3500, + "description": strings.ITEM_DESCRIPTIONS["cat6"], + }, + # ----- Tier 3 ----- + "monitor_360": { + "name": "360Hz monitor", + "emoji": "<:TipiMONITOR2:1483387699514839162>", + "cost": 7500, + "description": strings.ITEM_DESCRIPTIONS["monitor_360"], + }, + "karikas": { + "name": "TipiLAN karikas", + "emoji": "<:TipiKARIKAS:1483014841148112977>", + "cost": 6000, + "description": strings.ITEM_DESCRIPTIONS["karikas"], + }, + "gaming_tool": { + "name": "Gaming tool", + "emoji": "<:TipiTOOL:1483014341648187613>", + "cost": 9000, + "description": strings.ITEM_DESCRIPTIONS["gaming_tool"], + }, +} + +# Tier grouping (used by /shop pagination) +SHOP_TIERS: dict[int, list[str]] = { + 1: ["gaming_hiir", "hiirematt", "korvaklapid", "lan_pass", "energiajook", "anticheat", "gaming_laptop"], + 2: ["reguleeritav_laud", "jellyfin", "mikrofon", "klaviatuur", "monitor", "cat6"], + 3: ["monitor_360", "karikas", "gaming_tool"], +} + +# Minimum level required to purchase Tier 2 / Tier 3 shop items +SHOP_LEVEL_REQ: dict[str, int] = { + "reguleeritav_laud": 10, + "jellyfin": 10, + "mikrofon": 10, + "klaviatuur": 10, + "monitor": 10, + "cat6": 10, + "monitor_360": 20, + "karikas": 20, + "gaming_tool": 20, +} + +# --------------------------------------------------------------------------- +# EXP / Level system +# --------------------------------------------------------------------------- +# EXP awarded per successful action +EXP_REWARDS: dict[str, int] = { + "daily": 50, + "work": 25, + "beg": 5, + "crime_win": 15, + "rob_win": 15, + "gamble_win": 10, + "heist_win": 25, +} + +def gamble_exp(bet: int) -> int: + """Scale EXP for a gambling win by bet size. + Returns 0 for bets < 10 coins to close micro-bet EXP farming. + 10-99 → 5, 100-999 → 10, 1 000-9 999 → 15, 10 000+ → 20, 100 000+ → 25 (cap). + """ + return min(25, max(0, int(math.log10(max(1, bet))) * 5)) + + +ECONOMY_ROLE = "ECONOMY" + +# Vanity role milestones: (min_level, role_name) - highest first +LEVEL_ROLES: list[tuple[int, str]] = [ + (30, "TipiLEGEND"), + (20, "TipiCHAD"), + (10, "TipiHUSTLER"), + (5, "TipiGRINDER"), + (1, "TipiNOOB"), +] + + +def get_level(exp: int) -> int: + """Level = max(1, floor(sqrt(exp/10))). + Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000.""" + return max(1, int(math.sqrt(max(0, exp) / 10))) + + +def exp_for_level(level: int) -> int: + """Minimum cumulative EXP to reach this level.""" + if level <= 1: + return 0 + return level * level * 10 + + +def level_role_name(level: int) -> str: + """Return the vanity role name for a given level.""" + for threshold, name in LEVEL_ROLES: + if level >= threshold: + return name + return LEVEL_ROLES[-1][1] + + +# --------------------------------------------------------------------------- +# Cooldowns +# --------------------------------------------------------------------------- +COOLDOWNS: dict[str, timedelta] = { + "daily": timedelta(hours=20), + "work": timedelta(hours=1), + "beg": timedelta(minutes=5), + "crime": timedelta(hours=2), + "rob": timedelta(hours=2), +} + +JAIL_DURATION = timedelta(minutes=30) +HEIST_JAIL = timedelta(hours=1, minutes=30) + +# --------------------------------------------------------------------------- +# User schema +# --------------------------------------------------------------------------- +class UserData(TypedDict, total=False): + balance: int + exp: int # lifetime EXP (resets each season) + last_daily: str | None + last_work: str | None + last_beg: str | None + last_crime: str | None + last_rob: str | None + last_heist: str | None + daily_streak: int + last_streak_date: str | None # ISO date "YYYY-MM-DD" + items: list[str] + item_uses: dict # {item_id: remaining_uses} for consumables + jailed_until: str | None # ISO datetime or None + jailbreak_used: bool + reminders: list[str] # command names user wants DM reminders for + eco_banned: bool # if True, user cannot use any economy commands + # Lifetime statistics + peak_balance: int + lifetime_earned: int + lifetime_lost: int + work_count: int + beg_count: int + total_wagered: int + biggest_win: int + biggest_loss: int + slots_jackpots: int + crimes_attempted: int + crimes_succeeded: int + times_jailed: int + total_bail_paid: int + heists_joined: int + heists_won: int + total_given: int + total_received: int + best_daily_streak: int + heist_global_cd_until: float + + +def _default_user() -> UserData: + return { + "balance": 0, + "exp": 0, + "last_daily": None, + "last_work": None, + "last_beg": None, + "last_crime": None, + "last_rob": None, + "last_heist": None, + "daily_streak": 0, + "last_streak_date": None, + "items": [], + "item_uses": {}, + "jailed_until": None, + "jailbreak_used": False, + "reminders": ["daily", "work", "beg", "crime", "rob"], + "eco_banned": False, + # ── Lifetime stats ────────────────────────────────────────────────── + "peak_balance": 0, + "lifetime_earned": 0, + "lifetime_lost": 0, + "work_count": 0, + "beg_count": 0, + "total_wagered": 0, + "biggest_win": 0, + "biggest_loss": 0, + "slots_jackpots": 0, + "crimes_attempted": 0, + "crimes_succeeded": 0, + "times_jailed": 0, + "total_bail_paid": 0, + "heists_joined": 0, + "heists_won": 0, + "total_given": 0, + "total_received": 0, + "best_daily_streak": 0, + "heist_global_cd_until": 0.0, + } + + +# --------------------------------------------------------------------------- +# Persistence (PocketBase backend) +# --------------------------------------------------------------------------- +_log = logging.getLogger("tipiCOIN.economy") + +# --------------------------------------------------------------------------- +# House account (bot user) +# --------------------------------------------------------------------------- +HOUSE_ID: int | None = None + + +def set_house(user_id: int) -> None: + """Register the bot's Discord user ID as the house account.""" + global HOUSE_ID + HOUSE_ID = user_id + + +async def _credit_house(amount: int) -> None: + """Add `amount` coins to the house account. No-op if house not set.""" + if HOUSE_ID is None or amount <= 0: + return + user = await get_user(HOUSE_ID) + user["balance"] = user.get("balance", 0) + amount + await _commit(HOUSE_ID, user) + + +async def get_heist_global_cd() -> float: + """Return unix timestamp until which no new heist can start. Persisted on house record.""" + if HOUSE_ID is None: + return 0.0 + house = await get_user(HOUSE_ID) + return float(house.get("heist_global_cd_until") or 0) + + +async def set_heist_global_cd(until: float) -> None: + """Persist heist global cooldown expiry to the house account in PocketBase.""" + if HOUSE_ID is None: + return + house = await get_user(HOUSE_ID) + house["heist_global_cd_until"] = until + await _commit(HOUSE_ID, house) + + +async def do_spam_jail(user_id: int) -> None: + """Jail a user for 30 minutes due to suspected automated command spam.""" + user = await get_user(user_id) + user["jailed_until"] = (_now() + timedelta(minutes=30)).isoformat() + user["jailbreak_used"] = False + user["times_jailed"] = user.get("times_jailed", 0) + 1 + await _commit(user_id, user) + _txn("SPAM_JAIL", user=user_id, until=user["jailed_until"]) + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- +async def get_all_users_raw() -> dict[str, "UserData"]: + """Return a snapshot of all user records.""" + records = await pb_client.list_all_records() + result: dict[str, UserData] = {} + for record in records: + uid = record.get("user_id", "") + if not uid: + continue + user = _default_user() + for key in list(user.keys()): + if key in record: + user[key] = record[key] # type: ignore[literal-required] + user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key] + result[uid] = user + return result + + +async def migrate_anticheat_uses() -> int: + """One-time migration: users who own anticheat but have no item_uses entry get 2 uses.""" + records = await pb_client.list_all_records() + changed = 0 + for record in records: + items = record.get("items") or [] + item_uses = record.get("item_uses") or {} + if "anticheat" in items and "anticheat" not in item_uses: + item_uses["anticheat"] = 2 + await pb_client.update_record(record["id"], {"item_uses": item_uses}) + changed += 1 + return changed + + +async def migrate_reminders_default() -> int: + """One-time migration: enable all reminders for users who have an empty list.""" + _ALL_REMINDERS = ["daily", "work", "beg", "crime", "rob"] + records = await pb_client.list_all_records() + changed = 0 + for record in records: + reminders = record.get("reminders") + if not reminders: + await pb_client.update_record(record["id"], {"reminders": _ALL_REMINDERS}) + changed += 1 + return changed + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- +def _now() -> datetime: + return datetime.now(tz=timezone.utc) + + +def _parse_dt(s: str | None) -> datetime | None: + if not s: + return None + dt = datetime.fromisoformat(s) + # Ensure timezone-aware + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + +def _cooldown_remaining( + user: UserData, action: str, override_cd: timedelta | None = None +) -> timedelta | None: + """Return remaining cooldown, or None if the action is ready.""" + last = _parse_dt(user.get(f"last_{action}")) + if last is None: + return None + cd = override_cd if override_cd is not None else COOLDOWNS[action] + remaining = cd - (_now() - last) + return remaining if remaining.total_seconds() > 0 else None + + +def _is_jailed(user: UserData) -> timedelta | None: + """Return remaining jail time, or None if free.""" + until = _parse_dt(user.get("jailed_until")) + if until is None: + return None + remaining = until - _now() + return remaining if remaining.total_seconds() > 0 else None + + +def jailed_remaining(user: UserData) -> timedelta | None: + """Public wrapper - return remaining jail time, or None if free.""" + return _is_jailed(user) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def format_td(td: timedelta) -> str: + """Human-readable timedelta: '1t 23m' / '45m 12s' / '8s'.""" + total = int(td.total_seconds()) + h, rem = divmod(total, 3600) + m, s = divmod(rem, 60) + if h: + return f"{h}t {m}m" + if m: + return f"{m}m {s}s" + return f"{s}s" + + +async def get_user(user_id: int) -> UserData: + """Fetch user data from PocketBase, creating a default record if first seen.""" + uid = str(user_id) + record = await pb_client.get_record(uid) + if record is None: + default = _default_user() + default["user_id"] = uid # type: ignore[typeddict-unknown-key] + record = await pb_client.create_record(default) + user = _default_user() + for key in list(user.keys()): + if key in record: + user[key] = record[key] # type: ignore[literal-required] + user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key] + return user + + +async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]: + """Return top_n (user_id_str, balance) pairs sorted descending.""" + records = await pb_client.list_all_records() + result = sorted( + ((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")), + key=lambda x: x[1], + reverse=True, + ) + return result if top_n is None else result[:top_n] + + +async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]: + """Return top_n (user_id_str, exp, level) sorted by EXP descending.""" + records = await pb_client.list_all_records() + result = sorted( + ((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")), + key=lambda x: x[1], + reverse=True, + ) + entries = [(uid, exp, get_level(exp)) for uid, exp in result] + return entries if top_n is None else entries[:top_n] + + +async def award_exp(user_id: int, amount: int) -> dict: + """Add EXP to a user. Returns old_level, new_level, total exp.""" + user = await get_user(user_id) + old_exp = user.get("exp", 0) + new_exp = old_exp + amount + old_level = get_level(old_exp) + new_level = get_level(new_exp) + user["exp"] = new_exp + await _commit(user_id, user) + return {"old_level": old_level, "new_level": new_level, "exp": new_exp} + + +async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]: + """Snapshot top_n by EXP, then full wipe: EXP, balance, items, item_uses. + Returns top list (uid, exp, level) captured before the reset.""" + records = await pb_client.list_all_records() + top = sorted( + ((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")), + key=lambda x: x[1], + reverse=True, + )[:top_n] + reset_fields = { + "exp": 0, + "balance": 0, + "items": [], + "item_uses": {}, + "last_daily": None, + "last_work": None, + "last_beg": None, + "last_crime": None, + "last_rob": None, + "daily_streak": 0, + "last_streak_date": None, + } + for record in records: + await pb_client.update_record(record["id"], reset_fields) + return [(uid, exp, get_level(exp)) for uid, exp in top] + + +# --------------------------------------------------------------------------- +# Internal write helper +# --------------------------------------------------------------------------- +async def _commit(user_id: int, user: UserData) -> None: + try: + record_id = user.get("_pb_id") # type: ignore[typeddict-item] + clean = {k: v for k, v in user.items() if k != "_pb_id"} + clean["user_id"] = str(user_id) + if record_id: + await pb_client.update_record(record_id, clean) + else: + _log.warning("_commit for user %s had no _pb_id; creating new record", user_id) + created = await pb_client.create_record(clean) + user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key] + except Exception as exc: + _log.error("_commit failed for user %s: %s", user_id, exc) + + +# --------------------------------------------------------------------------- +# /daily +# --------------------------------------------------------------------------- +async def do_daily(user_id: int) -> dict: + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + + daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"] + if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd): + return {"ok": False, "reason": "cooldown", "remaining": cd} + + today = _now().date() + last_str = user.get("last_streak_date") + last_date = date.fromisoformat(last_str) if last_str else None + + if last_date is None: + streak = 1 + elif (today - last_date).days == 1: + streak = user["daily_streak"] + 1 + elif "karikas" in user["items"]: + streak = user["daily_streak"] # karikas: streak survives missed days + else: + streak = 1 # streak broken + + # Streak multiplier tiers + if streak >= 14: + streak_mult = 3.0 + elif streak >= 7: + streak_mult = 2.0 + elif streak >= 3: + streak_mult = 1.5 + else: + streak_mult = 1.0 + + vip = "lan_pass" in user["items"] + vip_mult = 2.0 if vip else 1.0 + + base = 150 + earned = int(base * streak_mult * vip_mult) + + # Investor interest (capped at 500/day to prevent runaway wealth) + interest = 0 + if "gaming_laptop" in user["items"] and user["balance"] > 0: + interest = min(int(user["balance"] * 0.05), 500) + earned += interest + + user["balance"] += earned + user["last_daily"] = _now().isoformat() + user["daily_streak"] = streak + user["last_streak_date"] = today.isoformat() + user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned + user["best_daily_streak"] = max(user.get("best_daily_streak", 0), streak) + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + await _commit(user_id, user) + _txn("DAILY", user=user_id, earned=f"+{earned}", streak=streak, bal=user["balance"]) + + return { + "ok": True, + "earned": earned, + "interest": interest, + "streak": streak, + "streak_mult": streak_mult, + "vip": vip, + "balance": user["balance"], + } + + +# --------------------------------------------------------------------------- +# /work +# --------------------------------------------------------------------------- +_WORK_JOBS = strings.WORK_JOBS + + +async def do_work(user_id: int) -> dict: + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + + work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"] + if cd := _cooldown_remaining(user, "work", override_cd=work_cd): + return {"ok": False, "reason": "cooldown", "remaining": cd} + if jail := _is_jailed(user): + return {"ok": False, "reason": "jailed", "remaining": jail} + + job, job_mult = random.choice(_WORK_JOBS) + base = random.randint(15, 75) + worker_mult = 1.5 if "gaming_hiir" in user["items"] else 1.0 + desk_mult = 1.25 if "reguleeritav_laud" in user["items"] else 1.0 + + lucky = False + if "energiajook" in user["items"] and random.random() < 0.30: + lucky = True + + earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0)) + user["balance"] += earned + user["last_work"] = _now().isoformat() + user["work_count"] = user.get("work_count", 0) + 1 + user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + await _commit(user_id, user) + _txn("WORK", user=user_id, earned=f"+{earned}", lucky=lucky, bal=user["balance"]) + + return { + "ok": True, + "earned": earned, + "job": job, + "lucky": lucky, + "hiir": worker_mult > 1.0, + "laud": desk_mult > 1.0, + "balance": user["balance"], + } + + +# --------------------------------------------------------------------------- +# /beg +# --------------------------------------------------------------------------- +_BEG_LINES = strings.BEG_LINES +_BEG_JAIL_LINES = strings.BEG_JAIL_LINES + + +async def do_beg(user_id: int) -> dict: + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + + beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"] + if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd): + return {"ok": False, "reason": "cooldown", "remaining": cd} + + jailed = bool(_is_jailed(user)) + beg_mult = 2 if "klaviatuur" in user["items"] else 1 + earned = random.randint(10, 40) * beg_mult + user["balance"] += earned + user["last_beg"] = _now().isoformat() + user["beg_count"] = user.get("beg_count", 0) + 1 + user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + await _commit(user_id, user) + _txn("BEG", user=user_id, earned=f"+{earned}", jailed=jailed, bal=user["balance"]) + + return { + "ok": True, + "earned": earned, + "text": random.choice(_BEG_JAIL_LINES if jailed else _BEG_LINES), + "klaviatuur": beg_mult > 1, + "jailed": jailed, + "balance": user["balance"], + } + + +# --------------------------------------------------------------------------- +# /crime +# --------------------------------------------------------------------------- +_CRIME_WIN = strings.CRIME_WIN +_CRIME_LOSE = strings.CRIME_LOSE + + +async def do_crime(user_id: int) -> dict: + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + + if cd := _cooldown_remaining(user, "crime"): + return {"ok": False, "reason": "cooldown", "remaining": cd} + if jail := _is_jailed(user): + return {"ok": False, "reason": "jailed", "remaining": jail} + + user["last_crime"] = _now().isoformat() + + win_chance = 0.75 if "cat6" in user["items"] else 0.60 + user["crimes_attempted"] = user.get("crimes_attempted", 0) + 1 + if random.random() < win_chance: + earned = random.randint(200, 500) + if "mikrofon" in user["items"]: + earned = int(earned * 1.3) + user["balance"] += earned + user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1 + user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + await _commit(user_id, user) + _txn("CRIME_WIN", user=user_id, earned=f"+{earned}", bal=user["balance"]) + return { + "ok": True, "success": True, + "earned": earned, "text": random.choice(_CRIME_WIN), + "mikrofon": "mikrofon" in user["items"], + "balance": user["balance"], + } + else: + fine = random.randint(50, 150) + user["balance"] = max(0, user["balance"] - fine) + jailed = "gaming_tool" not in user["items"] + if jailed: + user["jailed_until"] = (_now() + JAIL_DURATION).isoformat() + user["jailbreak_used"] = False + user["times_jailed"] = user.get("times_jailed", 0) + 1 + user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine + await _commit(user_id, user) + await _credit_house(fine) + _txn("CRIME_FAIL", user=user_id, fine=f"-{fine}", jailed=jailed, bal=user["balance"]) + return { + "ok": True, "success": False, + "fine": fine, "text": random.choice(_CRIME_LOSE), + "jailed": jailed, + "balance": user["balance"], + } + + +# --------------------------------------------------------------------------- +# /jailbreak (Monopoly-style dice rolls) +# --------------------------------------------------------------------------- +async def set_jailbreak_used(user_id: int) -> None: + """Mark that the user has consumed their dice attempt for this jail sentence.""" + user = await get_user(user_id) + user["jailbreak_used"] = True + await _commit(user_id, user) + + +async def do_jail_free(user_id: int) -> dict: + """Remove jail status after rolling doubles.""" + user = await get_user(user_id) + user["jailed_until"] = None + user["jailbreak_used"] = False + await _commit(user_id, user) + _txn("JAIL_FREE", user=user_id, method="doubles") + return {"ok": True, "balance": user["balance"]} + + +MIN_BAIL = 350 + +async def do_bail(user_id: int) -> dict: + """Charge bail fine after exhausting jailbreak rolls and free the user. + Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed.""" + user = await get_user(user_id) + if user["balance"] < MIN_BAIL: + return {"ok": False, "reason": "broke", "balance": user["balance"]} + pct = random.uniform(0.20, 0.30) + fine = max(MIN_BAIL, int(user["balance"] * pct)) + user["balance"] = max(0, user["balance"] - fine) + user["jailed_until"] = None + user["jailbreak_used"] = False + user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine + user["total_bail_paid"] = user.get("total_bail_paid", 0) + fine + await _commit(user_id, user) + _txn("BAIL_PAID", user=user_id, fine=f"-{fine}", pct=f"{pct:.0%}", bal=user["balance"]) + return {"ok": True, "fine": fine, "balance": user["balance"]} + + +# --------------------------------------------------------------------------- +# /rob +# --------------------------------------------------------------------------- +async def do_rob(robber_id: int, target_id: int) -> dict: + robber = await get_user(robber_id) + if robber.get("eco_banned"): + return {"ok": False, "reason": "banned"} + target = await get_user(target_id) + + if cd := _cooldown_remaining(robber, "rob"): + return {"ok": False, "reason": "cooldown", "remaining": cd} + target_jailed = bool(_is_jailed(target)) + if jail := _is_jailed(robber): + if not target_jailed: + return {"ok": False, "reason": "jailed", "remaining": jail} + elif target_jailed: + return {"ok": False, "reason": "target_jailed"} + is_house = (HOUSE_ID is not None and target_id == HOUSE_ID) + if is_house and target["balance"] < 50: + return {"ok": False, "reason": "broke"} + if not is_house and target["balance"] < 100: + return {"ok": False, "reason": "broke"} + + robber["last_rob"] = _now().isoformat() + + if "anticheat" in target["items"] and not is_house: + fine = random.randint(100, 200) + robber["balance"] = max(0, robber["balance"] - fine) + # Decrement anticheat uses + uses = target.get("item_uses", {}).get("anticheat", 2) - 1 + if "item_uses" not in target: + target["item_uses"] = {} + if uses <= 0: + target["items"] = [i for i in target["items"] if i != "anticheat"] + target["item_uses"].pop("anticheat", None) + else: + target["item_uses"]["anticheat"] = uses + robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine + await _commit(robber_id, robber) + await _commit(target_id, target) + await _credit_house(fine) + _txn("ROB_BLOCKED", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"], ac_uses_left=uses) + return {"ok": True, "success": False, "reason": "valvur", "fine": fine} + + # Robbing the house has lower success (35%) but jackpot chance + success_chance = 0.35 if is_house else (0.60 if "jellyfin" in robber["items"] else 0.45) + if random.random() < success_chance: + jackpot = is_house and random.random() < 0.10 + if jackpot: + pct = 0.40 + elif is_house: + pct = random.uniform(0.05, 0.15) + else: + pct = random.uniform(0.10, 0.25) + stolen = max(10, min(int(target["balance"] * pct), target["balance"])) + target["balance"] -= stolen + robber["balance"] += stolen + robber["lifetime_earned"] = robber.get("lifetime_earned", 0) + stolen + robber["biggest_win"] = max(robber.get("biggest_win", 0), stolen) + robber["peak_balance"] = max(robber.get("peak_balance", 0), robber["balance"]) + await _commit(robber_id, robber) + await _commit(target_id, target) + _txn("ROB_WIN", robber=robber_id, victim=target_id, stolen=f"+{stolen}", jackpot=jackpot, robber_bal=robber["balance"], victim_bal=target["balance"]) + return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot} + else: + fine = random.randint(100, 250) + robber["balance"] = max(0, robber["balance"] - fine) + robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine + robber["biggest_loss"] = max(robber.get("biggest_loss", 0), fine) + await _commit(robber_id, robber) + await _credit_house(fine) + _txn("ROB_FAIL", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"]) + return {"ok": True, "success": False, "reason": "caught", "fine": fine, "balance": robber["balance"]} + + +# --------------------------------------------------------------------------- +# /roulette +# --------------------------------------------------------------------------- +async def do_roulette(user_id: int, bet: int, colour: str) -> dict: + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + if jail := _is_jailed(user): + return {"ok": False, "reason": "jailed", "remaining": jail} + + if user["balance"] < bet: + return {"ok": False, "reason": "insufficient"} + + # Wheel: 18 red, 18 black, 1 green (37 slots - real roulette proportions) + result = random.choices(["punane", "must", "roheline"], weights=[18, 18, 1])[0] + won = result == colour + mult = 14 if colour == "roheline" else 1 + change = bet * mult if won else -bet + user["balance"] = max(0, user["balance"] + change) + user["total_wagered"] = user.get("total_wagered", 0) + bet + if won: + user["lifetime_earned"] = user.get("lifetime_earned", 0) + abs(change) + user["biggest_win"] = max(user.get("biggest_win", 0), abs(change)) + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + else: + user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet + user["biggest_loss"] = max(user.get("biggest_loss", 0), bet) + await _commit(user_id, user) + if not won: + await _credit_house(bet) + _txn("ROULETTE_" + ("WIN" if won else "LOSE"), user=user_id, bet=bet, colour=colour, result=result, mult=mult, bal=user["balance"]) + + return { + "ok": True, "won": won, + "result": result, "change": abs(change), "mult": mult, + "balance": user["balance"], + } + + +# --------------------------------------------------------------------------- +# /rps (bet resolution) +# --------------------------------------------------------------------------- +async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: + """Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'.""" + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + if jail := _is_jailed(user): + return {"ok": False, "reason": "jailed", "remaining": jail} + if user["balance"] < bet: + return {"ok": False, "reason": "insufficient"} + user["total_wagered"] = user.get("total_wagered", 0) + bet + if outcome == "win": + user["balance"] += bet + user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet + user["biggest_win"] = max(user.get("biggest_win", 0), bet) + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + elif outcome == "lose": + user["balance"] = max(0, user["balance"] - bet) + user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet + user["biggest_loss"] = max(user.get("biggest_loss", 0), bet) + # tie: no change + await _commit(user_id, user) + if outcome == "lose" and bet > 0: + await _credit_house(bet) + _txn("RPS_" + outcome.upper(), user=user_id, bet=bet, bal=user["balance"]) + return {"ok": True, "balance": user["balance"]} + + +# --------------------------------------------------------------------------- +# /slots +# --------------------------------------------------------------------------- +_SLOTS_SYMBOLS: list[tuple[str, int]] = [ + ("<:TipiHEART:1483431377561976853>", 27), + ("<:TipiFIRE:1483431381668335687>", 22), + ("<:TipiTROLL:1483431380166774895>", 18), + ("<:TipICRY:1483431288852709387>", 15), + ("<:TipiSKULL:1483431378929451028>", 10), + ("<:TipiKARIKAS:1483014841148112977>", 8), +] +_SLOTS_JACKPOT = "<:TipiKARIKAS:1483014841148112977>" +_SLOTS_TRIPLE_MULT: dict[str, int] = { + "<:TipiHEART:1483431377561976853>": 4, + "<:TipiFIRE:1483431381668335687>": 5, + "<:TipiTROLL:1483431380166774895>": 7, + "<:TipICRY:1483431288852709387>": 10, + "<:TipiSKULL:1483431378929451028>": 15, + "<:TipiKARIKAS:1483014841148112977>": 25, # jackpot +} + + +def _spin() -> str: + symbols, weights = zip(*_SLOTS_SYMBOLS) + return random.choices(list(symbols), weights=list(weights), k=1)[0] + + +async def do_slots(user_id: int, bet: int) -> dict: + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + if jail := _is_jailed(user): + return {"ok": False, "reason": "jailed", "remaining": jail} + if user["balance"] < bet: + return {"ok": False, "reason": "insufficient"} + + reels = [_spin(), _spin(), _spin()] + a, b, c = reels + + has_360 = "monitor_360" in user["items"] + if a == b == c: + tier = "jackpot" if a == _SLOTS_JACKPOT else "triple" + base_mult = _SLOTS_TRIPLE_MULT.get(a, 4) + mult = int(base_mult * 1.5) if has_360 else base_mult + change = bet * (mult - 1) + elif a == b or b == c or a == c: + tier = "pair" + change = bet // 2 + else: + tier = "miss" + change = -bet + + user["balance"] = max(0, user["balance"] + change) + user["total_wagered"] = user.get("total_wagered", 0) + bet + if tier in ("jackpot", "triple", "pair"): + user["lifetime_earned"] = user.get("lifetime_earned", 0) + change + user["biggest_win"] = max(user.get("biggest_win", 0), change) + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + if tier == "jackpot": + user["slots_jackpots"] = user.get("slots_jackpots", 0) + 1 + else: + user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet + user["biggest_loss"] = max(user.get("biggest_loss", 0), bet) + await _commit(user_id, user) + if tier == "miss": + await _credit_house(bet) + _txn("SLOTS_" + tier.upper(), user=user_id, bet=bet, change=change, bal=user["balance"]) + + return { + "ok": True, + "reels": reels, + "tier": tier, + "change": change, + "balance": user["balance"], + } + + +# --------------------------------------------------------------------------- +# /give +# --------------------------------------------------------------------------- +async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: + giver = await get_user(giver_id) + if giver.get("eco_banned"): + return {"ok": False, "reason": "banned"} + + if rem := _is_jailed(giver): + return {"ok": False, "reason": "jailed", "remaining": rem} + + if giver["balance"] < amount: + return {"ok": False, "reason": "insufficient"} + + receiver = await get_user(receiver_id) + giver["balance"] -= amount + receiver["balance"] += amount + giver["total_given"] = giver.get("total_given", 0) + amount + receiver["total_received"] = receiver.get("total_received", 0) + amount + await _commit(giver_id, giver) + await _commit(receiver_id, receiver) + _txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"]) + + return { + "ok": True, + "giver_balance": giver["balance"], + "receiver_balance": receiver["balance"], + } + + +# --------------------------------------------------------------------------- +# /buy +# --------------------------------------------------------------------------- +async def do_buy(user_id: int, item_id: str) -> dict: + if item_id not in SHOP: + return {"ok": False, "reason": "not_found"} + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + + item = SHOP[item_id] + if item_id in user["items"]: + # Allow repurchase of anticheat if uses are depleted + if item_id == "anticheat" and user.get("item_uses", {}).get("anticheat", 2) <= 0: + user["items"] = [i for i in user["items"] if i != "anticheat"] + user.get("item_uses", {}).pop("anticheat", None) + else: + return {"ok": False, "reason": "owned"} + min_level = SHOP_LEVEL_REQ.get(item_id, 0) + if min_level > 0: + user_level = get_level(user.get("exp", 0)) + if user_level < min_level: + return {"ok": False, "reason": "level_required", "min_level": min_level, "user_level": user_level} + if user["balance"] < item["cost"]: + return {"ok": False, "reason": "insufficient", "need": item["cost"] - user["balance"]} + + user["balance"] -= item["cost"] + user["items"].append(item_id) + if item_id == "anticheat": + if "item_uses" not in user: + user["item_uses"] = {} + user["item_uses"]["anticheat"] = 2 + await _commit(user_id, user) + _txn("BUY", user=user_id, item=item_id, cost=f"-{item['cost']}", bal=user["balance"]) + + return {"ok": True, "item": item, "balance": user["balance"]} + + +# --------------------------------------------------------------------------- +# Admin actions +# --------------------------------------------------------------------------- +async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict: + """Give (positive) or take (negative) coins from a user. Balance is floored at 0.""" + user = await get_user(target_id) + user["balance"] = max(0, user["balance"] + amount) + await _commit(target_id, user) + verb = f"+{amount}" if amount >= 0 else str(amount) + _txn("ADMIN_COINS", admin=admin_id, target=target_id, amount=verb, reason=reason, bal=user["balance"]) + return {"ok": True, "balance": user["balance"], "change": amount} + + +async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict: + """Manually jail a user for `minutes` minutes.""" + user = await get_user(target_id) + user["jailed_until"] = (_now() + timedelta(minutes=minutes)).isoformat() + user["jailbreak_used"] = False + await _commit(target_id, user) + _txn("ADMIN_JAIL", admin=admin_id, target=target_id, minutes=minutes, reason=reason) + return {"ok": True, "jailed_until": user["jailed_until"]} + + +async def do_admin_unjail(target_id: int, admin_id: int) -> dict: + """Remove jail from a user.""" + user = await get_user(target_id) + user["jailed_until"] = None + user["jailbreak_used"] = False + await _commit(target_id, user) + _txn("ADMIN_UNJAIL", admin=admin_id, target=target_id) + return {"ok": True} + + +async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict: + """Ban a user from all economy commands.""" + user = await get_user(target_id) + user["eco_banned"] = True + await _commit(target_id, user) + _txn("ADMIN_BAN", admin=admin_id, target=target_id, reason=reason) + return {"ok": True} + + +async def do_admin_unban(target_id: int, admin_id: int) -> dict: + """Lift an economy ban.""" + user = await get_user(target_id) + user["eco_banned"] = False + await _commit(target_id, user) + _txn("ADMIN_UNBAN", admin=admin_id, target=target_id) + return {"ok": True} + + +async def do_admin_reset(target_id: int, admin_id: int) -> dict: + """Wipe a user's economy data back to defaults.""" + user = await get_user(target_id) + fresh = _default_user() + fresh["_pb_id"] = user.get("_pb_id") # type: ignore[typeddict-unknown-key] + await _commit(target_id, fresh) + _txn("ADMIN_RESET", admin=admin_id, target=target_id) + return {"ok": True} + + +async def do_admin_inspect(target_id: int) -> dict: + """Return the user's full raw economy data.""" + user = await get_user(target_id) + return {"ok": True, "data": dict(user)} + + +# --------------------------------------------------------------------------- +# /reminders +# --------------------------------------------------------------------------- +async def do_set_reminders(user_id: int, commands: list[str]) -> None: + """Overwrite the user's reminder list with the given command names.""" + user = await get_user(user_id) + user["reminders"] = list(commands) + await _commit(user_id, user) + + +# --------------------------------------------------------------------------- +# /blackjack +# --------------------------------------------------------------------------- +async def do_blackjack_bet(user_id: int, bet: int) -> dict: + """Deduct the initial blackjack bet. Returns ok/fail.""" + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + if jail := _is_jailed(user): + return {"ok": False, "reason": "jailed", "remaining": jail} + if user["balance"] < bet: + return {"ok": False, "reason": "insufficient", "balance": user["balance"]} + user["balance"] -= bet + await _commit(user_id, user) + return {"ok": True, "balance": user["balance"]} + + +async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict: + """Credit the net payout. House receives the difference when payout < total_invested.""" + user = await get_user(user_id) + user["balance"] += payout + user["balance"] = max(0, user["balance"]) + user["total_wagered"] = user.get("total_wagered", 0) + total_invested + net = payout - total_invested + if net > 0: + user["lifetime_earned"] = user.get("lifetime_earned", 0) + net + user["biggest_win"] = max(user.get("biggest_win", 0), net) + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + elif net < 0: + user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net) + user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net)) + await _commit(user_id, user) + house_gain = total_invested - payout + if house_gain > 0: + await _credit_house(house_gain) + _txn("BLACKJACK", user=user_id, payout=f"{payout:+}", net=f"{net:+}", bal=user["balance"]) + return {"ok": True, "balance": user["balance"]} + + +# --------------------------------------------------------------------------- +# /jailed +# --------------------------------------------------------------------------- + +async def do_get_jailed() -> list[tuple[int, timedelta]]: + """Return [(user_id, remaining)] for every user currently in jail.""" + all_users = await get_all_users_raw() + result: list[tuple[int, timedelta]] = [] + for uid_str, user in all_users.items(): + if rem := _is_jailed(user): + result.append((int(uid_str), rem)) + result.sort(key=lambda x: x[1]) + return result + + +# --------------------------------------------------------------------------- +# /heist +# --------------------------------------------------------------------------- + +async def do_heist_check(user_id: int) -> dict: + """Check whether a user is eligible to join a heist.""" + user = await get_user(user_id) + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + if jail := _is_jailed(user): + return {"ok": False, "reason": "jailed", "remaining": jail} + return {"ok": True} + + +async def do_heist_resolve(user_ids: list[int], success: bool) -> dict: + """Apply heist outcome to all participants. On win, steals from house.""" + now = _now() + payout_each = 0 + + if success and HOUSE_ID is not None: + house = await get_user(HOUSE_ID) + pct = random.uniform(0.20, 0.55) + total = max(300, int(house["balance"] * pct)) + payout_each = total // len(user_ids) + house["balance"] = max(0, house["balance"] - total) + await _commit(HOUSE_ID, house) + _txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"]) + + for uid in user_ids: + user = await get_user(uid) + user["last_heist"] = now.isoformat() + user["heists_joined"] = user.get("heists_joined", 0) + 1 + if success: + user["balance"] += payout_each + user["heists_won"] = user.get("heists_won", 0) + 1 + user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + _txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"]) + else: + fine = max(150, min(1000, int(user["balance"] * 0.15))) + user["balance"] = max(0, user["balance"] - fine) + user["jailed_until"] = (now + HEIST_JAIL).isoformat() + user["jailbreak_used"] = False + user["times_jailed"] = user.get("times_jailed", 0) + 1 + user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine + _txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"]) + if fine > 0: + await _credit_house(fine) + await _commit(uid, user) + + return {"ok": True, "payout_each": payout_each, "success": success} diff --git a/logs/bot.log b/logs/bot.log new file mode 100644 index 0000000..c1fd299 --- /dev/null +++ b/logs/bot.log @@ -0,0 +1,1519 @@ +2026-03-16 14:13:24,452 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 14:13:24,453 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 14:13:24,463 [INFO] discord.client: logging in using static token +2026-03-16 14:13:25,622 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 7193819ae76e7f6c6ef3f1b2e6c981de). +2026-03-16 14:13:27,631 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 14:13:29,877 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 14:13:30,128 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 14:13:30,129 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 14:50:45,277 [INFO] tipilan: /check - OK=59, Fixed=0, NotFound=0, IDs=0, Errors=0 +2026-03-16 15:01:59,787 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 15:01:59,788 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 15:01:59,794 [INFO] discord.client: logging in using static token +2026-03-16 15:02:01,294 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 11cf006fac706d8b4e7cc4433e94e2c7). +2026-03-16 15:02:03,305 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 15:02:07,828 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 15:02:08,305 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 15:02:08,306 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 15:08:44,753 [INFO] tipilan: ADMINBAN lkfs (testing) by alexander.rr37 +2026-03-16 15:09:05,018 [INFO] tipilan: ADMINUNBAN lkfs by alexander.rr37 +2026-03-16 15:14:06,795 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 15:14:06,795 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 15:14:06,801 [INFO] discord.client: logging in using static token +2026-03-16 15:14:07,700 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 998396f3d5b486d8cc417d273b0c42e6). +2026-03-16 15:14:09,704 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 15:14:13,547 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 15:14:13,809 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 15:14:13,810 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 15:15:47,514 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 15:15:47,514 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 15:15:47,522 [INFO] discord.client: logging in using static token +2026-03-16 15:15:48,632 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 0b53a7c5666bbc1e4607169e7121a8e9). +2026-03-16 15:15:50,653 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 15:15:55,276 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 15:15:55,533 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 15:15:55,533 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 15:18:55,541 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 15:18:55,542 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 15:18:55,547 [INFO] discord.client: logging in using static token +2026-03-16 15:18:56,560 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: c32e0171038cd3e18293912801ba0bf6). +2026-03-16 15:18:58,573 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 15:19:03,300 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 15:19:03,529 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 15:19:03,529 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 15:19:03,530 [INFO] tipilan: Restored 4 reminder task(s) after restart +2026-03-16 15:21:16,114 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 15:21:16,117 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 15:21:16,124 [INFO] discord.client: logging in using static token +2026-03-16 15:21:17,052 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 8177d75b2dc72c0bfd3747b6e92afb02). +2026-03-16 15:21:19,071 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 15:21:22,529 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 15:21:22,737 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 15:21:22,739 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 15:21:22,740 [INFO] tipilan: Migrated 11 user(s) to default reminders +2026-03-16 15:21:22,749 [INFO] tipilan: Restored 27 reminder task(s) after restart +2026-03-16 15:31:59,185 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 15:31:59,187 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 15:31:59,194 [INFO] discord.client: logging in using static token +2026-03-16 15:32:00,146 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 5e54d25e261e3bec9b3521dd01330ecc). +2026-03-16 15:32:02,157 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 15:32:06,957 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 15:32:07,350 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 15:32:07,351 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 15:32:07,353 [INFO] tipilan: Restored 29 reminder task(s) after restart +2026-03-16 15:38:37,236 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 15:38:37,237 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 15:38:37,244 [INFO] discord.client: logging in using static token +2026-03-16 15:38:38,342 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: b456b4c43aae1ca14999c337bddc9dfc). +2026-03-16 15:38:40,358 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 15:38:43,834 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 15:38:44,074 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 15:38:44,075 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 15:38:44,075 [INFO] tipilan: Restored 28 reminder task(s) after restart +2026-03-16 16:03:48,162 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 16:03:48,163 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 16:03:48,177 [INFO] discord.client: logging in using static token +2026-03-16 16:03:49,116 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 79a63f715fdeef2b0ca8ab286a70a7f1). +2026-03-16 16:03:51,132 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 16:03:59,900 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 16:04:00,754 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (+ global) +2026-03-16 16:04:00,756 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 16:04:00,757 [INFO] tipilan: Restored 29 reminder task(s) after restart +2026-03-16 16:05:43,807 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 16:05:43,807 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 16:05:43,814 [INFO] discord.client: logging in using static token +2026-03-16 16:05:44,875 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 3b6939fb2021be9779fb667b74d5c4f4). +2026-03-16 16:05:46,893 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 16:05:51,386 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 16:05:51,652 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 16:05:51,653 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 16:05:51,654 [INFO] tipilan: Restored 28 reminder task(s) after restart +2026-03-16 16:06:35,528 [INFO] tipilan: /sync triggered by alexander.rr37 +2026-03-16 16:11:58,339 [INFO] tipilan: ALLOWCHANNEL +bot-spam by alexander.rr37 +2026-03-16 16:55:41,963 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 16:55:41,963 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 16:55:41,970 [INFO] discord.client: logging in using static token +2026-03-16 16:55:42,945 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 7a84a1b6b0eb32aa42550bf3474a1f99). +2026-03-16 16:55:44,944 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 16:55:49,150 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 16:55:49,357 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 16:55:49,357 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 16:55:49,360 [INFO] tipilan: Migrated 3 user(s) anticheat item_uses +2026-03-16 16:55:49,368 [INFO] tipilan: Restored 29 reminder task(s) after restart +2026-03-16 16:58:21,236 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 16:58:21,236 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 16:58:21,243 [INFO] discord.client: logging in using static token +2026-03-16 16:58:22,565 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 2d4216274ce02b68c632192c1f3b48db). +2026-03-16 16:58:24,588 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 16:58:27,427 [INFO] tipilan: Loaded 62 member rows from Google Sheets +2026-03-16 16:58:27,783 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 16:58:27,783 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 16:58:27,784 [INFO] tipilan: Restored 27 reminder task(s) after restart +2026-03-16 18:01:24,838 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-16 18:01:25,474 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-16 18:01:25,474 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-16 18:01:25,481 [INFO] discord.client: logging in using static token +2026-03-16 18:01:26,511 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 9d70617162aac1a41f28e4792da8c869). +2026-03-16 18:01:28,515 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-16 18:01:31,579 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-16 18:01:31,841 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-16 18:01:31,841 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-16 18:01:31,841 [INFO] tipilan: Restored 22 reminder task(s) after restart +2026-03-16 18:02:32,169 [INFO] tipilan: /sync triggered by alexander.rr37 +2026-03-16 19:19:02,602 [INFO] discord.gateway: Shard ID None has successfully RESUMED session 9d70617162aac1a41f28e4792da8c869. +2026-03-16 20:38:53,966 [INFO] discord.gateway: Shard ID None has successfully RESUMED session 9d70617162aac1a41f28e4792da8c869. +2026-03-16 20:56:15,557 [INFO] discord.gateway: Shard ID None has successfully RESUMED session 9d70617162aac1a41f28e4792da8c869. +2026-03-16 22:00:58,513 [INFO] discord.gateway: Shard ID None has successfully RESUMED session 9d70617162aac1a41f28e4792da8c869. +2026-03-16 22:28:51,009 [ERROR] tipilan: Unhandled slash command error: Failed to convert TipiBOT#7287 to Member +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\tree.py", line 1302, in _call + await command._invoke_with_namespace(interaction, namespace) + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 883, in _invoke_with_namespace + transformed_values = await self._transform_arguments(interaction, namespace) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 849, in _transform_arguments + transformed_values[param.name] = await param.transform(interaction, value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\transformers.py", line 184, in transform + return await maybe_coroutine(self._annotation.transform, interaction, value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\utils.py", line 713, in maybe_coroutine + return await value + ^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\transformers.py", line 625, in transform + raise TransformerError(value, self.type, self) +discord.app_commands.errors.TransformerError: Failed to convert TipiBOT#7287 to Member +2026-03-16 23:12:40,110 [ERROR] tipilan: Unhandled slash command error: Failed to convert TipiBOT#7287 to Member +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\tree.py", line 1302, in _call + await command._invoke_with_namespace(interaction, namespace) + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 883, in _invoke_with_namespace + transformed_values = await self._transform_arguments(interaction, namespace) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 849, in _transform_arguments + transformed_values[param.name] = await param.transform(interaction, value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\transformers.py", line 184, in transform + return await maybe_coroutine(self._annotation.transform, interaction, value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\utils.py", line 713, in maybe_coroutine + return await value + ^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\transformers.py", line 625, in transform + raise TransformerError(value, self.type, self) +discord.app_commands.errors.TransformerError: Failed to convert TipiBOT#7287 to Member +2026-03-16 23:16:45,961 [ERROR] tipilan: Unhandled slash command error: Failed to convert TipiBOT#7287 to Member +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\tree.py", line 1302, in _call + await command._invoke_with_namespace(interaction, namespace) + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 883, in _invoke_with_namespace + transformed_values = await self._transform_arguments(interaction, namespace) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 849, in _transform_arguments + transformed_values[param.name] = await param.transform(interaction, value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\transformers.py", line 184, in transform + return await maybe_coroutine(self._annotation.transform, interaction, value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\utils.py", line 713, in maybe_coroutine + return await value + ^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\transformers.py", line 625, in transform + raise TransformerError(value, self.type, self) +discord.app_commands.errors.TransformerError: Failed to convert TipiBOT#7287 to Member +2026-03-16 23:27:56,124 [INFO] discord.gateway: Shard ID None has successfully RESUMED session 9d70617162aac1a41f28e4792da8c869. +2026-03-16 23:50:58,751 [ERROR] tipilan: Unhandled slash command error: Failed to convert TipiBOT#7287 to Member +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\tree.py", line 1302, in _call + await command._invoke_with_namespace(interaction, namespace) + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 883, in _invoke_with_namespace + transformed_values = await self._transform_arguments(interaction, namespace) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 849, in _transform_arguments + transformed_values[param.name] = await param.transform(interaction, value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\transformers.py", line 184, in transform + return await maybe_coroutine(self._annotation.transform, interaction, value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\utils.py", line 713, in maybe_coroutine + return await value + ^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\transformers.py", line 625, in transform + raise TransformerError(value, self.type, self) +discord.app_commands.errors.TransformerError: Failed to convert TipiBOT#7287 to Member +2026-03-17 00:11:21,111 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 00:11:21,112 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 00:11:21,137 [INFO] discord.client: logging in using static token +2026-03-17 00:11:22,378 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: a4001a87115595fb08823f35eb89bc61). +2026-03-17 00:11:24,384 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 00:11:29,600 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 00:11:29,821 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 +2026-03-17 00:11:29,821 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 00:11:29,822 [INFO] tipilan: Restored 23 reminder task(s) after restart +2026-03-17 00:13:07,277 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 00:13:07,277 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 00:13:07,283 [INFO] discord.client: logging in using static token +2026-03-17 00:13:08,431 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 0667a0bb4567068448c492757bc291c1). +2026-03-17 00:13:10,453 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 00:13:14,817 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 00:13:15,390 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 00:13:15,392 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 00:13:15,392 [INFO] tipilan: Restored 23 reminder task(s) after restart +2026-03-17 00:22:18,635 [INFO] tipilan: /sync triggered by alexander.rr37 +2026-03-17 00:22:22,289 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 00:22:22,289 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 00:22:22,295 [INFO] discord.client: logging in using static token +2026-03-17 00:22:23,550 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 9f88b35aecdcee9dfdeaee4dbc4a1470). +2026-03-17 00:22:25,595 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 00:22:29,679 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 00:23:04,141 [INFO] tipilan: ADMINVIEW kalatexx by alexander.rr37 +2026-03-17 00:23:18,409 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 00:23:18,410 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 00:23:18,411 [INFO] tipilan: Restored 25 reminder task(s) after restart +2026-03-17 00:27:51,204 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 00:27:51,204 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 00:27:51,210 [INFO] discord.client: logging in using static token +2026-03-17 00:27:52,576 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 99c9c3cf2177ed1d4ebddeaaadfc854c). +2026-03-17 00:27:54,579 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 00:27:58,953 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 00:27:59,371 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 00:27:59,372 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 00:27:59,372 [INFO] tipilan: Restored 22 reminder task(s) after restart +2026-03-17 07:48:56,091 [WARNING] discord.gateway: Shard ID None has stopped responding to the gateway. Closing and restarting. +2026-03-17 07:48:56,817 [INFO] discord.gateway: Shard ID None session has been invalidated. +2026-03-17 07:49:02,508 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 3f7291c6c9bb3e9b6473cd6b1d9f7402). +2026-03-17 07:49:04,535 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 07:49:09,215 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 07:49:09,639 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 07:49:09,641 [INFO] tipilan: Restored 5 reminder task(s) after restart +2026-03-17 09:00:06,368 [ERROR] discord.ext.tasks: Unhandled exception in internal background task 'birthday_daily'. +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\ext\tasks\__init__.py", line 247, in _loop + await self.coro(*args, **kwargs) + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\bot.py", line 305, in birthday_daily + if not is_birthday_today(bday_str): + ^^^^^^^^^^^^^^^^^ +NameError: name 'is_birthday_today' is not defined +2026-03-17 09:53:46,528 [INFO] discord.gateway: Shard ID None has successfully RESUMED session 3f7291c6c9bb3e9b6473cd6b1d9f7402. +2026-03-17 09:53:46,721 [ERROR] tipilan: Unhandled slash command error: Command 'roulette' raised an exception: NotFound: 404 Not Found (error code: 10062): Unknown interaction +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 859, in _do_call + return await self._callback(interaction, **params) # type: ignore + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\bot.py", line 1422, in cmd_roulette + if res["success"]: + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\interactions.py", line 1085, in send_message + data = await adapter.create_interaction_response( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\webhook\async_.py", line 224, in request + raise NotFound(response, data) +discord.errors.NotFound: 404 Not Found (error code: 10062): Unknown interaction + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\tree.py", line 1302, in _call + await command._invoke_with_namespace(interaction, namespace) + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 884, in _invoke_with_namespace + return await self._do_call(interaction, transformed_values) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 877, in _do_call + raise CommandInvokeError(self, e) from e +discord.app_commands.errors.CommandInvokeError: Command 'roulette' raised an exception: NotFound: 404 Not Found (error code: 10062): Unknown interaction +2026-03-17 09:57:46,924 [ERROR] asyncio: Task exception was never retrieved +future: exception=NameError("name 'is_birthday_today' is not defined")> +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\ext\tasks\__init__.py", line 279, in _loop + raise exc + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\ext\tasks\__init__.py", line 247, in _loop + await self.coro(*args, **kwargs) + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\bot.py", line 305, in birthday_daily + continue +NameError: name 'is_birthday_today' is not defined +2026-03-17 09:57:51,976 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 09:57:51,977 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 09:57:51,984 [INFO] discord.client: logging in using static token +2026-03-17 09:57:52,964 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: ce3f0c99662256c337ab44b0a940b6d6). +2026-03-17 09:57:54,979 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 09:57:59,370 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 09:57:59,822 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 09:57:59,823 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 09:57:59,824 [INFO] tipilan: Restored 26 reminder task(s) after restart +2026-03-17 09:58:54,556 [INFO] tipilan: /adminseason triggered by alexander.rr37 (top_n=10) +2026-03-17 09:59:00,103 [INFO] tipilan: /sync triggered by alexander.rr37 +2026-03-17 10:01:37,085 [INFO] tipilan: /adminseason triggered by alexander.rr37 (top_n=10) +2026-03-17 10:01:49,748 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 10:01:49,749 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 10:01:49,755 [INFO] discord.client: logging in using static token +2026-03-17 10:01:50,670 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 9e2785fa27146c1e1a1f92a23ed16f84). +2026-03-17 10:01:52,702 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 10:01:55,519 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 10:01:55,939 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 10:01:55,939 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 10:01:55,941 [INFO] tipilan: Restored 25 reminder task(s) after restart +2026-03-17 10:02:05,085 [INFO] tipilan: /adminseason triggered by alexander.rr37 (top_n=10) +2026-03-17 10:26:58,570 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 10:26:59,216 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 10:26:59,216 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 10:26:59,222 [INFO] discord.client: logging in using static token +2026-03-17 10:27:00,236 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: ba8aa9a70fd429d76c653b211de74fee). +2026-03-17 10:27:02,264 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 10:27:06,072 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 10:27:06,648 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 10:27:06,649 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 10:27:06,649 [INFO] tipilan: Restored 25 reminder task(s) after restart +2026-03-17 10:27:19,824 [INFO] tipilan: /adminseason triggered by alexander.rr37 (top_n=10) +2026-03-17 10:45:11,855 [ERROR] tipiCOIN.economy: Failed to save economy.json: [WinError 5] Access is denied: 'data\\economy.tmp' -> 'data\\economy.json' +2026-03-17 10:48:15,565 [ERROR] tipiCOIN.economy: Failed to save economy.json: [WinError 5] Access is denied: 'data\\economy.tmp' -> 'data\\economy.json' +2026-03-17 12:00:54,316 [ERROR] tipiCOIN.economy: Failed to save economy.json: [WinError 5] Access is denied: 'data\\economy.tmp' -> 'data\\economy.json' +2026-03-17 12:32:08,567 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 12:32:14,896 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 12:32:14,897 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 12:32:14,923 [INFO] discord.client: logging in using static token +2026-03-17 12:32:16,134 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: b3db3a73c79d47a5e080b37639b07a80). +2026-03-17 12:32:18,146 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 12:32:22,070 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 12:32:22,694 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 12:32:22,695 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 12:32:22,696 [INFO] tipilan: Restored 30 reminder task(s) after restart +2026-03-17 12:46:17,141 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 12:46:17,896 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 12:46:17,897 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 12:46:17,906 [INFO] discord.client: logging in using static token +2026-03-17 12:46:19,051 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 5000a01de16af3e16482b6adca69fc7b). +2026-03-17 12:46:21,062 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 12:46:25,363 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 12:46:25,792 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 12:46:25,794 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 12:46:25,796 [INFO] tipilan: Restored 31 reminder task(s) after restart +2026-03-17 12:54:13,222 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 12:54:14,185 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 12:54:14,187 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 12:54:14,194 [INFO] discord.client: logging in using static token +2026-03-17 12:54:15,284 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 20a8ad0ef133931580f7f2b9edc76c60). +2026-03-17 12:54:17,292 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 12:54:22,253 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 12:54:22,722 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 12:54:22,724 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 12:54:22,726 [INFO] tipilan: Restored 23 reminder task(s) after restart +2026-03-17 12:55:07,804 [INFO] tipilan: ADMINUNJAIL ukku by alexander.rr37 +2026-03-17 13:01:24,897 [INFO] tipilan: /check - OK=59, Fixed=0, NotFound=0, IDs=0, Errors=0 +2026-03-17 13:04:04,288 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 13:04:05,188 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 13:04:05,188 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 13:04:05,195 [INFO] discord.client: logging in using static token +2026-03-17 13:04:06,547 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 3dd57e55526a0ab84467aa9f56fa2820). +2026-03-17 13:04:08,566 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 13:04:12,179 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 13:04:12,603 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 13:04:12,604 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 13:04:12,605 [INFO] tipilan: Restored 24 reminder task(s) after restart +2026-03-17 13:05:11,579 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 13:05:12,383 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 13:05:12,383 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 13:05:12,391 [INFO] discord.client: logging in using static token +2026-03-17 13:05:13,483 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: a0abf6552d735123581887a19471c361). +2026-03-17 13:05:15,519 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 13:05:20,256 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 13:05:20,738 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 13:05:20,739 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 13:05:20,740 [INFO] tipilan: Restored 24 reminder task(s) after restart +2026-03-17 13:10:10,081 [INFO] tipilan: /economysetup triggered by alexander.rr37 +2026-03-17 13:23:27,106 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 13:23:27,106 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 13:23:27,116 [INFO] discord.client: logging in using static token +2026-03-17 13:23:28,051 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: f2b6053fcc6694f32330e69315e8e394). +2026-03-17 13:23:30,062 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 13:23:33,758 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 13:23:34,224 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 13:23:34,225 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 13:23:34,306 [INFO] tipilan: Restored 23 reminder task(s) after restart +2026-03-17 13:43:19,219 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 13:43:19,220 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 13:43:19,230 [INFO] discord.client: logging in using static token +2026-03-17 13:43:20,901 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 68078257fc77b14f87239cfdcbc3f4cf). +2026-03-17 13:43:22,910 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 13:43:27,022 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 13:43:27,435 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 13:43:27,435 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 13:43:27,509 [INFO] tipilan: Restored 24 reminder task(s) after restart +2026-03-17 13:51:35,097 [ERROR] asyncio: Unclosed client session +client_session: +2026-03-17 13:51:36,218 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 13:51:36,219 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 13:51:36,225 [INFO] discord.client: logging in using static token +2026-03-17 13:51:37,287 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: ac4da6249c5a4f5281ed7cd2372dd4cc). +2026-03-17 13:51:39,295 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 13:51:42,588 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 13:51:43,240 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 13:51:43,240 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 13:51:43,241 [INFO] tipilan: Rich presence rotation started +2026-03-17 13:51:43,322 [INFO] tipilan: Restored 23 reminder task(s) after restart +2026-03-17 13:56:32,848 [ERROR] asyncio: Unclosed client session +client_session: +2026-03-17 13:56:34,181 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 13:56:34,181 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 13:56:34,191 [INFO] discord.client: logging in using static token +2026-03-17 13:56:35,546 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: e2cd57e7519524af2a4b1c3b8450c997). +2026-03-17 13:56:37,568 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 13:56:42,152 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 13:56:42,573 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 13:56:42,574 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 13:56:42,574 [INFO] tipilan: Rich presence rotation started +2026-03-17 13:56:42,655 [INFO] tipilan: Restored 21 reminder task(s) after restart +2026-03-17 14:05:08,684 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 14:05:08,751 [ERROR] asyncio: Unclosed client session +client_session: +2026-03-17 14:05:09,467 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 14:05:09,467 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 14:05:09,474 [INFO] discord.client: logging in using static token +2026-03-17 14:05:10,622 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 4f3f1c6a3ef87f3de778ab09b2723674). +2026-03-17 14:05:12,616 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 14:05:15,827 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 14:05:16,324 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 14:05:16,325 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 14:05:16,325 [INFO] tipilan: Rich presence rotation started +2026-03-17 14:05:16,398 [INFO] tipilan: Restored 23 reminder task(s) after restart +2026-03-17 14:08:34,688 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 14:08:34,748 [ERROR] asyncio: Unclosed client session +client_session: +2026-03-17 14:08:34,749 [ERROR] asyncio: Unclosed connector +connections: ['deque([(, 339752.343), (, 339752.359)])'] +connector: +2026-03-17 14:08:35,470 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 14:08:35,470 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 14:08:35,480 [INFO] discord.client: logging in using static token +2026-03-17 14:08:36,584 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: db2b1692d33e9a746fd91e57b61efa2b). +2026-03-17 14:08:38,610 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 14:08:42,082 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 14:08:42,514 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 14:08:42,514 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 14:08:42,515 [INFO] tipilan: Rich presence rotation started +2026-03-17 14:08:42,517 [WARNING] tipilan: Presence: failed to fetch economy count: name 'pb_client' is not defined +2026-03-17 14:08:42,583 [INFO] tipilan: Restored 26 reminder task(s) after restart +2026-03-17 14:09:02,515 [WARNING] tipilan: Presence: failed to fetch economy count: name 'pb_client' is not defined +2026-03-17 14:09:22,521 [WARNING] tipilan: Presence: failed to fetch economy count: name 'pb_client' is not defined +2026-03-17 14:09:42,524 [WARNING] tipilan: Presence: failed to fetch economy count: name 'pb_client' is not defined +2026-03-17 14:10:02,516 [WARNING] tipilan: Presence: failed to fetch economy count: name 'pb_client' is not defined +2026-03-17 14:10:04,488 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 14:10:04,561 [ERROR] asyncio: Unclosed client session +client_session: +2026-03-17 14:10:04,562 [ERROR] asyncio: Unclosed connector +connections: ['deque([(, 339838.906)])'] +connector: +2026-03-17 14:10:05,419 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 14:10:05,419 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 14:10:05,427 [INFO] discord.client: logging in using static token +2026-03-17 14:10:06,435 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: a4369d6344c91fef554686600d1e8814). +2026-03-17 14:10:08,455 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 14:10:12,040 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 14:10:12,497 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 14:10:12,498 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 14:10:12,498 [INFO] tipilan: Rich presence rotation started +2026-03-17 14:10:12,568 [INFO] tipilan: Restored 26 reminder task(s) after restart +2026-03-17 14:14:08,702 [ERROR] tipilan: Unhandled slash command error: Command 'help' raised an exception: NameError: name 'math' is not defined +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 859, in _do_call + return await self._callback(interaction, **params) # type: ignore + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\bot.py", line 669, in cmd_help + embed=_help_embed("üldine"), view=HelpView(is_admin), ephemeral=True + ^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\bot.py", line 592, in _help_embed + total_pages = math.ceil(len(fields) / _HELP_PAGE_SIZE) + ^^^^ +NameError: name 'math' is not defined + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\tree.py", line 1302, in _call + await command._invoke_with_namespace(interaction, namespace) + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 884, in _invoke_with_namespace + return await self._do_call(interaction, transformed_values) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\app_commands\commands.py", line 877, in _do_call + raise CommandInvokeError(self, e) from e +discord.app_commands.errors.CommandInvokeError: Command 'help' raised an exception: NameError: name 'math' is not defined +2026-03-17 14:19:47,402 [INFO] tipilan: /restart triggered by alexander.rr37 +2026-03-17 14:19:47,474 [ERROR] asyncio: Unclosed client session +client_session: +2026-03-17 14:19:47,475 [ERROR] asyncio: Unclosed connector +connections: ['deque([(, 340430.859), (, 340430.859)])'] +connector: +2026-03-17 14:19:48,143 [WARNING] discord.client: PyNaCl is not installed, voice will NOT be supported +2026-03-17 14:19:48,143 [WARNING] discord.client: davey is not installed, voice will NOT be supported +2026-03-17 14:19:48,152 [INFO] discord.client: logging in using static token +2026-03-17 14:19:49,088 [INFO] discord.gateway: Shard ID None has connected to Gateway (Session ID: 3a7ec7641141b4cf9237263e5384d24f). +2026-03-17 14:19:51,100 [INFO] tipilan: Logged in as TipiBOT#7287 (ID: 1482364717291933766) +2026-03-17 14:19:54,909 [INFO] tipilan: Loaded 63 member rows from Google Sheets +2026-03-17 14:19:55,347 [INFO] tipilan: Slash commands synced to guild 1478302278086819946 (global commands cleared) +2026-03-17 14:19:55,347 [INFO] tipilan: Birthday daily task started (fires 09:00 Tallinn time) +2026-03-17 14:19:55,348 [INFO] tipilan: Rich presence rotation started +2026-03-17 14:19:55,409 [INFO] tipilan: Restored 28 reminder task(s) after restart +2026-03-17 14:22:30,398 [ERROR] discord.ui.view: Ignoring exception in view for item +Traceback (most recent call last): + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\.venv\Lib\site-packages\discord\ui\view.py", line 598, in _scheduled_task + await item.callback(interaction) + File "C:\Users\Sass\Documents\GitHub\tipilan-bot\bot.py", line 662, in callback + await interaction.response.edit_message(embed=_help_embed(self.view.category, 0), view=self.view) + ^^^^^^^^^^^^^^^^^^ +AttributeError: 'NoneType' object has no attribute 'category' +2026-03-17 14:37:37,953 [WARNING] discord.ui.view: View interaction referencing unknown view for item