Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a3dcb03b | ||
|
|
0e617d87a2 | ||
|
|
2c2621d24e | ||
|
|
192888625e | ||
|
|
6d344a47f4 | ||
|
|
07e7f5e0b2 | ||
|
|
d65173fbe9 | ||
|
|
58684d5f34 | ||
|
|
8529706809 | ||
|
|
173e2564f1 | ||
|
|
93f4d471dc | ||
|
|
de7cfce833 | ||
|
|
a4a447867f | ||
|
|
9ae26049c5 | ||
|
|
b998418c14 | ||
|
|
94df54dde2 | ||
|
|
77a3badd41 | ||
|
|
17102ae202 | ||
|
|
cd41bc2a48 | ||
| 802a6a2e8d | |||
|
|
64d9b304a9 | ||
|
|
07360d3f11 | ||
| 8f28832432 | |||
|
|
4d1981420d | ||
|
|
14927b610d |
34
.env.example
34
.env.example
@@ -1,5 +1,10 @@
|
|||||||
# Discord bot token (from https://discord.com/developers/applications)
|
|
||||||
DISCORD_TOKEN=your-bot-token-here
|
# Profile-specific Discord bot tokens (from https://discord.com/developers/applications)
|
||||||
|
DISCORD_TOKEN_DEV=your-dev-bot-token-here
|
||||||
|
DISCORD_TOKEN_ECONOMY=your-economy-bot-token-here
|
||||||
|
|
||||||
|
# Legacy fallback token (optional, backward compatibility)
|
||||||
|
DISCORD_TOKEN=
|
||||||
|
|
||||||
# Google Sheets spreadsheet ID (the long string in the sheet URL)
|
# Google Sheets spreadsheet ID (the long string in the sheet URL)
|
||||||
SHEET_ID=your-google-sheet-id-here
|
SHEET_ID=your-google-sheet-id-here
|
||||||
@@ -7,11 +12,21 @@ SHEET_ID=your-google-sheet-id-here
|
|||||||
# Path to Google service account credentials JSON
|
# Path to Google service account credentials JSON
|
||||||
GOOGLE_CREDS_PATH=credentials.json
|
GOOGLE_CREDS_PATH=credentials.json
|
||||||
|
|
||||||
# Guild (server) ID - right-click your server with dev mode on
|
# Profile-specific guild (server) IDs - right-click your server with dev mode on
|
||||||
GUILD_ID=your-guild-id-here
|
GUILD_ID_DEV=your-dev-guild-id-here
|
||||||
|
GUILD_ID_ECONOMY=your-economy-guild-id-here
|
||||||
|
|
||||||
# Channel ID where birthday announcements are posted (bot sends @here on the birthday at 09:00 Tallinn time)
|
# Legacy fallback guild ID (optional, backward compatibility)
|
||||||
BIRTHDAY_CHANNEL_ID=your-channel-id-here
|
GUILD_ID=
|
||||||
|
|
||||||
|
# Channel ID where birthday announcements are posted (dev profile)
|
||||||
|
BIRTHDAY_CHANNEL_ID_DEV=your-dev-birthday-channel-id-here
|
||||||
|
|
||||||
|
# Optional birthday channel for economy profile (normally unset for economy-only bot)
|
||||||
|
BIRTHDAY_CHANNEL_ID_ECONOMY=
|
||||||
|
|
||||||
|
# Legacy fallback birthday channel ID (optional, backward compatibility)
|
||||||
|
BIRTHDAY_CHANNEL_ID=
|
||||||
|
|
||||||
# How many days before a birthday the on-join check counts as "coming up"
|
# How many days before a birthday the on-join check counts as "coming up"
|
||||||
BIRTHDAY_WINDOW_DAYS=7
|
BIRTHDAY_WINDOW_DAYS=7
|
||||||
@@ -20,3 +35,10 @@ BIRTHDAY_WINDOW_DAYS=7
|
|||||||
PB_URL=http://127.0.0.1:8090
|
PB_URL=http://127.0.0.1:8090
|
||||||
PB_ADMIN_EMAIL=admin@example.com
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
PB_ADMIN_PASSWORD=your-pb-admin-password
|
PB_ADMIN_PASSWORD=your-pb-admin-password
|
||||||
|
|
||||||
|
# Profile-specific PocketBase collections
|
||||||
|
PB_ECONOMY_COLLECTION_DEV=economy_users_dev
|
||||||
|
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod
|
||||||
|
|
||||||
|
# Legacy fallback collection name (optional, backward compatibility)
|
||||||
|
PB_ECONOMY_COLLECTION=
|
||||||
|
|||||||
17
.gitea/workflows/deploy.yml
Normal file
17
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: linux
|
||||||
|
steps:
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
cd ~/tipibot
|
||||||
|
git pull
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
sudo systemctl restart tipibot
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,10 +4,9 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
data/restart_channel.json
|
data/
|
||||||
data/economy.json
|
|
||||||
pocketbase.exe
|
pocketbase.exe
|
||||||
pocketbase
|
pocketbase
|
||||||
pb_data/
|
pb_data/
|
||||||
pb_migrations/
|
pb_migrations/
|
||||||
logs/
|
logs/
|
||||||
257
DEV_NOTES.md
257
DEV_NOTES.md
@@ -1,257 +0,0 @@
|
|||||||
# 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.) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding a New Economy Command
|
|
||||||
|
|
||||||
Checklist - do all of these, in order:
|
|
||||||
|
|
||||||
1. **`economy.py`** - add the `do_<cmd>` 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. **PocketBase** - if the function stores new fields, add them manually in the PB admin UI at `http://127.0.0.1:8090/_/`. Fields not in the PB schema are silently dropped on PATCH.
|
|
||||||
5. **`strings.py` `CMD`** - add the slash command description
|
|
||||||
6. **`strings.py` `OPT`** - add any parameter descriptions
|
|
||||||
7. **`strings.py` `TITLE`** - add embed title(s) for success/fail states
|
|
||||||
8. **`strings.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`)
|
|
||||||
9. **`strings.py` `CD_MSG`** - add cooldown message if command has a cooldown
|
|
||||||
10. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed
|
|
||||||
11. **`bot.py`** - implement the `cmd_<name>` function, handle all `res["reason"]` cases
|
|
||||||
12. **`bot.py`** - call `_maybe_remind` if the command has a cooldown and reminders make sense
|
|
||||||
13. **`bot.py`** - call `_award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` on success
|
|
||||||
14. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one
|
|
||||||
15. **`bot.py` `_maybe_remind`** and **`_restore_reminders`** - if item-modified cooldown, add `elif` branch
|
|
||||||
16. **`bot.py` `_REMINDER_COOLDOWN_KEYS`** - add `"cmd": "last_cmd"` mapping if reminder-capable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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_<cmd>` function
|
|
||||||
- **`bot.py` `_maybe_remind`** - add `elif cmd == "<cmd>" and "<item>" in items:` branch with the new delay
|
|
||||||
- **`bot.py` `_restore_reminders`** - add the same `elif` branch
|
|
||||||
- **`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` `OPT`** - add parameter descriptions
|
|
||||||
3. **`strings.py` `ADMIN`** - add response and DM strings
|
|
||||||
4. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry
|
|
||||||
5. **`economy.py`** - add `do_admin_<name>` function
|
|
||||||
6. **`bot.py`** - add command with `@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; prestige daily_plus adds +20% per level |
|
|
||||||
| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook; prestige work_plus +20%/level |
|
|
||||||
| `/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 directly |
|
|
||||||
| `/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 |
|
|
||||||
| `/fish` | 2min (90s w/ ussipurk) | varies by fish rarity | Interactive minigame; catches go to inventory; sell with `/fishsell` |
|
|
||||||
| `/slots` | - | varies | pair=+0.5× bet; triple tiered; karikas jackpot ×25; ×1.5 w/ monitor_360; miss=lose bet |
|
|
||||||
| `/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 |
|
|
||||||
|
|
||||||
### Fishing System
|
|
||||||
|
|
||||||
- `/fish` - interactive minigame: cast → wait 5–15s for bite → press button within 2s (3s w/ echolood) → keep or sell
|
|
||||||
- Fish stored in `fish_inventory` (list of `{fish_id, weight, value}` objects)
|
|
||||||
- `/fishbook` - paginated fish collection showing caught species and inventory counts
|
|
||||||
- `/fishsell` - sell all fish from inventory at once
|
|
||||||
- `fish_inventory` and `fish_book` **survive prestige resets**
|
|
||||||
- `kalavork` (T3, 5000⬡): bumps all caught fish up one rarity tier
|
|
||||||
- `ussipurk` (T2, 3500⬡): cooldown 2min → 90s
|
|
||||||
- `echolood` (T3, 8000⬡): bite window 2s → 3s
|
|
||||||
|
|
||||||
### Prestige System
|
|
||||||
|
|
||||||
- Requires level 30 (9000 EXP)
|
|
||||||
- Resets: balance, EXP, items, cooldowns, jail
|
|
||||||
- Preserves: fish_book, fish_inventory, lifetime stats, prestige_points, season_total_exp, prestige_upgrades
|
|
||||||
- Awards prestige_points = max(1, exp ÷ 1000) at time of prestige
|
|
||||||
- Each prestige increments `prestige_level` counter
|
|
||||||
- Prestige coin/exp multipliers apply to all earned values
|
|
||||||
|
|
||||||
**Prestige Shop** (`PRESTIGE_SHOP` in economy.py):
|
|
||||||
|
|
||||||
| Upgrade | Max Level | Cost/level | Effect |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `coin_mult` | 5 | 5 PP | +8% coin multiplier per level |
|
|
||||||
| `exp_mult` | 5 | 5 PP | +8% EXP multiplier per level |
|
|
||||||
| `daily_plus` | 3 | 7 PP | +20% daily base reward per level |
|
|
||||||
| `work_plus` | 3 | 7 PP | +20% work earnings per level |
|
|
||||||
|
|
||||||
### "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 single-button dice rolls (both dice at once), need doubles to escape free. Animated reveal with TipiDICE emoji. On fail after 3 tries - bail = 20-30% of balance, min 350⬡. If balance < 350⬡, stay 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.
|
|
||||||
|
|
||||||
Fish EXP is awarded per catch (varies by rarity, defined in `FISH_CATALOGUE`). Prestige `exp_mult` upgrade applies to fish EXP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin Commands Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---|---|
|
|
||||||
| `/pause` | Toggle maintenance mode - blocks all non-admin commands |
|
|
||||||
| `/admincoins @user <amount> <reason>` | Give/take coins (positive/negative). DMs user. |
|
|
||||||
| `/adminexp @user <amount> <reason>` | Give/take EXP (positive/negative). Auto-applies level roles on change. DMs user. |
|
|
||||||
| `/adminitem @user <item_id> <anna\|eemalda>` | Give or remove any shop item for free. DMs user. |
|
|
||||||
| `/adminjail @user <minutes> <reason>` | Manually jail a user. DMs user. |
|
|
||||||
| `/adminunjail @user` | Remove jail from a user. |
|
|
||||||
| `/adminban @user <reason>` | Ban from all economy commands. DMs user. |
|
|
||||||
| `/adminunban @user` | Lift economy ban. |
|
|
||||||
| `/adminreset @user <reason>` | Wipe balance, EXP, items, streak. DMs user. |
|
|
||||||
| `/adminview @user` | Full profile: balance, EXP/level, streak, prestige, fish stats, items, timestamps. |
|
|
||||||
| `/adminseason <top_n>` | End season: DM top N players, reset all EXP. |
|
|
||||||
|
|
||||||
All admin commands require **Manage Guild** permission and work in any channel (bypass pause and channel restrictions).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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`
|
|
||||||
- **`/adminexp`**: automatically re-applies level roles if level changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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, **ussipurk** |
|
|
||||||
| T3 | 20 | monitor_360, karikas, gaming_tool, **kalavork**, **echolood** |
|
|
||||||
|
|
||||||
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"]` |
|
|
||||||
| Fish UI | `FISH_UI["key"]` | `/fish`, `/fishbook`, `/fishsell` |
|
|
||||||
| Fish names | `FISH_NAMES["fish_id"]` | Fish display name |
|
|
||||||
| Admin responses | `ADMIN["key"]` | Admin command success/DM messages |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
| `FISH_CATALOGUE` | `economy.py` | All fish species (rarity, weight, coins, exp) |
|
|
||||||
| `PRESTIGE_SHOP` | `economy.py` | Prestige upgrade definitions |
|
|
||||||
| `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
|
|
||||||
- **Fishing** is a steady passive income; `kalavork` (T3) dramatically increases fish value by bumping rarity
|
|
||||||
111
README.md
111
README.md
@@ -70,7 +70,7 @@ 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.
|
1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root.
|
||||||
2. Start PocketBase: `.\pocketbase.exe serve`
|
2. Start PocketBase: `.\pocketbase.exe serve`
|
||||||
3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account.
|
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.
|
4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes.
|
||||||
5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`.
|
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`
|
6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py`
|
||||||
|
|
||||||
@@ -88,15 +88,25 @@ cp .env.example .env
|
|||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `DISCORD_TOKEN` | Bot token from Discord Developer Portal |
|
| `BOT_PROFILE` | Runtime profile: `dev` or `economy` |
|
||||||
|
| `DISCORD_TOKEN_DEV` | Dev bot token from Discord Developer Portal |
|
||||||
|
| `DISCORD_TOKEN_ECONOMY` | Economy bot token from Discord Developer Portal |
|
||||||
|
| `DISCORD_TOKEN` | Legacy fallback token (optional) |
|
||||||
| `SHEET_ID` | ID from the Google Sheet URL |
|
| `SHEET_ID` | ID from the Google Sheet URL |
|
||||||
| `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) |
|
| `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) |
|
||||||
| `GUILD_ID` | Right-click server → Copy Server ID (Developer Mode on) |
|
| `GUILD_ID_DEV` | Dev bot guild ID |
|
||||||
| `BIRTHDAY_CHANNEL_ID` | Channel for birthday `@here` pings |
|
| `GUILD_ID_ECONOMY` | Economy bot guild ID |
|
||||||
|
| `GUILD_ID` | Legacy fallback guild ID (optional) |
|
||||||
|
| `BIRTHDAY_CHANNEL_ID_DEV` | Channel for birthday `@here` pings in dev profile |
|
||||||
|
| `BIRTHDAY_CHANNEL_ID_ECONOMY` | Optional birthday channel in economy profile |
|
||||||
|
| `BIRTHDAY_CHANNEL_ID` | Legacy fallback birthday channel ID (optional) |
|
||||||
| `BIRTHDAY_WINDOW_DAYS` | Days before birthday that on-join check flags it (default: 7) |
|
| `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_URL` | PocketBase base URL (default: `http://127.0.0.1:8090`) |
|
||||||
| `PB_ADMIN_EMAIL` | PocketBase superuser e-mail |
|
| `PB_ADMIN_EMAIL` | PocketBase superuser e-mail |
|
||||||
| `PB_ADMIN_PASSWORD` | PocketBase superuser password |
|
| `PB_ADMIN_PASSWORD` | PocketBase superuser password |
|
||||||
|
| `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` |
|
||||||
|
| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
|
||||||
|
| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) |
|
||||||
|
|
||||||
### 6. Install & Run
|
### 6. Install & Run
|
||||||
|
|
||||||
@@ -110,7 +120,12 @@ pip install -r requirements.txt
|
|||||||
# Terminal 1 - keep running
|
# Terminal 1 - keep running
|
||||||
.\pocketbase.exe serve
|
.\pocketbase.exe serve
|
||||||
|
|
||||||
# Terminal 2
|
# Terminal 2 (dev bot)
|
||||||
|
set BOT_PROFILE=dev
|
||||||
|
python bot.py
|
||||||
|
|
||||||
|
# Terminal 3 (economy bot)
|
||||||
|
set BOT_PROFILE=economy
|
||||||
python bot.py
|
python bot.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -146,6 +161,8 @@ Admins (bot lacks permission to modify them) are silently skipped and still mark
|
|||||||
## Admin Commands
|
## Admin Commands
|
||||||
|
|
||||||
> These commands are hidden from users who don't have the required permission. Override per-role in **Server Settings → Integrations → Bot**.
|
> These commands are hidden from users who don't have the required permission. Override per-role in **Server Settings → Integrations → Bot**.
|
||||||
|
>
|
||||||
|
> Profile note: `/check`, `/member`, and `/birthdays` are available only when `BOT_PROFILE=dev`.
|
||||||
|
|
||||||
| Command | Permission | What it does |
|
| Command | Permission | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -205,7 +222,7 @@ If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a bi
|
|||||||
|
|
||||||
## TipiCOIN Economy
|
## 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`.
|
All economy data is stored in **PocketBase** (`economy_users` collection - see `core/pb_client.py`). The currency is **TipiCOIN** (⬡), displayed as a custom Discord emoji configured in `core/economy.py → COIN`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -262,17 +279,17 @@ Every successful economy action awards EXP:
|
|||||||
| `/rob` 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 |
|
| 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 |
|
| `/beg` completed | +5 |
|
||||||
| `/fish` catch | +3 to +15 (varies by rarity) |
|
| `/fish` catch | +2 to +25 (varies by rarity: common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25) |
|
||||||
|
|
||||||
**Level formula:** `level = floor(√(total_exp ÷ 10))`
|
**Level formula:** `level = max(1, floor(√(total_exp ÷ 6)))`
|
||||||
|
|
||||||
| Level | EXP required | Milestone |
|
| Level | EXP required | Milestone |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | 10 | TipiNOOB role |
|
| 1 | 0 | TipiNOOB role |
|
||||||
| 5 | 250 | TipiGRINDER role |
|
| 5 | 150 | TipiGRINDER role |
|
||||||
| 10 | 1 000 | TipiHUSTLER role · **T2 shop unlocks** |
|
| 10 | 600 | TipiHUSTLER role · **T2 shop unlocks** |
|
||||||
| 20 | 4 000 | TipiCHAD role · **T3 shop unlocks** |
|
| 20 | 2 400 | TipiCHAD role · **T3 shop unlocks** |
|
||||||
| 30 | 9 000 | TipiLEGEND role |
|
| 30 | 5 400 | TipiLEGEND role |
|
||||||
|
|
||||||
Use `/rank` to see your current EXP, level, progress bar to the next level, and leaderboard position.
|
Use `/rank` to see your current EXP, level, progress bar to the next level, and leaderboard position.
|
||||||
|
|
||||||
@@ -431,7 +448,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
|
|||||||
Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` as the amount to wager your entire balance.
|
Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` as the amount to wager your entire balance.
|
||||||
|
|
||||||
### Custom emoji
|
### Custom emoji
|
||||||
Change `COIN` in `economy.py` to any Discord emoji string:
|
Change `COIN` in `core/economy.py` to any Discord emoji string:
|
||||||
```python
|
```python
|
||||||
COIN = "<:tipicoin:YOUR_EMOJI_ID>"
|
COIN = "<:tipicoin:YOUR_EMOJI_ID>"
|
||||||
```
|
```
|
||||||
@@ -440,12 +457,12 @@ COIN = "<:tipicoin:YOUR_EMOJI_ID>"
|
|||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
All logs are written to the `logs/` directory (auto-created on startup).
|
Logs are written under `logs/<BOT_PROFILE>/` (auto-created on startup), so dev and economy profiles keep separate log streams.
|
||||||
|
|
||||||
| File | Rotation | Contents |
|
| File | Rotation | Contents |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `logs/bot.log` | 5 MB x 5 backups | All INFO+ events: commands, errors, member sync |
|
| `logs/<profile>/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 |
|
| `logs/<profile>/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).
|
The terminal output is **colour-coded** by log level (green = INFO, yellow = WARNING, red = ERROR).
|
||||||
|
|
||||||
@@ -456,28 +473,44 @@ Every slash command invocation is logged with the user ID, display name, and all
|
|||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── bot.py # Discord client, all slash commands, event handlers
|
├── bot.py # Discord client, event handlers, shared helpers; wires command modules together
|
||||||
├── economy.py # TipiCOIN business logic, constants (SHOP, COOLDOWNS, etc.)
|
├── strings.py # All user-facing strings (command descriptions, help text, errors)
|
||||||
├── pb_client.py # Async PocketBase REST client (auth + CRUD for economy_users)
|
├── config.py # Environment variable loader
|
||||||
├── strings.py # All user-facing strings, command descriptions, help text
|
├── core/
|
||||||
├── member_sync.py # Role/nickname/birthday sync logic
|
│ ├── economy.py # TipiCOIN business logic, constants (SHOP, COOLDOWNS, EXP_REWARDS, ...)
|
||||||
├── sheets.py # Google Sheets read/write + in-memory cache
|
│ ├── pb_client.py # Async PocketBase REST client (auth + CRUD for economy_users)
|
||||||
├── config.py # Environment variable loader
|
│ ├── sheets.py # Google Sheets read/write + in-memory cache
|
||||||
├── requirements.txt # Python dependencies
|
│ └── member_sync.py # Role/nickname/birthday sync logic
|
||||||
├── .env.example # Template for secrets
|
├── commands/
|
||||||
├── .env # Your secrets (gitignored)
|
│ ├── dev_member_commands.py # /check, /member (dev profile)
|
||||||
├── credentials.json # Google service account key (gitignored)
|
│ ├── dev_member_runtime.py # on_member_join + birthday daily task helpers
|
||||||
|
│ ├── economy_admin_commands.py # /admincoins, /adminexp, /adminitem, /adminjail, ...
|
||||||
|
│ ├── economy_extra_commands.py # /heist, /jailbreak, /reminders, /request, ...
|
||||||
|
│ ├── economy_fish_commands.py # /fish, /fishbook, /fishsell
|
||||||
|
│ ├── economy_games_commands.py # /roulette, /slots, /blackjack, /rps
|
||||||
|
│ ├── economy_income_commands.py # /daily, /work, /beg, /crime, /rob
|
||||||
|
│ ├── economy_prestige_commands.py# /prestige, /prestigeshop, /prestigebuy
|
||||||
|
│ ├── economy_profile_commands.py # /balance, /rank, /stats, /cooldowns, /leaderboard
|
||||||
|
│ ├── economy_support_commands.py # /shop, /buy, /give, /economysetup
|
||||||
|
│ ├── info_commands.py # /patchnotes, /help auxiliaries
|
||||||
|
│ ├── ops_admin_commands.py # /sync, /restart, /shutdown, /pause, /send, /status
|
||||||
|
│ └── ops_channel_commands.py # channel allowlist gating
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── DEV_NOTES.md # Developer reference (architecture, checklists, constants)
|
│ ├── DEV_NOTES.md # Developer reference (architecture, checklists, constants)
|
||||||
│ ├── CHANGELOG.md # Version history
|
│ ├── PATCHNOTES.md # Player-facing patch notes (surfaced via /patchnotes)
|
||||||
│ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions
|
│ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── migrate_to_pb.py # One-time migration: economy.json → PocketBase
|
│ ├── migrate_to_pb.py # One-time legacy migration: economy.json → PocketBase
|
||||||
│ └── add_stats_fields.py # Schema migration: add new fields to economy_users collection
|
│ ├── add_stats_fields.py # Schema migration: add new fields to economy_users collection
|
||||||
├── data/
|
│ └── reset_pb_collections.py # Destructive: deletes & recreates economy collections (--confirm required)
|
||||||
│ └── birthday_sent.json # Birthday dedup log (auto-created)
|
├── requirements.txt # Python dependencies
|
||||||
├── pb_data/ # PocketBase database files (auto-created, gitignored)
|
├── .env.example # Template for secrets
|
||||||
└── logs/
|
├── .env # Your secrets (gitignored)
|
||||||
├── bot.log # General rotating log (auto-created)
|
├── credentials.json # Google service account key (gitignored)
|
||||||
└── transactions.log # Daily economy transaction log (auto-created)
|
├── data/<BOT_PROFILE>/
|
||||||
|
│ └── birthday_sent.json # Birthday dedup log (auto-created per profile)
|
||||||
|
├── pb_data/ # PocketBase database files (auto-created, gitignored)
|
||||||
|
└── logs/<BOT_PROFILE>/
|
||||||
|
├── bot.log # General rotating log (auto-created)
|
||||||
|
└── transactions.log # Daily economy transaction log (auto-created)
|
||||||
```
|
```
|
||||||
|
|||||||
314
commands/dev_member_commands.py
Normal file
314
commands/dev_member_commands.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import sheets
|
||||||
|
import strings as S
|
||||||
|
from core.member_sync import announce_birthday, sync_member
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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"🎂 {S.BIRTHDAY_MONTHS[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:
|
||||||
|
member = guild.get_member(uid)
|
||||||
|
if member:
|
||||||
|
display = member.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=S.BIRTHDAY_MONTHS[month - 1]))
|
||||||
|
pages.append(embed)
|
||||||
|
|
||||||
|
return pages, today.month - 1
|
||||||
|
|
||||||
|
|
||||||
|
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 S.CHECK_UI["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)
|
||||||
|
|
||||||
|
|
||||||
|
def register_dev_member_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
bot: discord.Client,
|
||||||
|
log: logging.Logger,
|
||||||
|
has_announced_today: Callable[[int], bool],
|
||||||
|
mark_announced_today: Callable[[int], None],
|
||||||
|
) -> None:
|
||||||
|
@tree.command(name="birthdays", description=S.CMD["birthdays"])
|
||||||
|
@app_commands.guild_only()
|
||||||
|
async def cmd_birthdays(interaction: discord.Interaction):
|
||||||
|
await interaction.response.defer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await 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
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await sheets.refresh()
|
||||||
|
except Exception as e:
|
||||||
|
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
guild_member = discord.utils.find(
|
||||||
|
lambda m, n=discord_name: m.name.lower() == n.lower(),
|
||||||
|
guild.members,
|
||||||
|
)
|
||||||
|
if guild_member:
|
||||||
|
await sheets.set_user_id(discord_name, guild_member.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]] = []
|
||||||
|
|
||||||
|
for member in guild.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(S.CHECK_UI["detail_error"].format(error=err))
|
||||||
|
|
||||||
|
if result.changed:
|
||||||
|
changed_count += 1
|
||||||
|
parts = []
|
||||||
|
if result.nickname_changed:
|
||||||
|
parts.append(S.CHECK_UI["detail_nickname"])
|
||||||
|
if result.roles_added:
|
||||||
|
parts.append(S.CHECK_UI["detail_roles_added"].format(roles=", ".join(result.roles_added)))
|
||||||
|
details.append(S.CHECK_UI["detail_changed"].format(name=member.display_name, parts=", ".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)
|
||||||
|
|
||||||
|
if sync_updates:
|
||||||
|
try:
|
||||||
|
await sheets.batch_set_synced(sync_updates)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("/check batch_set_synced failed: %s", e)
|
||||||
|
|
||||||
|
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])
|
||||||
|
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="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)
|
||||||
|
|
||||||
|
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)
|
||||||
93
commands/dev_member_runtime.py
Normal file
93
commands/dev_member_runtime.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
import config
|
||||||
|
from core import sheets
|
||||||
|
from core.member_sync import announce_birthday, is_birthday_today, sync_member
|
||||||
|
|
||||||
|
|
||||||
|
async def run_birthday_daily(
|
||||||
|
bot: discord.Client,
|
||||||
|
log: logging.Logger,
|
||||||
|
has_announced_today: Callable[[int], bool],
|
||||||
|
mark_announced_today: Callable[[int], None],
|
||||||
|
) -> None:
|
||||||
|
"""Announce birthdays in the configured guild for users whose birthday is today."""
|
||||||
|
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 = await 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)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_member_join(
|
||||||
|
member: discord.Member,
|
||||||
|
bot: discord.Client,
|
||||||
|
log: logging.Logger,
|
||||||
|
has_announced_today: Callable[[int], bool],
|
||||||
|
mark_announced_today: Callable[[int], None],
|
||||||
|
log_sync_result: Callable[[discord.Member, object], None],
|
||||||
|
) -> None:
|
||||||
|
"""Sync a newly joined member against sheet data and trigger birthday notice if needed."""
|
||||||
|
log.info("Member joined: %s (ID: %s)", member, member.id)
|
||||||
|
|
||||||
|
if not sheets.get_cache():
|
||||||
|
await sheets.refresh()
|
||||||
|
|
||||||
|
result = await sync_member(member, member.guild)
|
||||||
|
|
||||||
|
if result.not_found:
|
||||||
|
try:
|
||||||
|
await 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)
|
||||||
|
await sheets.set_synced(member.id, result.synced)
|
||||||
|
|
||||||
|
if result.birthday_soon and not has_announced_today(member.id):
|
||||||
|
await announce_birthday(member, bot)
|
||||||
|
mark_announced_today(member.id)
|
||||||
310
commands/economy_admin_commands.py
Normal file
310
commands/economy_admin_commands.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
async def _dm_user(bot: discord.Client, 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
|
||||||
|
|
||||||
|
|
||||||
|
def register_economy_admin_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
bot: discord.Client,
|
||||||
|
log: logging.Logger,
|
||||||
|
cd_ts: Callable[[datetime.timedelta], str],
|
||||||
|
apply_level_role: Callable[[discord.Member, int, int], Awaitable[None]],
|
||||||
|
) -> None:
|
||||||
|
@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
|
||||||
|
|
||||||
|
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 member in list(role.members):
|
||||||
|
try:
|
||||||
|
await member.remove_roles(role, reason="Season reset")
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
|
||||||
|
medals = ["🥇", "🥈", "🥉"]
|
||||||
|
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="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(
|
||||||
|
bot,
|
||||||
|
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
|
||||||
|
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(
|
||||||
|
bot,
|
||||||
|
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(bot, 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(bot, 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(bot, 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(bot, 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)
|
||||||
|
data = res["data"]
|
||||||
|
items_str = ", ".join(data.get("items", [])) or "-"
|
||||||
|
uses = data.get("item_uses", {})
|
||||||
|
uses_str = ", ".join(f"{k}:{v}" for k, v in uses.items()) if uses else "-"
|
||||||
|
jailed = data.get("jailed_until") or "-"
|
||||||
|
banned = S.ADMINVIEW_UI["banned_yes"] if data.get("eco_banned") else S.ADMINVIEW_UI["banned_no"]
|
||||||
|
exp = data.get("exp", 0)
|
||||||
|
level = economy.get_level(exp)
|
||||||
|
prestige_lvl = data.get("prestige_level", 0)
|
||||||
|
prestige_pp = data.get("prestige_points", 0)
|
||||||
|
total_fish = data.get("total_fish_caught", 0)
|
||||||
|
inv_fish = len(data.get("fish_inventory") or [])
|
||||||
|
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"{data.get('balance', 0):,} {economy.COIN}", inline=True)
|
||||||
|
embed.add_field(name=S.ADMINVIEW_UI["f_exp"], value=S.ADMINVIEW_UI["exp_val"].format(exp=f"{exp:,}", level=level), inline=True)
|
||||||
|
embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(data.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_prestige"], value=S.ADMINVIEW_UI["prestige_val"].format(level=prestige_lvl, pp=prestige_pp), inline=True)
|
||||||
|
embed.add_field(name=S.ADMINVIEW_UI["f_fish"], value=S.ADMINVIEW_UI["fish_val"].format(caught=total_fish, inv=inv_fish), 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=data.get("last_daily") or "-", inline=True)
|
||||||
|
embed.add_field(name=S.ADMINVIEW_UI["f_last_work"], value=data.get("last_work") or "-", inline=True)
|
||||||
|
embed.add_field(name=S.ADMINVIEW_UI["f_last_crime"], value=data.get("last_crime") or "-", inline=True)
|
||||||
|
embed.add_field(name=S.ADMINVIEW_UI["f_last_fish"], value=data.get("last_fish") 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)
|
||||||
|
|
||||||
|
@tree.command(name="adminexp", description=S.CMD["adminexp"])
|
||||||
|
@app_commands.guild_only()
|
||||||
|
@app_commands.describe(
|
||||||
|
kasutaja=S.OPT["admin_kasutaja"],
|
||||||
|
kogus=S.OPT["adminexp_kogus"],
|
||||||
|
põhjus=S.OPT["admin_põhjus"],
|
||||||
|
)
|
||||||
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
|
async def cmd_adminexp(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_exp(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["exp_done"].format(
|
||||||
|
emoji=emoji,
|
||||||
|
name=kasutaja.display_name,
|
||||||
|
verb=verb,
|
||||||
|
exp=f"{res['exp']:,}",
|
||||||
|
level=res["new_level"],
|
||||||
|
reason=põhjus,
|
||||||
|
),
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
await _dm_user(
|
||||||
|
bot,
|
||||||
|
kasutaja.id,
|
||||||
|
S.ADMIN["exp_dm"].format(
|
||||||
|
emoji=emoji,
|
||||||
|
verb=verb,
|
||||||
|
reason=põhjus,
|
||||||
|
exp=f"{res['exp']:,}",
|
||||||
|
level=res["new_level"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if res["level_changed"]:
|
||||||
|
member = interaction.guild.get_member(kasutaja.id) if interaction.guild else None
|
||||||
|
if member:
|
||||||
|
await apply_level_role(member, res["new_level"], res["old_level"])
|
||||||
|
log.info("ADMINEXP %s → %s (%s) by %s", verb, kasutaja, põhjus, interaction.user)
|
||||||
|
|
||||||
|
@tree.command(name="adminitem", description=S.CMD["adminitem"])
|
||||||
|
@app_commands.guild_only()
|
||||||
|
@app_commands.describe(
|
||||||
|
kasutaja=S.OPT["admin_kasutaja"],
|
||||||
|
ese=S.OPT["adminitem_ese"],
|
||||||
|
tegevus=S.OPT["adminitem_tegevus"],
|
||||||
|
)
|
||||||
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
|
async def cmd_adminitem(interaction: discord.Interaction, kasutaja: discord.Member, ese: str, tegevus: str):
|
||||||
|
action = tegevus.strip().lower()
|
||||||
|
if action not in ("anna", "eemalda"):
|
||||||
|
await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=tegevus), ephemeral=True)
|
||||||
|
return
|
||||||
|
action_key = "give" if action == "anna" else "remove"
|
||||||
|
res = await economy.do_admin_item(kasutaja.id, ese, action_key, interaction.user.id)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "invalid_item":
|
||||||
|
await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=ese), ephemeral=True)
|
||||||
|
elif res["reason"] == "not_owned":
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.ADMIN["item_not_owned"].format(name=kasutaja.display_name, item_id=ese),
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(S.ERR["generic_error"].format(error=res["reason"]), ephemeral=True)
|
||||||
|
return
|
||||||
|
item_name = economy.SHOP[ese]["name"]
|
||||||
|
if action_key == "give":
|
||||||
|
await interaction.response.send_message(S.ADMIN["item_given"].format(item=item_name, name=kasutaja.display_name), ephemeral=True)
|
||||||
|
await _dm_user(bot, kasutaja.id, S.ADMIN["item_dm_given"].format(item=item_name))
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(S.ADMIN["item_removed"].format(item=item_name, name=kasutaja.display_name), ephemeral=True)
|
||||||
|
await _dm_user(bot, kasutaja.id, S.ADMIN["item_dm_removed"].format(item=item_name))
|
||||||
|
log.info("ADMINITEM %s %s %s by %s", action_key, ese, kasutaja, interaction.user)
|
||||||
883
commands/economy_extra_commands.py
Normal file
883
commands/economy_extra_commands.py
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable, MutableSet
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
def register_economy_extra_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
bot: discord.Client,
|
||||||
|
coin: Callable[[int], str],
|
||||||
|
cd_ts: Callable[[datetime.timedelta], str],
|
||||||
|
parse_amount: Callable[[str, int], tuple[int | None, str | None]],
|
||||||
|
ensure_level_role: Callable[[discord.Member, int], Awaitable[None]],
|
||||||
|
active_games: MutableSet[int],
|
||||||
|
) -> None:
|
||||||
|
active_heist = None
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# /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
|
||||||
|
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, organizer_has_jellyfin: bool = False):
|
||||||
|
super().__init__(timeout=_HEIST_JOIN_WINDOW)
|
||||||
|
self.organizer = organizer
|
||||||
|
self.participants: list[discord.Member] = [organizer]
|
||||||
|
self.message: discord.Message | None = None
|
||||||
|
self.resolved = False
|
||||||
|
self.jellyfin_holders: int = 1 if organizer_has_jellyfin else 0
|
||||||
|
|
||||||
|
def _chance(self) -> float:
|
||||||
|
n = len(self.participants)
|
||||||
|
base = min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE)
|
||||||
|
jelly_bonus = 0.05 if self.jellyfin_holders > 0 else 0.0
|
||||||
|
return min(base + jelly_bonus, _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:
|
||||||
|
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)
|
||||||
|
joiner_data = await economy.get_user(interaction.user.id)
|
||||||
|
if "jellyfin" in joiner_data.get("items", []):
|
||||||
|
self.jellyfin_holders += 1
|
||||||
|
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:
|
||||||
|
nonlocal 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
|
||||||
|
|
||||||
|
success = random.random() < self._chance()
|
||||||
|
story_lines = _build_heist_story(self.participants, success)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
await economy.set_heist_global_cd(time.time() + _HEIST_GLOBAL_CD)
|
||||||
|
|
||||||
|
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):
|
||||||
|
nonlocal 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
|
||||||
|
|
||||||
|
organizer_data = await economy.get_user(interaction.user.id)
|
||||||
|
view = HeistLobbyView(interaction.user, "jellyfin" in organizer_data.get("items", []))
|
||||||
|
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._rolling = False
|
||||||
|
self._add_roll_btn()
|
||||||
|
|
||||||
|
def _add_roll_btn(self):
|
||||||
|
self.clear_items()
|
||||||
|
btn = discord.ui.Button(
|
||||||
|
label=S.JAILBREAK_UI["btn_roll"].format(try_=self.tries + 1, max=self.MAX_TRIES),
|
||||||
|
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._rolling:
|
||||||
|
await interaction.response.defer()
|
||||||
|
return
|
||||||
|
self._rolling = True
|
||||||
|
|
||||||
|
self.clear_items()
|
||||||
|
rolling_embed = discord.Embed(
|
||||||
|
title=S.TITLE["jailbreak"],
|
||||||
|
description=S.JAILBREAK_UI["rolling_desc"],
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
await interaction.response.edit_message(embed=rolling_embed, view=self)
|
||||||
|
|
||||||
|
d1 = random.randint(1, 6)
|
||||||
|
d2 = random.randint(1, 6)
|
||||||
|
e1, e2 = _DICE_EMOJI[d1 - 1], _DICE_EMOJI[d2 - 1]
|
||||||
|
double = d1 == d2
|
||||||
|
self.tries += 1
|
||||||
|
tries_left = self.MAX_TRIES - self.tries
|
||||||
|
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
self._rolling = False
|
||||||
|
|
||||||
|
if double:
|
||||||
|
await economy.do_jail_free(self.user_id)
|
||||||
|
self.stop()
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["jailbreak_free"],
|
||||||
|
description=S.JAILBREAK_UI["free_desc"].format(d1=e1, d2=e2),
|
||||||
|
color=0x57F287,
|
||||||
|
)
|
||||||
|
await interaction.edit_original_response(embed=embed, view=self)
|
||||||
|
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.edit_original_response(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.edit_original_response(embed=embed, view=None)
|
||||||
|
else:
|
||||||
|
self._add_roll_btn()
|
||||||
|
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.edit_original_response(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,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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 is None or 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, data: dict, guild: discord.Guild | None, bot_user: discord.ClientUser | None):
|
||||||
|
super().__init__(timeout=120)
|
||||||
|
self.data = data
|
||||||
|
self.guild = guild
|
||||||
|
self.bot_user = bot_user
|
||||||
|
self.page = 0
|
||||||
|
self.mode = "coins"
|
||||||
|
self.max_page = 0
|
||||||
|
self._update_buttons()
|
||||||
|
|
||||||
|
def _current_list(self) -> list:
|
||||||
|
return self.data.get(self.mode, [])
|
||||||
|
|
||||||
|
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
|
||||||
|
for m, btn in [
|
||||||
|
("coins", self.coins_btn),
|
||||||
|
("exp", self.exp_btn),
|
||||||
|
("season", self.season_btn),
|
||||||
|
("prestige", self.prestige_btn),
|
||||||
|
("wagered", self.wagered_btn),
|
||||||
|
("fish", self.fish_btn),
|
||||||
|
]:
|
||||||
|
btn.style = discord.ButtonStyle.primary if m == self.mode else discord.ButtonStyle.secondary
|
||||||
|
|
||||||
|
def _name(self, uid: str, highlight_uid: int | None = None) -> str:
|
||||||
|
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} ‹**"
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _make_embed(self, highlight_uid: int | None = None) -> discord.Embed:
|
||||||
|
title_map = {
|
||||||
|
"coins": f"{economy.COIN} {S.TITLE['leaderboard_coins']}",
|
||||||
|
"exp": S.TITLE["leaderboard_exp"],
|
||||||
|
"season": S.TITLE["leaderboard_season"],
|
||||||
|
"prestige": S.TITLE["leaderboard_prestige"],
|
||||||
|
"wagered": S.TITLE["leaderboard_wagered"],
|
||||||
|
"fish": S.TITLE["leaderboard_fish"],
|
||||||
|
}
|
||||||
|
color_map = {"coins": 0xF4C430, "wagered": 0xED4245, "fish": 0x57F287}
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=title_map.get(self.mode, "Edetabel"),
|
||||||
|
color=color_map.get(self.mode, 0x5865F2),
|
||||||
|
)
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if self.mode == "coins" and self.page == 0 and self.data.get("house_entry"):
|
||||||
|
_, bal = self.data["house_entry"]
|
||||||
|
house_name = self.bot_user.display_name if self.bot_user else S.LEADERBOARD_UI["house_default_name"]
|
||||||
|
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}.**"
|
||||||
|
name = self._name(uid, highlight_uid)
|
||||||
|
if self.mode == "coins":
|
||||||
|
lines.append(f"{prefix} {name} - {coin(entry[1])}")
|
||||||
|
elif self.mode == "exp":
|
||||||
|
lines.append(
|
||||||
|
S.LEADERBOARD_UI["exp_entry"].format(
|
||||||
|
prefix=prefix,
|
||||||
|
name=name,
|
||||||
|
exp=entry[1],
|
||||||
|
level=entry[2],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif self.mode == "season":
|
||||||
|
lines.append(
|
||||||
|
S.LEADERBOARD_UI["season_entry"].format(
|
||||||
|
prefix=prefix,
|
||||||
|
name=name,
|
||||||
|
exp=entry[1],
|
||||||
|
prestige=entry[2],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif self.mode == "prestige":
|
||||||
|
lines.append(
|
||||||
|
S.LEADERBOARD_UI["prestige_entry"].format(
|
||||||
|
prefix=prefix,
|
||||||
|
name=name,
|
||||||
|
prestige=entry[1],
|
||||||
|
pp=entry[2],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif self.mode == "wagered":
|
||||||
|
lines.append(
|
||||||
|
S.LEADERBOARD_UI["wagered_entry"].format(
|
||||||
|
prefix=prefix,
|
||||||
|
name=name,
|
||||||
|
wagered=coin(entry[1]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif self.mode == "fish":
|
||||||
|
lines.append(
|
||||||
|
S.LEADERBOARD_UI["fish_entry"].format(
|
||||||
|
prefix=prefix,
|
||||||
|
name=name,
|
||||||
|
caught=entry[1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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, row=0)
|
||||||
|
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, row=0)
|
||||||
|
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_find_me"], style=discord.ButtonStyle.secondary, row=0)
|
||||||
|
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)
|
||||||
|
|
||||||
|
@discord.ui.button(label=S.LEADERBOARD_UI["btn_coins"], style=discord.ButtonStyle.primary, row=1)
|
||||||
|
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, row=1)
|
||||||
|
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_season"], style=discord.ButtonStyle.secondary, row=1)
|
||||||
|
async def season_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
self.mode = "season"
|
||||||
|
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_prestige"], style=discord.ButtonStyle.secondary, row=1)
|
||||||
|
async def prestige_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
self.mode = "prestige"
|
||||||
|
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_wagered"], style=discord.ButtonStyle.secondary, row=1)
|
||||||
|
async def wagered_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
self.mode = "wagered"
|
||||||
|
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_fish"], style=discord.ButtonStyle.secondary, row=2)
|
||||||
|
async def fish_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
self.mode = "fish"
|
||||||
|
self.page = 0
|
||||||
|
self._update_buttons()
|
||||||
|
await interaction.response.edit_message(embed=self._make_embed(), view=self)
|
||||||
|
|
||||||
|
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()
|
||||||
|
coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather(
|
||||||
|
economy.get_leaderboard(top_n=None),
|
||||||
|
economy.get_leaderboard_exp(top_n=None),
|
||||||
|
economy.get_leaderboard_season_exp(top_n=None),
|
||||||
|
economy.get_leaderboard_prestige(top_n=None),
|
||||||
|
economy.get_leaderboard_wagered(top_n=None),
|
||||||
|
economy.get_leaderboard_fish(top_n=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
house_entry = None
|
||||||
|
regular = []
|
||||||
|
bot_id = bot.user.id if bot.user else None
|
||||||
|
for uid, bal in coins_raw:
|
||||||
|
if bot_id and int(uid) == bot_id:
|
||||||
|
house_entry = (uid, bal)
|
||||||
|
else:
|
||||||
|
regular.append((uid, bal))
|
||||||
|
|
||||||
|
def _no_bot(entries: list) -> list:
|
||||||
|
return [e for e in entries if not (bot_id and int(e[0]) == bot_id)]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"coins": regular,
|
||||||
|
"exp": _no_bot(exp_raw),
|
||||||
|
"season": _no_bot(season_raw),
|
||||||
|
"prestige": _no_bot(prestige_raw),
|
||||||
|
"wagered": _no_bot(wagered_raw),
|
||||||
|
"fish": _no_bot(fish_raw),
|
||||||
|
"house_entry": house_entry,
|
||||||
|
}
|
||||||
|
view = LeaderboardView(data, interaction.guild, bot.user)
|
||||||
|
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)
|
||||||
380
commands/economy_fish_commands.py
Normal file
380
commands/economy_fish_commands.py
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from collections.abc import Awaitable, Callable, MutableSet
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
def register_economy_fish_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
coin: Callable[[int], str],
|
||||||
|
cd_ts: Callable,
|
||||||
|
check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]],
|
||||||
|
maybe_remind: Callable[[int, str], Awaitable[None]],
|
||||||
|
award_exp: Callable[[discord.Interaction, int], Awaitable[None]],
|
||||||
|
active_games: MutableSet[int],
|
||||||
|
) -> None:
|
||||||
|
class FishCatchView(discord.ui.View):
|
||||||
|
"""Shown after a successful pull - lets user sell or keep the fish."""
|
||||||
|
|
||||||
|
def __init__(self, user_id: int, res: dict, fish_id: str, weight: int):
|
||||||
|
super().__init__(timeout=60)
|
||||||
|
self.user_id = user_id
|
||||||
|
self._res = res
|
||||||
|
self._fish_id = fish_id
|
||||||
|
self._weight = weight
|
||||||
|
self._done = False
|
||||||
|
|
||||||
|
def _catch_embed(self, color: int = 0x57F287) -> discord.Embed:
|
||||||
|
rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"]
|
||||||
|
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||||||
|
fish_name = S.FISH_NAMES[self._fish_id]
|
||||||
|
desc = S.FISH_UI["catch_desc"].format(
|
||||||
|
name=fish_name,
|
||||||
|
weight=self._weight,
|
||||||
|
exp=self._res["exp"],
|
||||||
|
value=coin(self._res["value"]),
|
||||||
|
)
|
||||||
|
if self._res.get("is_new"):
|
||||||
|
desc += S.FISH_UI["new_fish"]
|
||||||
|
return discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=color)
|
||||||
|
|
||||||
|
@discord.ui.button(label="", style=discord.ButtonStyle.success)
|
||||||
|
async def sell_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
if interaction.user.id != self.user_id:
|
||||||
|
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||||
|
return
|
||||||
|
if self._done:
|
||||||
|
await interaction.response.defer()
|
||||||
|
return
|
||||||
|
self._done = True
|
||||||
|
self.stop()
|
||||||
|
for child in self.children:
|
||||||
|
child.disabled = True
|
||||||
|
sell_res = await economy.do_fish_sell(self.user_id, [-1])
|
||||||
|
rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"]
|
||||||
|
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||||||
|
fish_name = S.FISH_NAMES[self._fish_id]
|
||||||
|
desc = S.FISH_UI["catch_sold"].format(
|
||||||
|
name=fish_name,
|
||||||
|
weight=self._weight,
|
||||||
|
coins=coin(sell_res["coins"]),
|
||||||
|
exp=self._res["exp"],
|
||||||
|
balance=coin(sell_res["balance"]),
|
||||||
|
)
|
||||||
|
if self._res.get("is_new"):
|
||||||
|
desc += S.FISH_UI["new_fish"]
|
||||||
|
embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x57F287)
|
||||||
|
await interaction.response.edit_message(embed=embed, view=self)
|
||||||
|
|
||||||
|
@discord.ui.button(label="", style=discord.ButtonStyle.secondary)
|
||||||
|
async def keep_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
if interaction.user.id != self.user_id:
|
||||||
|
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||||
|
return
|
||||||
|
if self._done:
|
||||||
|
await interaction.response.defer()
|
||||||
|
return
|
||||||
|
self._done = True
|
||||||
|
self.stop()
|
||||||
|
for child in self.children:
|
||||||
|
child.disabled = True
|
||||||
|
rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"]
|
||||||
|
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||||||
|
fish_name = S.FISH_NAMES[self._fish_id]
|
||||||
|
desc = S.FISH_UI["catch_kept"].format(
|
||||||
|
name=fish_name,
|
||||||
|
weight=self._weight,
|
||||||
|
exp=self._res["exp"],
|
||||||
|
)
|
||||||
|
if self._res.get("is_new"):
|
||||||
|
desc += S.FISH_UI["new_fish"]
|
||||||
|
embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x5865F2)
|
||||||
|
await interaction.response.edit_message(embed=embed, view=self)
|
||||||
|
|
||||||
|
async def on_timeout(self):
|
||||||
|
for child in self.children:
|
||||||
|
child.disabled = True
|
||||||
|
|
||||||
|
class FishingView(discord.ui.View):
|
||||||
|
BITE_WINDOW = 2.0
|
||||||
|
|
||||||
|
def __init__(self, user_id: int, fish_id: str, weight: int):
|
||||||
|
super().__init__(timeout=40)
|
||||||
|
self.user_id = user_id
|
||||||
|
self._fish_id = fish_id
|
||||||
|
self._weight = weight
|
||||||
|
self._clicked = False
|
||||||
|
self._bite_active = False
|
||||||
|
self._msg: discord.Message | None = None
|
||||||
|
|
||||||
|
self.pull_btn = discord.ui.Button(
|
||||||
|
label=S.FISH_UI["btn_wait"],
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
disabled=True,
|
||||||
|
)
|
||||||
|
self.pull_btn.callback = self._pull
|
||||||
|
self.add_item(self.pull_btn)
|
||||||
|
|
||||||
|
async def start(self, msg: discord.Message) -> None:
|
||||||
|
self._msg = msg
|
||||||
|
wait = random.uniform(5, 15)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
if self._clicked or self.is_finished():
|
||||||
|
return
|
||||||
|
self._bite_active = True
|
||||||
|
self.pull_btn.disabled = False
|
||||||
|
self.pull_btn.label = S.FISH_UI["btn_bite"]
|
||||||
|
self.pull_btn.style = discord.ButtonStyle.success
|
||||||
|
try:
|
||||||
|
await msg.edit(
|
||||||
|
embed=discord.Embed(
|
||||||
|
title=S.TITLE["fish_bite"],
|
||||||
|
description=S.FISH_UI["bite_desc"],
|
||||||
|
color=0xED4245,
|
||||||
|
),
|
||||||
|
view=self,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(self.BITE_WINDOW)
|
||||||
|
if not self._clicked:
|
||||||
|
self.stop()
|
||||||
|
active_games.discard(self.user_id)
|
||||||
|
self.pull_btn.disabled = True
|
||||||
|
try:
|
||||||
|
await msg.edit(
|
||||||
|
embed=discord.Embed(
|
||||||
|
title=S.TITLE["fish_escape"],
|
||||||
|
description=S.FISH_UI["escape_desc"],
|
||||||
|
color=0x99AAB5,
|
||||||
|
),
|
||||||
|
view=self,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _pull(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
|
||||||
|
if not self._bite_active:
|
||||||
|
await interaction.response.send_message(S.FISH_UI["too_early"], ephemeral=True)
|
||||||
|
return
|
||||||
|
self._clicked = True
|
||||||
|
self.stop()
|
||||||
|
active_games.discard(self.user_id)
|
||||||
|
self.pull_btn.disabled = True
|
||||||
|
await interaction.response.defer()
|
||||||
|
|
||||||
|
if self._fish_id == "junk":
|
||||||
|
junk_text = random.choice(S.FISH_JUNK_LINES)
|
||||||
|
user_data = await economy.get_user(interaction.user.id)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["fish_junk"],
|
||||||
|
description=S.FISH_UI["junk_desc"].format(
|
||||||
|
text=junk_text,
|
||||||
|
balance=coin(user_data.get("balance", 0)),
|
||||||
|
),
|
||||||
|
color=0x99AAB5,
|
||||||
|
)
|
||||||
|
await self._msg.edit(embed=embed, view=self)
|
||||||
|
return
|
||||||
|
|
||||||
|
res = await economy.do_fish_resolve(self.user_id, self._fish_id, self._weight)
|
||||||
|
if not res["ok"]:
|
||||||
|
await self._msg.edit(
|
||||||
|
embed=discord.Embed(title=S.ERR["generic_error"], color=0xED4245),
|
||||||
|
view=self,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
catch_view = FishCatchView(self.user_id, res, self._fish_id, self._weight)
|
||||||
|
catch_view.sell_btn.label = S.FISH_UI["btn_sell"]
|
||||||
|
catch_view.keep_btn.label = S.FISH_UI["btn_keep"]
|
||||||
|
await self._msg.edit(embed=catch_view._catch_embed(), view=catch_view)
|
||||||
|
if res.get("exp", 0) > 0:
|
||||||
|
asyncio.create_task(award_exp(interaction, res["exp"]))
|
||||||
|
|
||||||
|
async def on_timeout(self):
|
||||||
|
for child in self.children:
|
||||||
|
child.disabled = True
|
||||||
|
active_games.discard(self.user_id)
|
||||||
|
|
||||||
|
@tree.command(name="fish", description=S.CMD["fish"])
|
||||||
|
@app_commands.guild_only()
|
||||||
|
async def cmd_fish(interaction: discord.Interaction):
|
||||||
|
if await check_cmd_rate(interaction):
|
||||||
|
return
|
||||||
|
if interaction.user.id in active_games:
|
||||||
|
await interaction.response.send_message(S.ERR["game_in_progress"], ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
res = await economy.do_fish_start(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["fish"].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
|
||||||
|
|
||||||
|
user_data = await economy.get_user(interaction.user.id)
|
||||||
|
rarity_bump = "kalavork" in user_data.get("items", [])
|
||||||
|
has_echolood = "echolood" in user_data.get("items", [])
|
||||||
|
fish_id, weight = economy.roll_fish(rarity_bump=rarity_bump)
|
||||||
|
|
||||||
|
active_games.add(interaction.user.id)
|
||||||
|
view = FishingView(interaction.user.id, fish_id, weight)
|
||||||
|
if has_echolood:
|
||||||
|
view.BITE_WINDOW = 3.0
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["fish_cast"],
|
||||||
|
description=S.FISH_UI["cast_desc"],
|
||||||
|
color=0x5865F2,
|
||||||
|
)
|
||||||
|
await interaction.response.send_message(embed=embed, view=view)
|
||||||
|
msg = await interaction.original_response()
|
||||||
|
asyncio.create_task(view.start(msg))
|
||||||
|
asyncio.create_task(maybe_remind(interaction.user.id, "fish"))
|
||||||
|
|
||||||
|
@tree.command(name="fishbook", description=S.CMD["fishbook"])
|
||||||
|
@app_commands.describe(kasutaja=S.OPT["fishbook_kasutaja"])
|
||||||
|
async def cmd_fishbook(interaction: discord.Interaction, kasutaja: discord.Member | None = None):
|
||||||
|
target = kasutaja or interaction.user
|
||||||
|
res = await economy.do_fishbook(target.id)
|
||||||
|
book: dict = res["book"]
|
||||||
|
total = res["total_species"]
|
||||||
|
caught_count = res["unique_caught"]
|
||||||
|
|
||||||
|
if not book:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["fishbook"],
|
||||||
|
description=S.FISH_UI["book_empty"],
|
||||||
|
color=0x5865F2,
|
||||||
|
)
|
||||||
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
inv_counts: dict = res.get("inv_counts", {})
|
||||||
|
all_fish = list(economy.FISH_CATALOGUE.items())
|
||||||
|
lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)]
|
||||||
|
for fish_id, fish_data in all_fish:
|
||||||
|
rarity = fish_data["rarity"]
|
||||||
|
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||||||
|
rarity_name = S.FISH_RARITY_NAMES[rarity]
|
||||||
|
count = book.get(fish_id, 0)
|
||||||
|
if count > 0:
|
||||||
|
n_inv = inv_counts.get(fish_id, 0)
|
||||||
|
inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else ""
|
||||||
|
lines.append(
|
||||||
|
S.FISH_UI["book_yes"].format(
|
||||||
|
emoji=emoji,
|
||||||
|
name=S.FISH_NAMES[fish_id],
|
||||||
|
rarity=rarity_name,
|
||||||
|
count=count,
|
||||||
|
inv=inv_str,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name))
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["fishbook"].replace("Kalakogu", f"{target.display_name} kalakogu"),
|
||||||
|
description="\n".join(lines),
|
||||||
|
color=0x5865F2,
|
||||||
|
)
|
||||||
|
embed.set_footer(
|
||||||
|
text=S.FISH_UI["book_footer"].format(
|
||||||
|
page=1,
|
||||||
|
total_pages=1,
|
||||||
|
caught=caught_count,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
|
|
||||||
|
@tree.command(name="fishsell", description=S.CMD["fishsell"])
|
||||||
|
@app_commands.guild_only()
|
||||||
|
async def cmd_fishsell(interaction: discord.Interaction):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
user_data = await economy.get_user(interaction.user.id)
|
||||||
|
inv: list = user_data.get("fish_inventory") or []
|
||||||
|
if not inv:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["fishbook"],
|
||||||
|
description=S.FISH_UI["inv_empty"],
|
||||||
|
color=0x5865F2,
|
||||||
|
)
|
||||||
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
total_value = sum(e["value"] for e in inv)
|
||||||
|
lines = [S.FISH_UI["inv_header"].format(count=len(inv), total_value=coin(total_value))]
|
||||||
|
for entry in inv:
|
||||||
|
fid = entry.get("fish_id", "")
|
||||||
|
rarity = economy.FISH_CATALOGUE.get(fid, {}).get("rarity", "common")
|
||||||
|
emoji = S.FISH_RARITY_EMOJI.get(rarity, "🐟")
|
||||||
|
name = S.FISH_NAMES.get(fid, fid)
|
||||||
|
lines.append(
|
||||||
|
S.FISH_UI["inv_entry"].format(
|
||||||
|
emoji=emoji,
|
||||||
|
name=name,
|
||||||
|
weight=entry["weight"],
|
||||||
|
value=coin(entry["value"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["fishbook"],
|
||||||
|
description="\n".join(lines),
|
||||||
|
color=0x5865F2,
|
||||||
|
)
|
||||||
|
|
||||||
|
sell_all_btn = discord.ui.Button(
|
||||||
|
label=S.FISH_UI["btn_sell"] + f" ({coin(total_value)})",
|
||||||
|
style=discord.ButtonStyle.success,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _sell_all(btn_interaction: discord.Interaction):
|
||||||
|
if btn_interaction.user.id != interaction.user.id:
|
||||||
|
await btn_interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
||||||
|
return
|
||||||
|
sell_view.stop()
|
||||||
|
for child in sell_view.children:
|
||||||
|
child.disabled = True
|
||||||
|
res = await economy.do_fish_sell(interaction.user.id)
|
||||||
|
if not res["ok"]:
|
||||||
|
await btn_interaction.response.edit_message(
|
||||||
|
embed=discord.Embed(description=S.FISH_UI["inv_none"], color=0x99AAB5),
|
||||||
|
view=sell_view,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
sold_embed = discord.Embed(
|
||||||
|
title=S.TITLE["fishbook"],
|
||||||
|
description=S.FISH_UI["inv_sold_all"].format(
|
||||||
|
count=res["count"],
|
||||||
|
coins=coin(res["coins"]),
|
||||||
|
balance=coin(res["balance"]),
|
||||||
|
),
|
||||||
|
color=0x57F287,
|
||||||
|
)
|
||||||
|
await btn_interaction.response.edit_message(embed=sold_embed, view=sell_view)
|
||||||
|
|
||||||
|
sell_all_btn.callback = _sell_all
|
||||||
|
sell_view = discord.ui.View(timeout=60)
|
||||||
|
sell_view.add_item(sell_all_btn)
|
||||||
|
await interaction.followup.send(embed=embed, view=sell_view, ephemeral=True)
|
||||||
1148
commands/economy_games_commands.py
Normal file
1148
commands/economy_games_commands.py
Normal file
File diff suppressed because it is too large
Load Diff
257
commands/economy_income_commands.py
Normal file
257
commands/economy_income_commands.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
def register_economy_income_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
bot: discord.Client,
|
||||||
|
coin: Callable[[int], str],
|
||||||
|
cd_ts: Callable[[datetime.timedelta], str],
|
||||||
|
check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]],
|
||||||
|
maybe_remind: Callable[[int, str], Awaitable[None]],
|
||||||
|
award_exp: Callable[[discord.Interaction, int], Awaitable[None]],
|
||||||
|
) -> None:
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
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"]))
|
||||||
|
try:
|
||||||
|
await sihtmärk.send(
|
||||||
|
S.ROB_UI["victim_dm"].format(
|
||||||
|
robber=interaction.user.display_name,
|
||||||
|
stolen=coin(res["stolen"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
249
commands/economy_prestige_commands.py
Normal file
249
commands/economy_prestige_commands.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
def register_prestige_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]],
|
||||||
|
ensure_level_role: Callable[[discord.Member, int], Awaitable[None]],
|
||||||
|
) -> None:
|
||||||
|
class PrestigeView(discord.ui.View):
|
||||||
|
def __init__(self, user_id: int, tab: str = "status"):
|
||||||
|
super().__init__(timeout=60)
|
||||||
|
self.user_id = user_id
|
||||||
|
self.tab = tab
|
||||||
|
|
||||||
|
async def _rebuild(self, data: dict):
|
||||||
|
self.clear_items()
|
||||||
|
pp = data.get("prestige_points", 0)
|
||||||
|
upgrades: dict = data.get("prestige_upgrades") or {}
|
||||||
|
exp = data.get("exp", 0)
|
||||||
|
level = economy.get_level(exp)
|
||||||
|
|
||||||
|
for tab_id, label in (("status", S.PRESTIGE_UI["btn_tab_status"]), ("shop", S.PRESTIGE_UI["btn_tab_shop"])):
|
||||||
|
btn = discord.ui.Button(
|
||||||
|
label=label,
|
||||||
|
style=discord.ButtonStyle.primary if tab_id == self.tab else discord.ButtonStyle.secondary,
|
||||||
|
disabled=(tab_id == self.tab),
|
||||||
|
row=0,
|
||||||
|
)
|
||||||
|
btn.callback = self._switch_tab(tab_id)
|
||||||
|
self.add_item(btn)
|
||||||
|
|
||||||
|
if self.tab == "status" and level >= economy.PRESTIGE_MIN_LEVEL:
|
||||||
|
confirm_btn = discord.ui.Button(
|
||||||
|
label=S.PRESTIGE_UI["btn_confirm"],
|
||||||
|
style=discord.ButtonStyle.danger,
|
||||||
|
row=1,
|
||||||
|
)
|
||||||
|
confirm_btn.callback = self._do_prestige()
|
||||||
|
cancel_btn = discord.ui.Button(
|
||||||
|
label=S.PRESTIGE_UI["btn_cancel"],
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
row=1,
|
||||||
|
)
|
||||||
|
cancel_btn.callback = self._do_cancel()
|
||||||
|
self.add_item(confirm_btn)
|
||||||
|
self.add_item(cancel_btn)
|
||||||
|
elif self.tab == "shop":
|
||||||
|
for uid, item in economy.PRESTIGE_SHOP.items():
|
||||||
|
cur_level = upgrades.get(uid, 0)
|
||||||
|
if cur_level >= item["max_level"]:
|
||||||
|
continue
|
||||||
|
cost = item["pp_cost"]
|
||||||
|
btn = discord.ui.Button(
|
||||||
|
label=S.PRESTIGE_UI["btn_buy_upgrade"].format(emoji=item["emoji"], name=S.PRESTIGE_SHOP_NAMES[uid], cost=cost),
|
||||||
|
style=discord.ButtonStyle.success if pp >= cost else discord.ButtonStyle.secondary,
|
||||||
|
disabled=(pp < cost),
|
||||||
|
row=1,
|
||||||
|
)
|
||||||
|
btn.callback = self._buy_upgrade(uid)
|
||||||
|
self.add_item(btn)
|
||||||
|
|
||||||
|
def _build_status_embed(self, data: dict) -> discord.Embed:
|
||||||
|
exp = data.get("exp", 0)
|
||||||
|
level = economy.get_level(exp)
|
||||||
|
pp = data.get("prestige_points", 0)
|
||||||
|
p_level = data.get("prestige_level", 0)
|
||||||
|
if level >= economy.PRESTIGE_MIN_LEVEL:
|
||||||
|
pp_preview = max(1, exp // 1000)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["prestige_confirm"],
|
||||||
|
description=S.PRESTIGE_UI["confirm_desc"].format(level=level, exp=exp, pp=pp_preview),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["prestige_too_low"],
|
||||||
|
description=S.PRESTIGE_UI["too_low_desc"].format(level=level, required=economy.PRESTIGE_MIN_LEVEL),
|
||||||
|
color=0xED4245,
|
||||||
|
)
|
||||||
|
if p_level > 0:
|
||||||
|
embed.set_footer(text=S.PRESTIGE_UI["status_footer"].format(level=p_level, pp=pp))
|
||||||
|
return embed
|
||||||
|
|
||||||
|
def _build_shop_embed(self, data: dict) -> discord.Embed:
|
||||||
|
pp = data.get("prestige_points", 0)
|
||||||
|
upgrades: dict = data.get("prestige_upgrades") or {}
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["prestige_shop"],
|
||||||
|
description=S.PRESTIGE_UI["shop_desc"].format(pp=pp),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
for uid, item in economy.PRESTIGE_SHOP.items():
|
||||||
|
cur_level = upgrades.get(uid, 0)
|
||||||
|
name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[uid]}"
|
||||||
|
cost_str = S.PRESTIGE_UI["shop_maxed"] if cur_level >= item["max_level"] else S.PRESTIGE_UI["shop_cost_fmt"].format(cost=item["pp_cost"])
|
||||||
|
level_str = S.PRESTIGE_UI["shop_level_fmt"].format(cur=cur_level, max=item["max_level"])
|
||||||
|
embed.add_field(name=f"{name} · {level_str} · {cost_str}", value=S.PRESTIGE_SHOP_DESCRIPTIONS[uid], inline=False)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
def _switch_tab(self, tab_id: str):
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
if interaction.user.id != self.user_id:
|
||||||
|
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
||||||
|
return
|
||||||
|
self.tab = tab_id
|
||||||
|
await interaction.response.defer()
|
||||||
|
data = await economy.get_user(self.user_id)
|
||||||
|
await self._rebuild(data)
|
||||||
|
embed = self._build_status_embed(data) if tab_id == "status" else self._build_shop_embed(data)
|
||||||
|
await interaction.edit_original_response(embed=embed, view=self)
|
||||||
|
|
||||||
|
return _cb
|
||||||
|
|
||||||
|
def _do_prestige(self):
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
if interaction.user.id != self.user_id:
|
||||||
|
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.defer()
|
||||||
|
res = await economy.do_prestige(self.user_id)
|
||||||
|
self.clear_items()
|
||||||
|
if not res["ok"]:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["prestige_too_low"],
|
||||||
|
description=S.PRESTIGE_UI["too_low_desc"].format(level=res.get("level", 0), required=res.get("required", 30)),
|
||||||
|
color=0xED4245,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["prestige_success"].format(level=res["prestige_level"]),
|
||||||
|
description=S.PRESTIGE_UI["success_desc"].format(pp=res["pp_earned"], level=res["prestige_level"], total_pp=res["prestige_points"]),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
await interaction.edit_original_response(embed=embed, view=self)
|
||||||
|
if res.get("ok") and interaction.guild:
|
||||||
|
member = interaction.guild.get_member(self.user_id)
|
||||||
|
if member:
|
||||||
|
asyncio.create_task(ensure_level_role(member, 1))
|
||||||
|
|
||||||
|
return _cb
|
||||||
|
|
||||||
|
def _do_cancel(self):
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
if interaction.user.id != self.user_id:
|
||||||
|
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||||
|
return
|
||||||
|
self.clear_items()
|
||||||
|
await interaction.response.edit_message(view=self)
|
||||||
|
|
||||||
|
return _cb
|
||||||
|
|
||||||
|
def _buy_upgrade(self, upgrade_id: str):
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
if interaction.user.id != self.user_id:
|
||||||
|
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.defer()
|
||||||
|
res = await economy.do_prestige_buy(self.user_id, upgrade_id)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "insufficient_pp":
|
||||||
|
err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"])
|
||||||
|
elif res["reason"] == "maxed":
|
||||||
|
err = S.PRESTIGE_UI["buy_maxed"]
|
||||||
|
else:
|
||||||
|
err = S.ERR["generic_error"].format(error=res["reason"])
|
||||||
|
await interaction.followup.send(err, ephemeral=True)
|
||||||
|
return
|
||||||
|
data = await economy.get_user(self.user_id)
|
||||||
|
await self._rebuild(data)
|
||||||
|
embed = self._build_shop_embed(data)
|
||||||
|
item = economy.PRESTIGE_SHOP[res["upgrade_id"]]
|
||||||
|
name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}"
|
||||||
|
embed.description = S.PRESTIGE_UI["buy_success_desc"].format(
|
||||||
|
name=name,
|
||||||
|
new_level=res["new_level"],
|
||||||
|
max_level=item["max_level"],
|
||||||
|
pp=res["pp_remaining"],
|
||||||
|
) + "\n\n" + (embed.description or "")
|
||||||
|
await interaction.edit_original_response(embed=embed, view=self)
|
||||||
|
|
||||||
|
return _cb
|
||||||
|
|
||||||
|
async def on_timeout(self):
|
||||||
|
self.clear_items()
|
||||||
|
|
||||||
|
@tree.command(name="prestige", description=S.CMD["prestige"])
|
||||||
|
@app_commands.guild_only()
|
||||||
|
async def cmd_prestige(interaction: discord.Interaction):
|
||||||
|
if await check_cmd_rate(interaction):
|
||||||
|
return
|
||||||
|
data = await economy.get_user(interaction.user.id)
|
||||||
|
if data.get("eco_banned"):
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
return
|
||||||
|
view = PrestigeView(interaction.user.id)
|
||||||
|
await view._rebuild(data)
|
||||||
|
embed = view._build_status_embed(data)
|
||||||
|
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
||||||
|
|
||||||
|
@tree.command(name="prestigeshop", description=S.CMD["prestigeshop"])
|
||||||
|
async def cmd_prestigeshop(interaction: discord.Interaction):
|
||||||
|
data = await economy.get_user(interaction.user.id)
|
||||||
|
view = PrestigeView(interaction.user.id, tab="shop")
|
||||||
|
await view._rebuild(data)
|
||||||
|
embed = view._build_shop_embed(data)
|
||||||
|
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
||||||
|
|
||||||
|
@tree.command(name="prestigebuy", description=S.CMD["prestigebuy"])
|
||||||
|
@app_commands.describe(upgrade=S.OPT["prestigebuy_upgrade"])
|
||||||
|
async def cmd_prestigebuy(interaction: discord.Interaction, upgrade: str):
|
||||||
|
if await check_cmd_rate(interaction):
|
||||||
|
return
|
||||||
|
res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower())
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "banned":
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
elif res["reason"] == "not_found":
|
||||||
|
await interaction.response.send_message(S.PRESTIGE_UI["buy_not_found"], ephemeral=True)
|
||||||
|
elif res["reason"] == "maxed":
|
||||||
|
await interaction.response.send_message(S.PRESTIGE_UI["buy_maxed"], ephemeral=True)
|
||||||
|
elif res["reason"] == "insufficient_pp":
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]),
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
item = economy.PRESTIGE_SHOP[res["upgrade_id"]]
|
||||||
|
name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}"
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["prestige_buy_ok"],
|
||||||
|
description=S.PRESTIGE_UI["buy_success_desc"].format(
|
||||||
|
name=name,
|
||||||
|
new_level=res["new_level"],
|
||||||
|
max_level=res["max_level"],
|
||||||
|
pp=res["pp_remaining"],
|
||||||
|
),
|
||||||
|
color=0x57F287,
|
||||||
|
)
|
||||||
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
545
commands/economy_profile_commands.py
Normal file
545
commands/economy_profile_commands.py
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
def register_economy_profile_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
coin: Callable[[int], str],
|
||||||
|
cd_ts: Callable[[datetime.timedelta], str],
|
||||||
|
ensure_level_role: Callable[[discord.Member, int], Awaitable[None]],
|
||||||
|
) -> None:
|
||||||
|
def _profile_main_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed:
|
||||||
|
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.PROFILE_UI["main_title"].format(name=target.display_name),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.PROFILE_UI["f_level"],
|
||||||
|
value=S.PROFILE_UI["level_val"].format(level=level, role=role_name),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
streak = data.get("daily_streak", 0)
|
||||||
|
if streak:
|
||||||
|
embed.add_field(
|
||||||
|
name=S.PROFILE_UI["f_streak"],
|
||||||
|
value=S.BALANCE_UI["streak_val"].format(streak=streak),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
p_level = data.get("prestige_level", 0)
|
||||||
|
if p_level > 0:
|
||||||
|
p_pp = data.get("prestige_points", 0)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.PROFILE_UI["f_prestige"],
|
||||||
|
value=S.PROFILE_UI["prestige_val"].format(level=p_level, pp=p_pp),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
jail_remaining = economy._is_jailed(data)
|
||||||
|
if jail_remaining:
|
||||||
|
embed.add_field(name=S.PROFILE_UI["f_jail"], value=cd_ts(jail_remaining), inline=True)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.PROFILE_UI["f_progress"].format(next=next_level),
|
||||||
|
value=S.PROFILE_UI["progress_bar"].format(bar=bar, done=progress, needed=needed),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
if level < 10:
|
||||||
|
embed.set_footer(text=S.PROFILE_UI["footer_t1"])
|
||||||
|
elif level < 20:
|
||||||
|
embed.set_footer(text=S.PROFILE_UI["footer_t2"])
|
||||||
|
else:
|
||||||
|
embed.set_footer(text=S.PROFILE_UI["footer_t3"])
|
||||||
|
return embed
|
||||||
|
|
||||||
|
def _profile_items_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.PROFILE_UI["items_title"].format(name=target.display_name),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
uses_map = data.get("item_uses", {})
|
||||||
|
item_lines = []
|
||||||
|
for item_id in data.get("items", []):
|
||||||
|
if item_id not in economy.SHOP:
|
||||||
|
continue
|
||||||
|
line = f"{economy.SHOP[item_id]['emoji']} **{economy.SHOP[item_id]['name']}**"
|
||||||
|
if item_id in uses_map:
|
||||||
|
uses = uses_map[item_id]
|
||||||
|
line += (
|
||||||
|
S.BALANCE_UI["uses_one"].format(uses=uses)
|
||||||
|
if uses == 1
|
||||||
|
else S.BALANCE_UI["uses_many"].format(uses=uses)
|
||||||
|
)
|
||||||
|
item_lines.append(line)
|
||||||
|
embed.description = "\n".join(item_lines) if item_lines else S.PROFILE_UI["items_empty"]
|
||||||
|
return embed
|
||||||
|
|
||||||
|
def _profile_stats_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed:
|
||||||
|
def _s(key: str) -> int:
|
||||||
|
return data.get(key, 0)
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.PROFILE_UI["stats_title"].format(name=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,
|
||||||
|
)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
def _profile_fish_embed(target: discord.User | discord.Member, fish_res: dict) -> discord.Embed:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.PROFILE_UI["fish_title"].format(name=target.display_name),
|
||||||
|
color=0x5865F2,
|
||||||
|
)
|
||||||
|
book: dict = fish_res["book"]
|
||||||
|
if not book:
|
||||||
|
embed.description = S.FISH_UI["book_empty"]
|
||||||
|
return embed
|
||||||
|
inv_counts: dict = fish_res.get("inv_counts", {})
|
||||||
|
caught_count = fish_res["unique_caught"]
|
||||||
|
total = fish_res["total_species"]
|
||||||
|
lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)]
|
||||||
|
for fish_id, fish_data in economy.FISH_CATALOGUE.items():
|
||||||
|
rarity = fish_data["rarity"]
|
||||||
|
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||||||
|
rarity_name = S.FISH_RARITY_NAMES[rarity]
|
||||||
|
count = book.get(fish_id, 0)
|
||||||
|
if count > 0:
|
||||||
|
n_inv = inv_counts.get(fish_id, 0)
|
||||||
|
inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else ""
|
||||||
|
lines.append(
|
||||||
|
S.FISH_UI["book_yes"].format(
|
||||||
|
emoji=emoji,
|
||||||
|
name=S.FISH_NAMES[fish_id],
|
||||||
|
rarity=rarity_name,
|
||||||
|
count=count,
|
||||||
|
inv=inv_str,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name))
|
||||||
|
embed.description = "\n".join(lines)
|
||||||
|
embed.set_footer(
|
||||||
|
text=S.FISH_UI["book_footer"].format(
|
||||||
|
page=1,
|
||||||
|
total_pages=1,
|
||||||
|
caught=caught_count,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
class ProfileView(discord.ui.View):
|
||||||
|
def __init__(self, target: discord.User | discord.Member, invoker_id: int, tab: str = "main"):
|
||||||
|
super().__init__(timeout=120)
|
||||||
|
self.target = target
|
||||||
|
self.invoker_id = invoker_id
|
||||||
|
self.tab = tab
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
def _rebuild(self):
|
||||||
|
self.clear_items()
|
||||||
|
tabs = [
|
||||||
|
("main", S.PROFILE_UI["btn_profile"]),
|
||||||
|
("items", S.PROFILE_UI["btn_items"]),
|
||||||
|
("stats", S.PROFILE_UI["btn_stats"]),
|
||||||
|
("fish", S.PROFILE_UI["btn_fish"]),
|
||||||
|
]
|
||||||
|
for tab_id, label in tabs:
|
||||||
|
btn = discord.ui.Button(
|
||||||
|
label=label,
|
||||||
|
style=(
|
||||||
|
discord.ButtonStyle.primary
|
||||||
|
if tab_id == self.tab
|
||||||
|
else discord.ButtonStyle.secondary
|
||||||
|
),
|
||||||
|
disabled=(tab_id == self.tab),
|
||||||
|
)
|
||||||
|
btn.callback = self._make_cb(tab_id)
|
||||||
|
self.add_item(btn)
|
||||||
|
|
||||||
|
def _make_cb(self, tab_id: str):
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
if interaction.user.id != self.invoker_id:
|
||||||
|
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
||||||
|
return
|
||||||
|
self.tab = tab_id
|
||||||
|
self._rebuild()
|
||||||
|
await interaction.response.defer()
|
||||||
|
data = await economy.get_user(self.target.id)
|
||||||
|
if tab_id == "fish":
|
||||||
|
fish_res = await economy.do_fishbook(self.target.id)
|
||||||
|
embed = _profile_fish_embed(self.target, fish_res)
|
||||||
|
inv: list = data.get("fish_inventory") or []
|
||||||
|
if inv and self.target.id == self.invoker_id:
|
||||||
|
total_value = sum(entry.get("value", 0) for entry in inv)
|
||||||
|
sell_btn = discord.ui.Button(
|
||||||
|
label=(
|
||||||
|
f"{S.FISH_UI['btn_sell']} "
|
||||||
|
f"({len(inv)} kala · {total_value:,} {economy.COIN})"
|
||||||
|
),
|
||||||
|
style=discord.ButtonStyle.success,
|
||||||
|
row=1,
|
||||||
|
)
|
||||||
|
sell_btn.callback = self._sell_fish_cb()
|
||||||
|
self.add_item(sell_btn)
|
||||||
|
elif tab_id == "items":
|
||||||
|
embed = _profile_items_embed(self.target, data)
|
||||||
|
elif tab_id == "stats":
|
||||||
|
embed = _profile_stats_embed(self.target, data)
|
||||||
|
else:
|
||||||
|
embed = _profile_main_embed(self.target, data)
|
||||||
|
await interaction.edit_original_response(embed=embed, view=self)
|
||||||
|
|
||||||
|
return _cb
|
||||||
|
|
||||||
|
def _sell_fish_cb(self):
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
if interaction.user.id != self.invoker_id:
|
||||||
|
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.defer()
|
||||||
|
res = await economy.do_fish_sell(self.invoker_id)
|
||||||
|
self.tab = "fish"
|
||||||
|
self._rebuild()
|
||||||
|
fish_res = await economy.do_fishbook(self.target.id)
|
||||||
|
embed = _profile_fish_embed(self.target, fish_res)
|
||||||
|
sold_line = S.FISH_UI["inv_sold_all"].format(
|
||||||
|
count=res.get("count", 0),
|
||||||
|
coins=coin(res.get("coins", 0)),
|
||||||
|
balance=coin(res.get("balance", 0)),
|
||||||
|
)
|
||||||
|
embed.description = f"{sold_line}\n\n{embed.description or ''}"
|
||||||
|
await interaction.edit_original_response(embed=embed, view=self)
|
||||||
|
|
||||||
|
return _cb
|
||||||
|
|
||||||
|
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_remaining = economy._is_jailed(data)
|
||||||
|
if jail_remaining:
|
||||||
|
embed.add_field(name=S.BALANCE_UI["jailed_until"], value=cd_ts(jail_remaining), inline=True)
|
||||||
|
item_lines = []
|
||||||
|
uses_map = data.get("item_uses", {})
|
||||||
|
for item_id in data.get("items", []):
|
||||||
|
if item_id not in economy.SHOP:
|
||||||
|
continue
|
||||||
|
line = f"{economy.SHOP[item_id]['emoji']} {economy.SHOP[item_id]['name']}"
|
||||||
|
if item_id in uses_map:
|
||||||
|
uses = uses_map[item_id]
|
||||||
|
line += (
|
||||||
|
S.BALANCE_UI["uses_one"].format(uses=uses)
|
||||||
|
if uses == 1
|
||||||
|
else S.BALANCE_UI["uses_many"].format(uses=uses)
|
||||||
|
)
|
||||||
|
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="profile", description=S.CMD["profile"])
|
||||||
|
@app_commands.describe(kasutaja=S.OPT["profile_kasutaja"])
|
||||||
|
async def cmd_profile(interaction: discord.Interaction, kasutaja: discord.Member | None = None):
|
||||||
|
target = kasutaja or interaction.user
|
||||||
|
data = await economy.get_user(target.id)
|
||||||
|
embed = _profile_main_embed(target, data)
|
||||||
|
invoker_id = interaction.user.id
|
||||||
|
await interaction.response.send_message(embed=embed, view=ProfileView(target, invoker_id))
|
||||||
|
if not kasutaja and interaction.guild:
|
||||||
|
member = interaction.guild.get_member(target.id)
|
||||||
|
if member:
|
||||||
|
asyncio.create_task(ensure_level_role(member, economy.get_level(data.get("exp", 0))))
|
||||||
|
|
||||||
|
@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, cooldown: 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 + cooldown
|
||||||
|
if expires <= now:
|
||||||
|
return S.COOLDOWNS_UI["ready"]
|
||||||
|
ts = int(expires.timestamp())
|
||||||
|
return f"⏳ <t:{ts}:R>"
|
||||||
|
|
||||||
|
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"]
|
||||||
|
fish_cd = datetime.timedelta(seconds=90) if "ussipurk" in items else economy.COOLDOWNS["fish"]
|
||||||
|
|
||||||
|
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"])),
|
||||||
|
S.COOLDOWNS_UI["fish_line"].format(
|
||||||
|
status=_status("last_fish", fish_cd),
|
||||||
|
note=S.COOLDOWNS_UI["note_ussipurk"] if "ussipurk" in items else "",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
p_level = data.get("prestige_level", 0)
|
||||||
|
p_pp = data.get("prestige_points", 0)
|
||||||
|
s_exp = data.get("season_total_exp", 0)
|
||||||
|
if p_level > 0 or s_exp > 0:
|
||||||
|
embed.add_field(
|
||||||
|
name="\u200b",
|
||||||
|
value=(
|
||||||
|
S.PRESTIGE_UI["rank_line"].format(level=p_level, pp=p_pp)
|
||||||
|
+ "\n"
|
||||||
|
+ S.PRESTIGE_UI["rank_season"].format(exp=s_exp)
|
||||||
|
),
|
||||||
|
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
|
||||||
|
data = await economy.get_user(target.id)
|
||||||
|
|
||||||
|
def _s(key: str) -> int:
|
||||||
|
return data.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)
|
||||||
216
commands/economy_support_commands.py
Normal file
216
commands/economy_support_commands.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
def register_economy_support_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
parse_amount: Callable[[str, int], tuple[int | None, str | None]],
|
||||||
|
coin: Callable[[int], str],
|
||||||
|
cancel_reminder_task: Callable[[int, str], None],
|
||||||
|
) -> None:
|
||||||
|
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()
|
||||||
|
|
||||||
|
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:
|
||||||
|
cancel_reminder_task(self.user_id, cmd)
|
||||||
|
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,
|
||||||
|
)
|
||||||
148
commands/info_commands.py
Normal file
148
commands/info_commands.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
_PATCHNOTES_PATH = Path(__file__).resolve().parent.parent / "docs" / "PATCHNOTES.md"
|
||||||
|
_VERSION_RE = re.compile(r"^##\s+(.+?)\s*$")
|
||||||
|
_EMBED_DESC_MAX = 4096
|
||||||
|
_SELECT_OPTIONS_MAX = 25
|
||||||
|
|
||||||
|
|
||||||
|
def _load_versions() -> list[tuple[str, str]]:
|
||||||
|
try:
|
||||||
|
text = _PATCHNOTES_PATH.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
versions: list[tuple[str, str]] = []
|
||||||
|
cur_header: str | None = None
|
||||||
|
cur_body: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = _VERSION_RE.match(line)
|
||||||
|
if m:
|
||||||
|
if cur_header is not None:
|
||||||
|
versions.append((cur_header, "\n".join(cur_body).strip()))
|
||||||
|
cur_header = m.group(1).strip()
|
||||||
|
cur_body = []
|
||||||
|
elif cur_header is not None:
|
||||||
|
cur_body.append(line)
|
||||||
|
if cur_header is not None:
|
||||||
|
versions.append((cur_header, "\n".join(cur_body).strip()))
|
||||||
|
return versions
|
||||||
|
|
||||||
|
|
||||||
|
def _build_embed(versions: list[tuple[str, str]], idx: int) -> discord.Embed:
|
||||||
|
header, body = versions[idx]
|
||||||
|
if len(body) > _EMBED_DESC_MAX:
|
||||||
|
body = body[: _EMBED_DESC_MAX - 1] + "…"
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.PATCHNOTES_UI["title"].format(version=header),
|
||||||
|
description=body or S.PATCHNOTES_UI["empty_version"],
|
||||||
|
color=0x5865F2,
|
||||||
|
)
|
||||||
|
embed.set_footer(
|
||||||
|
text=S.PATCHNOTES_UI["footer"].format(idx=idx + 1, total=len(versions))
|
||||||
|
)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
|
||||||
|
def register_info_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
bot: discord.Client,
|
||||||
|
log: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
@tree.command(name="patchnotes", description=S.CMD["patchnotes"])
|
||||||
|
async def cmd_patchnotes(interaction: discord.Interaction):
|
||||||
|
versions = _load_versions()
|
||||||
|
if not versions:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.PATCHNOTES_UI["empty_file"], ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
invoker_id = interaction.user.id
|
||||||
|
|
||||||
|
class PatchNotesView(discord.ui.View):
|
||||||
|
def __init__(self, idx: int = 0):
|
||||||
|
super().__init__(timeout=180)
|
||||||
|
self.idx = idx
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
def _rebuild(self):
|
||||||
|
self.clear_items()
|
||||||
|
newer_btn = discord.ui.Button(
|
||||||
|
label=S.PATCHNOTES_UI["btn_newer"],
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
disabled=self.idx <= 0,
|
||||||
|
)
|
||||||
|
older_btn = discord.ui.Button(
|
||||||
|
label=S.PATCHNOTES_UI["btn_older"],
|
||||||
|
style=discord.ButtonStyle.secondary,
|
||||||
|
disabled=self.idx >= len(versions) - 1,
|
||||||
|
)
|
||||||
|
newer_btn.callback = self._make_step_cb(-1)
|
||||||
|
older_btn.callback = self._make_step_cb(+1)
|
||||||
|
self.add_item(newer_btn)
|
||||||
|
self.add_item(older_btn)
|
||||||
|
|
||||||
|
opts: list[discord.SelectOption] = []
|
||||||
|
for i, (hdr, _) in enumerate(versions[:_SELECT_OPTIONS_MAX]):
|
||||||
|
opts.append(
|
||||||
|
discord.SelectOption(
|
||||||
|
label=hdr[:100],
|
||||||
|
value=str(i),
|
||||||
|
default=(i == self.idx),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(opts) > 1:
|
||||||
|
select = discord.ui.Select(
|
||||||
|
placeholder=S.PATCHNOTES_UI["select_placeholder"],
|
||||||
|
options=opts,
|
||||||
|
min_values=1,
|
||||||
|
max_values=1,
|
||||||
|
)
|
||||||
|
select.callback = self._make_select_cb(select)
|
||||||
|
self.add_item(select)
|
||||||
|
|
||||||
|
def _make_step_cb(self, delta: int):
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
if interaction.user.id != invoker_id:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.ERR["not_your_menu"], ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.idx = max(0, min(len(versions) - 1, self.idx + delta))
|
||||||
|
self._rebuild()
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
embed=_build_embed(versions, self.idx), view=self
|
||||||
|
)
|
||||||
|
|
||||||
|
return _cb
|
||||||
|
|
||||||
|
def _make_select_cb(self, select: discord.ui.Select):
|
||||||
|
async def _cb(interaction: discord.Interaction):
|
||||||
|
if interaction.user.id != invoker_id:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.ERR["not_your_menu"], ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.idx = int(select.values[0])
|
||||||
|
self._rebuild()
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
embed=_build_embed(versions, self.idx), view=self
|
||||||
|
)
|
||||||
|
|
||||||
|
return _cb
|
||||||
|
|
||||||
|
view = PatchNotesView(0)
|
||||||
|
await interaction.response.send_message(
|
||||||
|
embed=_build_embed(versions, 0), view=view, ephemeral=True
|
||||||
|
)
|
||||||
|
log.info("/patchnotes by %s (%d versions)", interaction.user, len(versions))
|
||||||
134
commands/ops_admin_commands.py
Normal file
134
commands/ops_admin_commands.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
def register_ops_admin_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
bot: discord.Client,
|
||||||
|
log: logging.Logger,
|
||||||
|
process,
|
||||||
|
start_time: datetime.datetime,
|
||||||
|
log_dir: Path,
|
||||||
|
guild_obj: discord.Object,
|
||||||
|
restart_file: Path,
|
||||||
|
get_member_cache_size: Callable[[], int],
|
||||||
|
get_paused: Callable[[], bool],
|
||||||
|
set_paused: Callable[[bool], None],
|
||||||
|
count_economy_users: Callable[[], Awaitable[int]],
|
||||||
|
) -> None:
|
||||||
|
@tree.command(name="status", description=S.CMD["status"])
|
||||||
|
@app_commands.guild_only()
|
||||||
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
|
async def cmd_status(interaction: discord.Interaction):
|
||||||
|
mem = process.memory_info()
|
||||||
|
cpu = process.cpu_percent(interval=0.1)
|
||||||
|
uptime = datetime.datetime.now() - start_time
|
||||||
|
hours, rem = divmod(int(uptime.total_seconds()), 3600)
|
||||||
|
minutes, seconds = divmod(rem, 60)
|
||||||
|
tasks_count = len(asyncio.all_tasks())
|
||||||
|
latency_ms = round(bot.latency * 1000, 1)
|
||||||
|
cache_size = get_member_cache_size()
|
||||||
|
user_count = await count_economy_users()
|
||||||
|
|
||||||
|
embed = discord.Embed(title=S.STATUS_UI["title"], color=0x57F287)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.STATUS_UI["uptime_field"],
|
||||||
|
value=S.STATUS_UI["uptime_val"].format(hours=hours, minutes=minutes, seconds=seconds),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.STATUS_UI["latency_field"],
|
||||||
|
value=S.STATUS_UI["latency_val"].format(ms=latency_ms),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.STATUS_UI["ram_field"],
|
||||||
|
value=S.STATUS_UI["ram_val"].format(mb=f"{mem.rss / 1024**2:.1f}"),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.STATUS_UI["cpu_field"],
|
||||||
|
value=S.STATUS_UI["cpu_val"].format(percent=f"{cpu:.1f}"),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.STATUS_UI["tasks_field"],
|
||||||
|
value=str(tasks_count),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.STATUS_UI["eco_players_field"],
|
||||||
|
value=str(user_count),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.STATUS_UI["members_cache_field"],
|
||||||
|
value=str(cache_size),
|
||||||
|
inline=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
log_lines = [
|
||||||
|
S.STATUS_UI["log_line"].format(name=p.name, size_kb=f"{p.stat().st_size / 1024:.1f}")
|
||||||
|
for p in sorted(log_dir.glob("*.log*"))
|
||||||
|
if p.is_file()
|
||||||
|
]
|
||||||
|
embed.add_field(
|
||||||
|
name=S.STATUS_UI["log_files_field"],
|
||||||
|
value="\n".join(log_lines) or S.STATUS_UI["none"],
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
|
|
||||||
|
@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="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):
|
||||||
|
paused = not get_paused()
|
||||||
|
set_paused(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)
|
||||||
135
commands/ops_channel_commands.py
Normal file
135
commands/ops_channel_commands.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
def register_ops_channel_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
bot: discord.Client,
|
||||||
|
log: logging.Logger,
|
||||||
|
get_allowed_channels: Callable[[], list[int]],
|
||||||
|
set_allowed_channels: Callable[[list[int]], None],
|
||||||
|
) -> None:
|
||||||
|
@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"• <#{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)
|
||||||
50
config.py
50
config.py
@@ -3,14 +3,58 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
|
BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev"
|
||||||
|
if BOT_PROFILE not in {"dev", "economy"}:
|
||||||
|
raise SystemExit("BOT_PROFILE must be either 'dev' or 'economy'.")
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, default: int) -> int:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None or not raw.strip():
|
||||||
|
return default
|
||||||
|
return int(raw)
|
||||||
|
|
||||||
|
|
||||||
|
_LEGACY_DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
|
||||||
|
DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "")
|
||||||
|
DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "")
|
||||||
|
DISCORD_TOKEN = (
|
||||||
|
DISCORD_TOKEN_ECONOMY if BOT_PROFILE == "economy" else DISCORD_TOKEN_DEV
|
||||||
|
) or _LEGACY_DISCORD_TOKEN
|
||||||
|
|
||||||
SHEET_ID = os.getenv("SHEET_ID")
|
SHEET_ID = os.getenv("SHEET_ID")
|
||||||
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
|
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"))
|
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
|
||||||
|
GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID)
|
||||||
|
GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID)
|
||||||
|
GUILD_ID = GUILD_ID_ECONOMY if BOT_PROFILE == "economy" else GUILD_ID_DEV
|
||||||
|
|
||||||
|
_LEGACY_BIRTHDAY_CHANNEL_ID = _env_int("BIRTHDAY_CHANNEL_ID", 0)
|
||||||
|
BIRTHDAY_CHANNEL_ID_DEV = _env_int("BIRTHDAY_CHANNEL_ID_DEV", _LEGACY_BIRTHDAY_CHANNEL_ID)
|
||||||
|
BIRTHDAY_CHANNEL_ID_ECONOMY = _env_int("BIRTHDAY_CHANNEL_ID_ECONOMY", 0)
|
||||||
|
BIRTHDAY_CHANNEL_ID = (
|
||||||
|
BIRTHDAY_CHANNEL_ID_ECONOMY
|
||||||
|
if BOT_PROFILE == "economy"
|
||||||
|
else BIRTHDAY_CHANNEL_ID_DEV
|
||||||
|
)
|
||||||
|
|
||||||
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
||||||
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
||||||
|
|
||||||
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
|
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
|
||||||
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
|
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
|
||||||
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
|
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
|
||||||
|
|
||||||
|
_LEGACY_PB_COLLECTION = os.getenv("PB_ECONOMY_COLLECTION", "").strip()
|
||||||
|
PB_ECONOMY_COLLECTION_DEV = (
|
||||||
|
os.getenv("PB_ECONOMY_COLLECTION_DEV", "").strip()
|
||||||
|
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_dev")
|
||||||
|
)
|
||||||
|
PB_ECONOMY_COLLECTION_ECONOMY = (
|
||||||
|
os.getenv("PB_ECONOMY_COLLECTION_ECONOMY", "").strip()
|
||||||
|
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod")
|
||||||
|
)
|
||||||
|
PB_ECONOMY_COLLECTION = (
|
||||||
|
PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ All public async functions are the single source of truth for mutations.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
import pb_client
|
import aiohttp
|
||||||
|
|
||||||
|
from . import pb_client
|
||||||
|
from .pb_client import DatabaseError
|
||||||
|
|
||||||
import strings
|
import strings
|
||||||
|
|
||||||
@@ -313,16 +317,16 @@ LEVEL_ROLES: list[tuple[int, str]] = [
|
|||||||
|
|
||||||
|
|
||||||
def get_level(exp: int) -> int:
|
def get_level(exp: int) -> int:
|
||||||
"""Level = max(1, floor(sqrt(exp/10))).
|
"""Level = max(1, floor(sqrt(exp/6))).
|
||||||
Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000."""
|
Level 5 @ 150 EXP, 10 @ 600, 20 @ 2400, 30 @ 5400."""
|
||||||
return max(1, int(math.sqrt(max(0, exp) / 10)))
|
return max(1, int(math.sqrt(max(0, exp) / 6)))
|
||||||
|
|
||||||
|
|
||||||
def exp_for_level(level: int) -> int:
|
def exp_for_level(level: int) -> int:
|
||||||
"""Minimum cumulative EXP to reach this level."""
|
"""Minimum cumulative EXP to reach this level. level^2 * 6."""
|
||||||
if level <= 1:
|
if level <= 1:
|
||||||
return 0
|
return 0
|
||||||
return level * level * 10
|
return level * level * 6
|
||||||
|
|
||||||
|
|
||||||
def level_role_name(level: int) -> str:
|
def level_role_name(level: int) -> str:
|
||||||
@@ -583,11 +587,15 @@ def format_td(td: timedelta) -> str:
|
|||||||
async def get_user(user_id: int) -> UserData:
|
async def get_user(user_id: int) -> UserData:
|
||||||
"""Fetch user data from PocketBase, creating a default record if first seen."""
|
"""Fetch user data from PocketBase, creating a default record if first seen."""
|
||||||
uid = str(user_id)
|
uid = str(user_id)
|
||||||
record = await pb_client.get_record(uid)
|
try:
|
||||||
if record is None:
|
record = await pb_client.get_record(uid)
|
||||||
default = _default_user()
|
if record is None:
|
||||||
default["user_id"] = uid # type: ignore[typeddict-unknown-key]
|
default = _default_user()
|
||||||
record = await pb_client.create_record(default)
|
default["user_id"] = uid # type: ignore[typeddict-unknown-key]
|
||||||
|
record = await pb_client.create_record(default)
|
||||||
|
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
|
||||||
|
_log.error("PocketBase unreachable for user %s: %s", user_id, exc)
|
||||||
|
raise DatabaseError(f"Database unavailable: {exc}") from exc
|
||||||
user = _default_user()
|
user = _default_user()
|
||||||
for key in list(user.keys()):
|
for key in list(user.keys()):
|
||||||
if key in record:
|
if key in record:
|
||||||
@@ -678,25 +686,29 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
|
|||||||
# Internal write helper
|
# Internal write helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def _commit(user_id: int, user: UserData) -> None:
|
async def _commit(user_id: int, user: UserData) -> None:
|
||||||
|
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)
|
||||||
try:
|
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:
|
if record_id:
|
||||||
await pb_client.update_record(record_id, clean)
|
await pb_client.update_record(record_id, clean)
|
||||||
else:
|
else:
|
||||||
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
|
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
|
||||||
created = await pb_client.create_record(clean)
|
created = await pb_client.create_record(clean)
|
||||||
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
|
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
|
||||||
except Exception as exc:
|
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
|
||||||
_log.error("_commit failed for user %s: %s", user_id, exc)
|
_log.error("_commit failed for user %s: %s", user_id, exc)
|
||||||
|
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /daily
|
# /daily
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def do_daily(user_id: int) -> dict:
|
async def do_daily(user_id: int) -> dict:
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
@@ -772,7 +784,10 @@ _WORK_JOBS = strings.WORK_JOBS
|
|||||||
|
|
||||||
|
|
||||||
async def do_work(user_id: int) -> dict:
|
async def do_work(user_id: int) -> dict:
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
@@ -822,7 +837,10 @@ _BEG_JAIL_LINES = strings.BEG_JAIL_LINES
|
|||||||
|
|
||||||
|
|
||||||
async def do_beg(user_id: int) -> dict:
|
async def do_beg(user_id: int) -> dict:
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
@@ -857,7 +875,10 @@ async def do_beg(user_id: int) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def do_fish_start(user_id: int) -> dict:
|
async def do_fish_start(user_id: int) -> dict:
|
||||||
"""Check cooldown + jail, set cooldown. Call before starting the fishing minigame."""
|
"""Check cooldown + jail, set cooldown. Call before starting the fishing minigame."""
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
if jail := _is_jailed(user):
|
if jail := _is_jailed(user):
|
||||||
@@ -928,8 +949,13 @@ async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict:
|
|||||||
to_sell = inv
|
to_sell = inv
|
||||||
remaining = []
|
remaining = []
|
||||||
else:
|
else:
|
||||||
to_sell = [inv[i] for i in sorted(set(indices)) if 0 <= i < len(inv)]
|
sell_idx = {
|
||||||
keep_idx = set(range(len(inv))) - set(indices)
|
(i if i >= 0 else len(inv) + i)
|
||||||
|
for i in indices
|
||||||
|
}
|
||||||
|
sell_idx = {i for i in sell_idx if 0 <= i < len(inv)}
|
||||||
|
to_sell = [inv[i] for i in sorted(sell_idx)]
|
||||||
|
keep_idx = set(range(len(inv))) - sell_idx
|
||||||
remaining = [inv[i] for i in sorted(keep_idx)]
|
remaining = [inv[i] for i in sorted(keep_idx)]
|
||||||
|
|
||||||
if not to_sell:
|
if not to_sell:
|
||||||
@@ -974,7 +1000,10 @@ async def do_fishbook(user_id: int) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def do_prestige(user_id: int) -> dict:
|
async def do_prestige(user_id: int) -> dict:
|
||||||
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
|
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
@@ -1022,7 +1051,10 @@ async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict:
|
|||||||
if upgrade_id not in PRESTIGE_SHOP:
|
if upgrade_id not in PRESTIGE_SHOP:
|
||||||
return {"ok": False, "reason": "not_found"}
|
return {"ok": False, "reason": "not_found"}
|
||||||
|
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
@@ -1116,7 +1148,10 @@ _CRIME_LOSE = strings.CRIME_LOSE
|
|||||||
|
|
||||||
|
|
||||||
async def do_crime(user_id: int) -> dict:
|
async def do_crime(user_id: int) -> dict:
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
@@ -1209,10 +1244,16 @@ async def do_bail(user_id: int) -> dict:
|
|||||||
# /rob
|
# /rob
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def do_rob(robber_id: int, target_id: int) -> dict:
|
async def do_rob(robber_id: int, target_id: int) -> dict:
|
||||||
robber = await get_user(robber_id)
|
try:
|
||||||
|
robber = await get_user(robber_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if robber.get("eco_banned"):
|
if robber.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
target = await get_user(target_id)
|
try:
|
||||||
|
target = await get_user(target_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
|
||||||
if cd := _cooldown_remaining(robber, "rob"):
|
if cd := _cooldown_remaining(robber, "rob"):
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||||
@@ -1261,12 +1302,32 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
|
|||||||
pct = random.uniform(0.10, 0.25)
|
pct = random.uniform(0.10, 0.25)
|
||||||
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
|
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
|
||||||
target["balance"] -= stolen
|
target["balance"] -= stolen
|
||||||
|
prev_lifetime_earned = robber.get("lifetime_earned", 0)
|
||||||
|
prev_biggest_win = robber.get("biggest_win", 0)
|
||||||
|
prev_peak_balance = robber.get("peak_balance", 0)
|
||||||
robber["balance"] += stolen
|
robber["balance"] += stolen
|
||||||
robber["lifetime_earned"] = robber.get("lifetime_earned", 0) + stolen
|
robber["lifetime_earned"] = prev_lifetime_earned + stolen
|
||||||
robber["biggest_win"] = max(robber.get("biggest_win", 0), stolen)
|
robber["biggest_win"] = max(prev_biggest_win, stolen)
|
||||||
robber["peak_balance"] = max(robber.get("peak_balance", 0), robber["balance"])
|
robber["peak_balance"] = max(prev_peak_balance, robber["balance"])
|
||||||
await _commit(robber_id, robber)
|
try:
|
||||||
await _commit(target_id, target)
|
await _commit(robber_id, robber)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
try:
|
||||||
|
await _commit(target_id, target)
|
||||||
|
except DatabaseError:
|
||||||
|
robber["balance"] -= stolen
|
||||||
|
robber["lifetime_earned"] = prev_lifetime_earned
|
||||||
|
robber["biggest_win"] = prev_biggest_win
|
||||||
|
robber["peak_balance"] = prev_peak_balance
|
||||||
|
try:
|
||||||
|
await _commit(robber_id, robber)
|
||||||
|
except DatabaseError as exc2:
|
||||||
|
_log.critical(
|
||||||
|
"do_rob rollback failed for robber %s after target commit failed: %s",
|
||||||
|
robber_id, exc2,
|
||||||
|
)
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
_txn("ROB_WIN", robber=robber_id, victim=target_id, stolen=f"+{stolen}", jackpot=jackpot, robber_bal=robber["balance"], victim_bal=target["balance"])
|
_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}
|
return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot}
|
||||||
else:
|
else:
|
||||||
@@ -1284,7 +1345,10 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
|
|||||||
# /roulette
|
# /roulette
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
if jail := _is_jailed(user):
|
if jail := _is_jailed(user):
|
||||||
@@ -1324,7 +1388,10 @@ async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
||||||
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
|
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
if jail := _is_jailed(user):
|
if jail := _is_jailed(user):
|
||||||
@@ -1349,6 +1416,66 @@ async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
|||||||
return {"ok": True, "balance": user["balance"]}
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /rps PvP escrow (deposit/payout/refund)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
|
||||||
|
"""Hold `bet` coins from a player as escrow for a PvP RPS duel."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
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["balance"] -= bet
|
||||||
|
user["total_wagered"] = user.get("total_wagered", 0) + bet
|
||||||
|
try:
|
||||||
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
_txn("RPS_PVP_DEPOSIT", user=user_id, bet=bet, bal=user["balance"])
|
||||||
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
||||||
|
"""Credit the duel winner with 2*bet (their stake back + opponent's)."""
|
||||||
|
try:
|
||||||
|
user = await get_user(winner_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
payout = bet * 2
|
||||||
|
user["balance"] = user.get("balance", 0) + payout
|
||||||
|
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"])
|
||||||
|
try:
|
||||||
|
await _commit(winner_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
_txn("RPS_PVP_PAYOUT", user=winner_id, payout=f"+{payout}", bal=user["balance"])
|
||||||
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
||||||
|
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
user["balance"] = user.get("balance", 0) + bet
|
||||||
|
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
|
||||||
|
try:
|
||||||
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
_txn("RPS_PVP_REFUND", user=user_id, bet=bet, bal=user["balance"])
|
||||||
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /slots
|
# /slots
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1377,7 +1504,10 @@ def _spin() -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def do_slots(user_id: int, bet: int) -> dict:
|
async def do_slots(user_id: int, bet: int) -> dict:
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
if jail := _is_jailed(user):
|
if jail := _is_jailed(user):
|
||||||
@@ -1430,7 +1560,10 @@ async def do_slots(user_id: int, bet: int) -> dict:
|
|||||||
# /give
|
# /give
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
||||||
giver = await get_user(giver_id)
|
try:
|
||||||
|
giver = await get_user(giver_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if giver.get("eco_banned"):
|
if giver.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
@@ -1440,13 +1573,31 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
|||||||
if giver["balance"] < amount:
|
if giver["balance"] < amount:
|
||||||
return {"ok": False, "reason": "insufficient"}
|
return {"ok": False, "reason": "insufficient"}
|
||||||
|
|
||||||
receiver = await get_user(receiver_id)
|
try:
|
||||||
|
receiver = await get_user(receiver_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
giver["balance"] -= amount
|
giver["balance"] -= amount
|
||||||
receiver["balance"] += amount
|
receiver["balance"] += amount
|
||||||
giver["total_given"] = giver.get("total_given", 0) + amount
|
giver["total_given"] = giver.get("total_given", 0) + amount
|
||||||
receiver["total_received"] = receiver.get("total_received", 0) + amount
|
receiver["total_received"] = receiver.get("total_received", 0) + amount
|
||||||
await _commit(giver_id, giver)
|
try:
|
||||||
await _commit(receiver_id, receiver)
|
await _commit(giver_id, giver)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
try:
|
||||||
|
await _commit(receiver_id, receiver)
|
||||||
|
except DatabaseError:
|
||||||
|
giver["balance"] += amount
|
||||||
|
giver["total_given"] = max(0, giver.get("total_given", 0) - amount)
|
||||||
|
try:
|
||||||
|
await _commit(giver_id, giver)
|
||||||
|
except DatabaseError as exc2:
|
||||||
|
_log.critical(
|
||||||
|
"do_give rollback failed for giver %s after receiver commit failed: %s",
|
||||||
|
giver_id, exc2,
|
||||||
|
)
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
_txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"])
|
_txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1462,7 +1613,10 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
|||||||
async def do_buy(user_id: int, item_id: str) -> dict:
|
async def do_buy(user_id: int, item_id: str) -> dict:
|
||||||
if item_id not in SHOP:
|
if item_id not in SHOP:
|
||||||
return {"ok": False, "reason": "not_found"}
|
return {"ok": False, "reason": "not_found"}
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
@@ -1627,7 +1781,10 @@ async def do_set_reminders(user_id: int, commands: list[str]) -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
||||||
"""Deduct the initial blackjack bet. Returns ok/fail."""
|
"""Deduct the initial blackjack bet. Returns ok/fail."""
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
if jail := _is_jailed(user):
|
if jail := _is_jailed(user):
|
||||||
@@ -1682,7 +1839,10 @@ async def do_get_jailed() -> list[tuple[int, timedelta]]:
|
|||||||
|
|
||||||
async def do_heist_check(user_id: int) -> dict:
|
async def do_heist_check(user_id: int) -> dict:
|
||||||
"""Check whether a user is eligible to join a heist."""
|
"""Check whether a user is eligible to join a heist."""
|
||||||
user = await get_user(user_id)
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "banned"}
|
return {"ok": False, "reason": "banned"}
|
||||||
if jail := _is_jailed(user):
|
if jail := _is_jailed(user):
|
||||||
@@ -1691,23 +1851,41 @@ async def do_heist_check(user_id: int) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
||||||
"""Apply heist outcome to all participants. On win, steals from house."""
|
"""Apply heist outcome to all participants. On win, steals from house.
|
||||||
|
|
||||||
|
Per-user commit failures attempt to compensate the house so the economy
|
||||||
|
stays balanced. If compensation also fails, a CRITICAL log is emitted.
|
||||||
|
"""
|
||||||
now = _now()
|
now = _now()
|
||||||
payout_each = 0
|
payout_each = 0
|
||||||
|
failed_users: list[int] = []
|
||||||
|
|
||||||
if success and HOUSE_ID is not None:
|
if success and HOUSE_ID is not None:
|
||||||
house = await get_user(HOUSE_ID)
|
try:
|
||||||
|
house = await get_user(HOUSE_ID)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
pct = random.uniform(0.20, 0.55)
|
pct = random.uniform(0.20, 0.55)
|
||||||
total = max(300, int(house["balance"] * pct))
|
total = max(300, int(house["balance"] * pct))
|
||||||
payout_each = total // len(user_ids)
|
payout_each = total // len(user_ids)
|
||||||
house["balance"] = max(0, house["balance"] - total)
|
house["balance"] = max(0, house["balance"] - total)
|
||||||
await _commit(HOUSE_ID, house)
|
try:
|
||||||
|
await _commit(HOUSE_ID, house)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
_txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"])
|
_txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"])
|
||||||
|
|
||||||
for uid in user_ids:
|
for uid in user_ids:
|
||||||
user = await get_user(uid)
|
try:
|
||||||
|
user = await get_user(uid)
|
||||||
|
except DatabaseError:
|
||||||
|
failed_users.append(uid)
|
||||||
|
if success and payout_each > 0:
|
||||||
|
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||||
|
continue
|
||||||
user["last_heist"] = now.isoformat()
|
user["last_heist"] = now.isoformat()
|
||||||
user["heists_joined"] = user.get("heists_joined", 0) + 1
|
user["heists_joined"] = user.get("heists_joined", 0) + 1
|
||||||
|
fine_credited = False
|
||||||
if success:
|
if success:
|
||||||
user["balance"] += payout_each
|
user["balance"] += payout_each
|
||||||
user["heists_won"] = user.get("heists_won", 0) + 1
|
user["heists_won"] = user.get("heists_won", 0) + 1
|
||||||
@@ -1723,7 +1901,51 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
|||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
|
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"])
|
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
|
||||||
if fine > 0:
|
if fine > 0:
|
||||||
await _credit_house(fine)
|
try:
|
||||||
await _commit(uid, user)
|
await _credit_house(fine)
|
||||||
|
fine_credited = True
|
||||||
|
except DatabaseError:
|
||||||
|
pass # user commit will still be attempted; if both fail, no economy effect
|
||||||
|
try:
|
||||||
|
await _commit(uid, user)
|
||||||
|
except DatabaseError:
|
||||||
|
failed_users.append(uid)
|
||||||
|
if success and payout_each > 0:
|
||||||
|
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||||
|
elif not success and fine_credited:
|
||||||
|
await _refund_user_safe(HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||||
|
|
||||||
return {"ok": True, "payout_each": payout_each, "success": success}
|
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
||||||
|
|
||||||
|
|
||||||
|
async def _refund_house_safe(amount: int, context: str, related_uid: int) -> None:
|
||||||
|
"""Best-effort refund of `amount` to the house. Logs critical if it fails."""
|
||||||
|
if HOUSE_ID is None or amount <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
house = await get_user(HOUSE_ID)
|
||||||
|
house["balance"] = house.get("balance", 0) + amount
|
||||||
|
await _commit(HOUSE_ID, house)
|
||||||
|
except DatabaseError as exc:
|
||||||
|
_log.critical(
|
||||||
|
"House compensation failed (%s, related uid %s, amount %s): %s",
|
||||||
|
context, related_uid, amount, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
|
||||||
|
"""Best-effort debit of `amount` from the house (compensates a failed user fine).
|
||||||
|
|
||||||
|
Reads house, subtracts amount, commits. Logs critical if it fails.
|
||||||
|
"""
|
||||||
|
if HOUSE_ID is None or amount <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
house = await get_user(HOUSE_ID)
|
||||||
|
house["balance"] = max(0, house.get("balance", 0) - amount)
|
||||||
|
await _commit(HOUSE_ID, house)
|
||||||
|
except DatabaseError as exc:
|
||||||
|
_log.critical(
|
||||||
|
"House debit compensation failed (%s, related uid %s, amount %s): %s",
|
||||||
|
context, uid, amount, exc,
|
||||||
|
)
|
||||||
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
|
|||||||
import discord
|
import discord
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import sheets
|
from . import sheets
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
|
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
|
||||||
@@ -126,12 +126,12 @@ async def sync_member(
|
|||||||
# --- Backfill User ID if missing ---
|
# --- Backfill User ID if missing ---
|
||||||
raw_id = str(row.get("User ID", "")).strip()
|
raw_id = str(row.get("User ID", "")).strip()
|
||||||
if not raw_id or raw_id == "0":
|
if not raw_id or raw_id == "0":
|
||||||
sheets.set_user_id(member.name, member.id)
|
await sheets.set_user_id(member.name, member.id)
|
||||||
|
|
||||||
# --- Update Discord username in sheet if it changed ---
|
# --- Update Discord username in sheet if it changed ---
|
||||||
sheet_username = str(row.get("Discord", "")).strip()
|
sheet_username = str(row.get("Discord", "")).strip()
|
||||||
if sheet_username.lower() != member.name.lower():
|
if sheet_username.lower() != member.name.lower():
|
||||||
sheets.update_username(member.id, member.name)
|
await sheets.update_username(member.id, member.name)
|
||||||
|
|
||||||
# --- Nickname (Nimi = real name, formatted as first name + last initial) ---
|
# --- Nickname (Nimi = real name, formatted as first name + last initial) ---
|
||||||
nimi = str(row.get("Nimi", "")).strip()
|
nimi = str(row.get("Nimi", "")).strip()
|
||||||
@@ -7,26 +7,33 @@ Environment variables (set in .env):
|
|||||||
PB_URL Base URL of PocketBase (default: http://127.0.0.1:8090)
|
PB_URL Base URL of PocketBase (default: http://127.0.0.1:8090)
|
||||||
PB_ADMIN_EMAIL PocketBase admin e-mail
|
PB_ADMIN_EMAIL PocketBase admin e-mail
|
||||||
PB_ADMIN_PASSWORD PocketBase admin password
|
PB_ADMIN_PASSWORD PocketBase admin password
|
||||||
|
PB_ECONOMY_COLLECTION_DEV / PB_ECONOMY_COLLECTION_ECONOMY
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseError(Exception):
|
||||||
|
"""Raised when PocketBase is unreachable or returns an error."""
|
||||||
|
pass
|
||||||
|
|
||||||
_log = logging.getLogger("tipiCOIN.pb")
|
_log = logging.getLogger("tipiCOIN.pb")
|
||||||
|
|
||||||
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
|
PB_URL = config.PB_URL
|
||||||
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
|
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
|
||||||
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
|
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
|
||||||
ECONOMY_COLLECTION = "economy_users"
|
ECONOMY_COLLECTION = config.PB_ECONOMY_COLLECTION
|
||||||
|
|
||||||
_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
_TIMEOUT = aiohttp.ClientTimeout(total=4)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Persistent session (created once, reused for the lifetime of the process)
|
# Persistent session (created once, reused for the lifetime of the process)
|
||||||
@@ -55,17 +62,20 @@ async def _ensure_auth() -> str:
|
|||||||
if time.monotonic() < _token_expiry:
|
if time.monotonic() < _token_expiry:
|
||||||
return _token
|
return _token
|
||||||
session = _get_session()
|
session = _get_session()
|
||||||
async with session.post(
|
try:
|
||||||
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
async with session.post(
|
||||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
||||||
) as resp:
|
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||||
if resp.status != 200:
|
) as resp:
|
||||||
text = await resp.text()
|
if resp.status != 200:
|
||||||
raise RuntimeError(f"PocketBase auth failed ({resp.status}): {text}")
|
text = await resp.text()
|
||||||
data = await resp.json()
|
raise DatabaseError(f"PocketBase auth failed ({resp.status}): {text}")
|
||||||
_token = data["token"]
|
data = await resp.json()
|
||||||
_token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry
|
_token = data["token"]
|
||||||
_log.debug("PocketBase admin token refreshed")
|
_token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry
|
||||||
|
_log.debug("PocketBase admin token refreshed")
|
||||||
|
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
|
||||||
|
raise DatabaseError(f"Database unavailable: {e}") from e
|
||||||
return _token
|
return _token
|
||||||
|
|
||||||
|
|
||||||
@@ -73,80 +83,92 @@ async def _hdrs() -> dict[str, str]:
|
|||||||
return {"Authorization": await _ensure_auth()}
|
return {"Authorization": await _ensure_auth()}
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_token() -> None:
|
||||||
|
global _token_expiry
|
||||||
|
_token_expiry = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request helper with auth-retry and error wrapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _request(method: str, url: str, **kwargs: Any) -> Any:
|
||||||
|
"""Make an authenticated request, retrying once on 401/403 by re-authing.
|
||||||
|
|
||||||
|
Returns the parsed JSON body. Raises DatabaseError on connection issues or
|
||||||
|
non-2xx responses after retrying.
|
||||||
|
"""
|
||||||
|
session = _get_session()
|
||||||
|
for attempt in range(2):
|
||||||
|
kwargs["headers"] = await _hdrs()
|
||||||
|
try:
|
||||||
|
async with session.request(method, url, **kwargs) as resp:
|
||||||
|
if resp.status in (401, 403) and attempt == 0:
|
||||||
|
_invalidate_token()
|
||||||
|
continue
|
||||||
|
if not resp.ok:
|
||||||
|
text = await resp.text()
|
||||||
|
raise DatabaseError(f"Database unavailable: {resp.status}, {text}")
|
||||||
|
return await resp.json()
|
||||||
|
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
|
||||||
|
raise DatabaseError(f"Database unavailable: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CRUD helpers
|
# CRUD helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def get_record(user_id: str) -> dict[str, Any] | None:
|
async def get_record(user_id: str) -> dict[str, Any] | None:
|
||||||
"""Fetch one economy record by Discord user_id. Returns None if not found."""
|
"""Fetch one economy record by Discord user_id. Returns None if not found."""
|
||||||
session = _get_session()
|
data = await _request(
|
||||||
async with session.get(
|
"GET",
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||||
params={"filter": f'user_id="{user_id}"', "perPage": 1},
|
params={"filter": f'user_id="{user_id}"', "perPage": 1},
|
||||||
headers=await _hdrs(),
|
)
|
||||||
) as resp:
|
items = data.get("items", [])
|
||||||
resp.raise_for_status()
|
return items[0] if items else None
|
||||||
data = await resp.json()
|
|
||||||
items = data.get("items", [])
|
|
||||||
return items[0] if items else None
|
|
||||||
|
|
||||||
|
|
||||||
async def create_record(record: dict[str, Any]) -> dict[str, Any]:
|
async def create_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Create a new economy record. Returns the created record (includes PB id)."""
|
"""Create a new economy record. Returns the created record (includes PB id)."""
|
||||||
session = _get_session()
|
return await _request(
|
||||||
async with session.post(
|
"POST",
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||||
json=record,
|
json=record,
|
||||||
headers=await _hdrs(),
|
)
|
||||||
) as resp:
|
|
||||||
if resp.status not in (200, 201):
|
|
||||||
text = await resp.text()
|
|
||||||
raise RuntimeError(f"PocketBase create failed ({resp.status}): {text}")
|
|
||||||
return await resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""PATCH an existing record by its PocketBase record id."""
|
"""PATCH an existing record by its PocketBase record id."""
|
||||||
session = _get_session()
|
return await _request(
|
||||||
async with session.patch(
|
"PATCH",
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}",
|
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}",
|
||||||
json=data,
|
json=data,
|
||||||
headers=await _hdrs(),
|
)
|
||||||
) as resp:
|
|
||||||
resp.raise_for_status()
|
|
||||||
return await resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def count_records() -> int:
|
async def count_records() -> int:
|
||||||
"""Return the total number of records in the collection (single cheap request)."""
|
"""Return the total number of records in the collection (single cheap request)."""
|
||||||
session = _get_session()
|
data = await _request(
|
||||||
async with session.get(
|
"GET",
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||||
params={"perPage": 1, "page": 1},
|
params={"perPage": 1, "page": 1},
|
||||||
headers=await _hdrs(),
|
)
|
||||||
) as resp:
|
return int(data.get("totalItems", 0))
|
||||||
resp.raise_for_status()
|
|
||||||
data = await resp.json()
|
|
||||||
return int(data.get("totalItems", 0))
|
|
||||||
|
|
||||||
|
|
||||||
async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]:
|
async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]:
|
||||||
"""Fetch every record in the collection, handling PocketBase pagination."""
|
"""Fetch every record in the collection, handling PocketBase pagination."""
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
page = 1
|
page = 1
|
||||||
session = _get_session()
|
|
||||||
hdrs = await _hdrs()
|
|
||||||
while True:
|
while True:
|
||||||
async with session.get(
|
data = await _request(
|
||||||
|
"GET",
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||||
params={"perPage": page_size, "page": page},
|
params={"perPage": page_size, "page": page},
|
||||||
headers=hdrs,
|
)
|
||||||
) as resp:
|
batch = data.get("items", [])
|
||||||
resp.raise_for_status()
|
results.extend(batch)
|
||||||
data = await resp.json()
|
if len(batch) < page_size:
|
||||||
batch = data.get("items", [])
|
return results
|
||||||
results.extend(batch)
|
page += 1
|
||||||
if len(batch) < page_size:
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
return results
|
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
"""Google Sheets integration - read/write member data via gspread."""
|
"""Google Sheets integration - read/write member data via gspread.
|
||||||
|
|
||||||
|
Public network-hitting functions are async and delegate the blocking gspread
|
||||||
|
work to `asyncio.to_thread` so the discord.py event loop is not stalled
|
||||||
|
(stalled loops drop gateway heartbeats and can disconnect the bot).
|
||||||
|
Pure-cache helpers (get_cache, find_*) remain sync.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import gspread
|
import gspread
|
||||||
from google.oauth2.service_account import Credentials
|
from google.oauth2.service_account import Credentials
|
||||||
@@ -69,11 +77,7 @@ def _ensure_headers(ws: gspread.Worksheet) -> None:
|
|||||||
ws.update_cell(1, col_idx, header)
|
ws.update_cell(1, col_idx, header)
|
||||||
|
|
||||||
|
|
||||||
def refresh() -> list[dict]:
|
def _refresh_sync() -> list[dict]:
|
||||||
"""Pull all rows from the sheet into the in-memory cache.
|
|
||||||
|
|
||||||
Returns the cache (list of dicts keyed by header names).
|
|
||||||
"""
|
|
||||||
global _cache
|
global _cache
|
||||||
ws = _get_worksheet()
|
ws = _get_worksheet()
|
||||||
_ensure_headers(ws)
|
_ensure_headers(ws)
|
||||||
@@ -83,6 +87,11 @@ def refresh() -> list[dict]:
|
|||||||
return _cache
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh() -> list[dict]:
|
||||||
|
"""Pull all rows from the sheet into the in-memory cache (non-blocking)."""
|
||||||
|
return await asyncio.to_thread(_refresh_sync)
|
||||||
|
|
||||||
|
|
||||||
def get_cache() -> list[dict]:
|
def get_cache() -> list[dict]:
|
||||||
"""Return the current in-memory cache without re-querying."""
|
"""Return the current in-memory cache without re-querying."""
|
||||||
return _cache
|
return _cache
|
||||||
@@ -122,16 +131,12 @@ def _row_index_for_member(discord_id: int | None = None, username: str | None =
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def update_cell_for_member(
|
def _update_cell_for_member_sync(
|
||||||
discord_id: int | None,
|
discord_id: int | None,
|
||||||
username: str | None,
|
username: str | None,
|
||||||
column_name: str,
|
column_name: str,
|
||||||
value: str,
|
value: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Write a value to a specific column for a member row.
|
|
||||||
|
|
||||||
Returns True if the write succeeded.
|
|
||||||
"""
|
|
||||||
ws = _worksheet or _get_worksheet()
|
ws = _worksheet or _get_worksheet()
|
||||||
row_idx = _row_index_for_member(discord_id=discord_id, username=username)
|
row_idx = _row_index_for_member(discord_id=discord_id, username=username)
|
||||||
if row_idx is None:
|
if row_idx is None:
|
||||||
@@ -145,7 +150,6 @@ def update_cell_for_member(
|
|||||||
ws.update([[value]], gspread.utils.rowcol_to_a1(row_idx, col_idx),
|
ws.update([[value]], gspread.utils.rowcol_to_a1(row_idx, col_idx),
|
||||||
value_input_option="USER_ENTERED")
|
value_input_option="USER_ENTERED")
|
||||||
|
|
||||||
# Keep cache in sync
|
|
||||||
cache_idx = row_idx - 3
|
cache_idx = row_idx - 3
|
||||||
if 0 <= cache_idx < len(_cache):
|
if 0 <= cache_idx < len(_cache):
|
||||||
_cache[cache_idx][column_name] = value
|
_cache[cache_idx][column_name] = value
|
||||||
@@ -153,8 +157,19 @@ def update_cell_for_member(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
|
async def update_cell_for_member(
|
||||||
"""Batch-write 'Discordis synced?' for multiple members in a single API call."""
|
discord_id: int | None,
|
||||||
|
username: str | None,
|
||||||
|
column_name: str,
|
||||||
|
value: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Write a value to a specific column for a member row (non-blocking)."""
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_update_cell_for_member_sync, discord_id, username, column_name, value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_set_synced_sync(updates: list[tuple[int, bool]]) -> None:
|
||||||
ws = _worksheet or _get_worksheet()
|
ws = _worksheet or _get_worksheet()
|
||||||
col_idx = EXPECTED_HEADERS.index("Discordis synced?") + 1
|
col_idx = EXPECTED_HEADERS.index("Discordis synced?") + 1
|
||||||
cells = []
|
cells = []
|
||||||
@@ -170,9 +185,14 @@ def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
|
|||||||
ws.update_cells(cells, value_input_option="USER_ENTERED")
|
ws.update_cells(cells, value_input_option="USER_ENTERED")
|
||||||
|
|
||||||
|
|
||||||
def set_user_id(username: str, discord_id: int) -> bool:
|
async def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
|
||||||
|
"""Batch-write 'Discordis synced?' for multiple members (non-blocking)."""
|
||||||
|
await asyncio.to_thread(_batch_set_synced_sync, updates)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_id(username: str, discord_id: int) -> bool:
|
||||||
"""Write a Discord user ID for a row matched by Discord username."""
|
"""Write a Discord user ID for a row matched by Discord username."""
|
||||||
return update_cell_for_member(
|
return await update_cell_for_member(
|
||||||
discord_id=None,
|
discord_id=None,
|
||||||
username=username,
|
username=username,
|
||||||
column_name="User ID",
|
column_name="User ID",
|
||||||
@@ -180,9 +200,9 @@ def set_user_id(username: str, discord_id: int) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_synced(discord_id: int, synced: bool) -> bool:
|
async def set_synced(discord_id: int, synced: bool) -> bool:
|
||||||
"""Mark a member as synced (TRUE) or not (FALSE)."""
|
"""Mark a member as synced (TRUE) or not (FALSE)."""
|
||||||
return update_cell_for_member(
|
return await update_cell_for_member(
|
||||||
discord_id=discord_id,
|
discord_id=discord_id,
|
||||||
username=None,
|
username=None,
|
||||||
column_name="Discordis synced?",
|
column_name="Discordis synced?",
|
||||||
@@ -190,9 +210,9 @@ def set_synced(discord_id: int, synced: bool) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_username(discord_id: int, new_username: str) -> bool:
|
async def update_username(discord_id: int, new_username: str) -> bool:
|
||||||
"""Update the Discord column for a member (keeps sheet in sync with Discord)."""
|
"""Update the Discord column for a member (keeps sheet in sync with Discord)."""
|
||||||
return update_cell_for_member(
|
return await update_cell_for_member(
|
||||||
discord_id=discord_id,
|
discord_id=discord_id,
|
||||||
username=None,
|
username=None,
|
||||||
column_name="Discord",
|
column_name="Discord",
|
||||||
@@ -200,17 +220,17 @@ def update_username(discord_id: int, new_username: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_new_member_row(username: str, discord_id: int) -> None:
|
def _add_new_member_row_sync(username: str, discord_id: int) -> None:
|
||||||
"""Append a new row to the sheet with Discord username and User ID pre-filled.
|
|
||||||
|
|
||||||
All other columns are left empty for manual entry by an admin.
|
|
||||||
"""
|
|
||||||
ws = _worksheet or _get_worksheet()
|
ws = _worksheet or _get_worksheet()
|
||||||
row = [""] * len(EXPECTED_HEADERS)
|
row = [""] * len(EXPECTED_HEADERS)
|
||||||
row[EXPECTED_HEADERS.index("Discord")] = username
|
row[EXPECTED_HEADERS.index("Discord")] = username
|
||||||
row[EXPECTED_HEADERS.index("User ID")] = str(discord_id)
|
row[EXPECTED_HEADERS.index("User ID")] = str(discord_id)
|
||||||
row[EXPECTED_HEADERS.index("Discordis synced?")] = "FALSE"
|
row[EXPECTED_HEADERS.index("Discordis synced?")] = "FALSE"
|
||||||
ws.append_row(row, value_input_option="USER_ENTERED")
|
ws.append_row(row, value_input_option="USER_ENTERED")
|
||||||
# Add to local cache so subsequent find_member() calls work in the same session
|
|
||||||
new_entry = {h: row[i] for i, h in enumerate(EXPECTED_HEADERS)}
|
new_entry = {h: row[i] for i, h in enumerate(EXPECTED_HEADERS)}
|
||||||
_cache.append(new_entry)
|
_cache.append(new_entry)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_new_member_row(username: str, discord_id: int) -> None:
|
||||||
|
"""Append a new row pre-filled with Discord username and User ID (non-blocking)."""
|
||||||
|
await asyncio.to_thread(_add_new_member_row_sync, username, discord_id)
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"2026-03-14": [
|
|
||||||
"650046190972305409"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"allowed_channels": [
|
|
||||||
"1482398641699291357"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,904 +0,0 @@
|
|||||||
## 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 `<v0.23.0-rc14` versions and have generated a full collections snapshot migration (aka. `./pocketbase migrate collections`), then you may have to regenerate the migration file to ensure that it includes the latest changes.
|
|
||||||
|
|
||||||
PocketBase v0.23.0 is a major refactor of the internals with the overall goal of making PocketBase an easier to use Go framework.
|
|
||||||
There are a lot of changes but to highlight some of the most notable ones:
|
|
||||||
|
|
||||||
- New and more [detailed documentation](https://pocketbase.io/docs/).
|
|
||||||
_The old documentation could be accessed at [pocketbase.io/old](https://pocketbase.io/old/)._
|
|
||||||
- Replaced `echo` with a new router built on top of the Go 1.22 `net/http` mux enhancements.
|
|
||||||
- Merged `daos` packages in `core.App` to simplify the DB operations (_the `models` package structs are also migrated in `core`_).
|
|
||||||
- Option to specify custom `DBConnect` function as part of the app configuration to allow different `database/sql` SQLite drivers (_turso/libsql, sqlcipher, etc._) and custom builds.
|
|
||||||
_Note that we no longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition` linker errors in case different CGO SQLite drivers or builds are used. You can find an example how to enable it back if you want to in the [new documentation](https://pocketbase.io/docs/go-overview/#github-commattngo-sqlite3)._
|
|
||||||
- New hooks allowing better control over the execution chain and error handling (_including wrapping an entire hook chain in a single DB transaction_).
|
|
||||||
- Various `Record` model improvements (_support for get/set modifiers, simplfied file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
|
|
||||||
- Dedicated fields structs with safer defaults to make it easier creating/updating collections programmatically.
|
|
||||||
- Option to mark field as "Hidden", disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
|
|
||||||
- Option to customize the default system collection fields (`id`, `email`, `password`, etc.).
|
|
||||||
- Admins are now system `_superusers` auth records.
|
|
||||||
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
|
|
||||||
- Batch/transactional Web API endpoint.
|
|
||||||
- Impersonate Web API endpoint (_it could be also used for generating fixed/nonrenewable superuser tokens, aka. "API keys"_).
|
|
||||||
- Support for custom user request activity log attributes.
|
|
||||||
- One-Time Password (OTP) auth method (_via email code_).
|
|
||||||
- Multi-Factor Authentication (MFA) support (_currently requires any 2 different auth methods to be used_).
|
|
||||||
- Support for Record "proxy/projection" in preparation for the planned autogeneration of typed Go record models.
|
|
||||||
- Linear OAuth2 provider ([#5909](https://github.com/pocketbase/pocketbase/pull/5909); thanks @chnfyi).
|
|
||||||
- WakaTime OAuth2 provider ([#5829](https://github.com/pocketbase/pocketbase/pull/5829); thanks @tigawanna).
|
|
||||||
- Notion OAuth2 provider ([#4999](https://github.com/pocketbase/pocketbase/pull/4999); thanks @s-li1).
|
|
||||||
- monday.com OAuth2 provider ([#5346](https://github.com/pocketbase/pocketbase/pull/5346); thanks @Jaytpa01).
|
|
||||||
- New Instagram provider compatible with the new Instagram Login APIs ([#5588](https://github.com/pocketbase/pocketbase/pull/5588); thanks @pnmcosta).
|
|
||||||
_The provider key is `instagram2` to prevent conflicts with existing linked users._
|
|
||||||
- Option to retrieve the OIDC OAuth2 user info from the `id_token` payload for the cases when the provider doesn't have a dedicated user info endpoint.
|
|
||||||
- Various minor UI improvements (_recursive `Presentable` view, slightly different collection options organization, zoom/pan for the logs chart, etc._)
|
|
||||||
- and many more...
|
|
||||||
|
|
||||||
#### Go/JSVM APIs changes
|
|
||||||
|
|
||||||
> - 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`.
|
|
||||||
@@ -2,37 +2,75 @@
|
|||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
|
The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord slash command handlers), and a thin **`bot.py`** that wires everything together.
|
||||||
|
|
||||||
|
### Top level
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `bot.py` | All Discord commands, views (UI components), event handlers, reminder system |
|
| `bot.py` | Discord client, event handlers (`on_ready`, `on_member_join`, ...), background tasks (presence rotation, daily birthday loop), shared helpers (`_award_exp`, `_maybe_remind`, `_parse_amount`, `_PAUSED`), and `register_*_commands(...)` wiring for every command module |
|
||||||
| `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. |
|
| `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.) |
|
| `config.py` | Environment variables (TOKEN, GUILD_ID, PB_URL, etc.) |
|
||||||
| `scripts/migrate_to_pb.py` | One-time utility: migrate `data/economy.json` → PocketBase |
|
|
||||||
|
### `core/` - domain logic, no Discord coupling
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `core/economy.py` | All economy business logic (`do_daily`, `do_work`, ...), data model, constants (SHOP, COOLDOWNS, LEVEL_ROLES, EXP_REWARDS, JAIL_DURATION, ...) |
|
||||||
|
| `core/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
|
||||||
|
| `core/sheets.py` | Google Sheets integration (member sync) |
|
||||||
|
| `core/member_sync.py` | Birthday/member sync helpers |
|
||||||
|
|
||||||
|
### `commands/` - one slash-command group per file
|
||||||
|
|
||||||
|
Each file exposes a `register_<group>_commands(tree, bot, ...)` function. `bot.py` calls them all once on startup, passing in shared helpers (`coin`, `cd_ts`, `award_exp`, `maybe_remind`, `parse_amount`, ...).
|
||||||
|
|
||||||
|
| File | Commands / Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `commands/dev_member_commands.py` | `/check`, `/member` (dev profile only) |
|
||||||
|
| `commands/dev_member_runtime.py` | `on_member_join` flow + `birthday_daily` task body |
|
||||||
|
| `commands/economy_income_commands.py` | `/daily`, `/work`, `/beg`, `/crime`, `/rob` |
|
||||||
|
| `commands/economy_games_commands.py` | `/roulette`, `/slots`, `/blackjack`, `/rps` |
|
||||||
|
| `commands/economy_extra_commands.py` | `/heist`, `/jailbreak`, `/reminders`, `/request`, ... |
|
||||||
|
| `commands/economy_fish_commands.py` | `/fish`, `/fishbook`, `/fishsell` |
|
||||||
|
| `commands/economy_profile_commands.py` | `/balance`, `/rank`, `/stats`, `/cooldowns`, `/leaderboard` |
|
||||||
|
| `commands/economy_support_commands.py` | `/shop`, `/buy`, `/give`, `/economysetup` |
|
||||||
|
| `commands/economy_prestige_commands.py` | `/prestige`, `/prestigeshop`, `/prestigebuy` |
|
||||||
|
| `commands/economy_admin_commands.py` | `/admincoins`, `/adminexp`, `/adminitem`, `/adminjail`, `/adminban`, `/adminreset`, `/adminview` |
|
||||||
|
| `commands/ops_admin_commands.py` | `/sync`, `/restart`, `/shutdown`, `/pause`, `/send`, `/status` |
|
||||||
|
| `commands/ops_channel_commands.py` | Channel allowlist commands |
|
||||||
|
| `commands/info_commands.py` | `/patchnotes` and other lightweight info commands |
|
||||||
|
|
||||||
|
### `scripts/`
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/migrate_to_pb.py` | One-time legacy migration: `data/economy.json` → PocketBase. Only relevant if you still have a pre-PB JSON store. |
|
||||||
|
| `scripts/add_stats_fields.py` | Schema migration: adds new fields to the `economy_users` collection. Idempotent. |
|
||||||
|
| `scripts/reset_pb_collections.py` | **Destructive** - deletes and recreates the dev + economy collections. Requires `--confirm`. Use only in dev. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding a New Economy Command
|
## Adding a New Economy Command
|
||||||
|
|
||||||
|
Pick the `commands/economy_*_commands.py` file that matches the new command's category (income, games, profile, ...) and add the handler inside its `register_*_commands` function. If none fit, create a new module and register it from `bot.py`.
|
||||||
|
|
||||||
Checklist - do all of these, in order:
|
Checklist - do all of these, in order:
|
||||||
|
|
||||||
1. **`economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging
|
1. **`core/economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging
|
||||||
2. **`economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one
|
2. **`core/economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one
|
||||||
3. **`economy.py`** - add the EXP reward to `EXP_REWARDS` dict
|
3. **`core/economy.py`** - add the EXP reward to `EXP_REWARDS` dict
|
||||||
4. **`strings.py` `CMD`** - add the slash command description
|
4. **`strings.py` `CMD`** - add the slash command description
|
||||||
5. **`strings.py` `OPT`** - add any parameter descriptions
|
5. **`strings.py` `OPT`** - add any parameter descriptions
|
||||||
6. **`strings.py` `TITLE`** - add embed title(s) for success/fail states
|
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"]`)
|
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
|
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
|
9. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed
|
||||||
10. **`bot.py`** - implement the `cmd_<name>` function, handle all `res["reason"]` cases
|
10. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases
|
||||||
11. **`bot.py`** - call `_maybe_remind` if the command has a cooldown and reminders make sense
|
11. **`commands/economy_<group>_commands.py`** - call `maybe_remind(user_id, "<cmd>")` if the command has a cooldown and reminders make sense (the helper is passed in via the `register_*` signature)
|
||||||
12. **`bot.py`** - call `_award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` on success
|
12. **`commands/economy_<group>_commands.py`** - call `await award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` on success
|
||||||
13. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one
|
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
|
14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch (this helper still lives in `bot.py` and is shared across all command modules)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,21 +78,21 @@ Checklist - do all of these, in order:
|
|||||||
|
|
||||||
Checklist:
|
Checklist:
|
||||||
|
|
||||||
1. **`economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
|
1. **`core/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)
|
2. **`core/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)
|
3. **`core/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)
|
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)
|
5. **`strings.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost)
|
||||||
6. If the item modifies a cooldown:
|
6. If the item modifies a cooldown:
|
||||||
- **`economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_<cmd>` function
|
- **`core/economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_<cmd>` function
|
||||||
- **`bot.py` `_maybe_remind`** - add `elif cmd == "<cmd>" and "<item>" in items:` branch with the new delay
|
- **`bot.py` `_maybe_remind`** - add `elif cmd == "<cmd>" and "<item>" in items:` branch with the new delay
|
||||||
- **`bot.py` `cmd_cooldowns`** - add the item annotation to the relevant status line
|
- **`commands/economy_profile_commands.py` `cmd_cooldowns`** - add the item annotation to the relevant status line
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding a New Level Role
|
## Adding a New Level Role
|
||||||
|
|
||||||
1. **`economy.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first)
|
1. **`core/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)
|
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
|
3. Run **`/economysetup`** in the server to create the role and set its position
|
||||||
|
|
||||||
@@ -64,7 +102,7 @@ Checklist:
|
|||||||
|
|
||||||
1. **`strings.py` `CMD`** - add `"[Admin] ..."` description
|
1. **`strings.py` `CMD`** - add `"[Admin] ..."` description
|
||||||
2. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry
|
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()`
|
3. **`commands/economy_admin_commands.py`** (or `commands/ops_admin_commands.py` for non-economy ops) - add the handler with `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,7 +110,7 @@ Checklist:
|
|||||||
|
|
||||||
### Storage
|
### 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.
|
All economy state is stored in **PocketBase** (`economy_users` collection). `core/pb_client.py` owns all reads/writes. Each `do_*` function in `core/economy.py` calls `get_user()` → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase.
|
||||||
|
|
||||||
### Currency & Income Sources
|
### Currency & Income Sources
|
||||||
|
|
||||||
@@ -103,8 +141,10 @@ Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/bl
|
|||||||
- `/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.
|
- `/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`)
|
- **Blocked while jailed**: `/work`, `/beg`, `/crime`, `/rob`, `/give` (checked in `do_*` functions via `_is_jailed`)
|
||||||
|
|
||||||
### EXP Rewards (from `EXP_REWARDS` in economy.py)
|
### EXP Rewards (from `EXP_REWARDS` in `core/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.
|
EXP is awarded on every successful command use. Level formula: `level = max(1, floor(sqrt(exp / 6)))` (see `get_level` / `exp_for_level`). Thresholds: Level 5 = 150 EXP, Level 10 = 600, Level 20 = 2 400, Level 30 = 5 400.
|
||||||
|
|
||||||
|
Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FISH` (common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -139,27 +179,30 @@ Role assignment:
|
|||||||
| T3 | 20 | monitor_360, karikas, gaming_tool |
|
| T3 | 20 | monitor_360, karikas, gaming_tool |
|
||||||
|
|
||||||
Shop display is sorted by cost (ascending) within each tier.
|
Shop display is sorted by cost (ascending) within each tier.
|
||||||
The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds.
|
The `SHOP_LEVEL_REQ` dict in `core/economy.py` controls per-item lock thresholds.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## strings.py Organisation
|
## strings.py Organisation
|
||||||
|
|
||||||
| Section | Dict | Usage in bot.py |
|
Imported as `import strings as S` everywhere. Dicts are read from `bot.py` and from every `commands/*.py` module.
|
||||||
|
|
||||||
|
| Section | Dict | Typical usage |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions |
|
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions |
|
||||||
| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` |
|
| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` |
|
||||||
| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` |
|
| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` |
|
||||||
| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` |
|
| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` (in `bot.py`) |
|
||||||
| Banned message | `MSG_BANNED` | All banned checks |
|
| Banned message | `MSG_BANNED` | All banned checks |
|
||||||
| Maintenance mode | `MSG_MAINTENANCE` | Shown when `_PAUSED=True` in bot.py (toggled by `/pause`) |
|
| Maintenance mode | `MSG_MAINTENANCE` | Shown when `_PAUSED=True` in `bot.py` (toggled by `/pause` in `commands/ops_admin_commands.py`) |
|
||||||
| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown |
|
| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown |
|
||||||
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` |
|
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` (in `commands/economy_games_commands.py`) |
|
||||||
| Embed titles | `TITLE["key"]` | `discord.Embed(title=S.TITLE["key"])` |
|
| 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 |
|
| 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 |
|
| Cooldown messages | `CD_MSG["cmd"].format(ts=cd_ts(...))` | Cooldown responses (`cd_ts` helper passed in by `bot.py`) |
|
||||||
| Shop UI | `SHOP_UI["key"]` | `_shop_embed` |
|
| Shop UI | `SHOP_UI["key"]` | `_shop_embed` (in `commands/economy_support_commands.py`) |
|
||||||
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.SHOP[key]["description"]` |
|
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `core/economy.py` `SHOP[key]["description"]` |
|
||||||
|
| Patch notes UI | `PATCHNOTES_UI["key"]` | `commands/info_commands.py` (`/patchnotes`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -167,17 +210,18 @@ The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds.
|
|||||||
|
|
||||||
| Constant | File | Description |
|
| Constant | File | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SHOP` | `economy.py` | All shop items (name, emoji, cost, description) |
|
| `SHOP` | `core/economy.py` | All shop items (name, emoji, cost, description) |
|
||||||
| `SHOP_TIERS` | `economy.py` | Which items are in T1/T2/T3 |
|
| `SHOP_TIERS` | `core/economy.py` | Which items are in T1/T2/T3 |
|
||||||
| `SHOP_LEVEL_REQ` | `economy.py` | Min level per item |
|
| `SHOP_LEVEL_REQ` | `core/economy.py` | Min level per item |
|
||||||
| `COOLDOWNS` | `economy.py` | Base cooldown per command |
|
| `COOLDOWNS` | `core/economy.py` | Base cooldown per command |
|
||||||
| `JAIL_DURATION` | `economy.py` | How long jail lasts |
|
| `JAIL_DURATION` | `core/economy.py` | How long jail lasts |
|
||||||
| `LEVEL_ROLES` | `economy.py` | `[(min_level, "RoleName"), ...]` highest first |
|
| `LEVEL_ROLES` | `core/economy.py` | `[(min_level, "RoleName"), ...]` highest first |
|
||||||
| `ECONOMY_ROLE` | `economy.py` | Name of the base economy participation role |
|
| `ECONOMY_ROLE` | `core/economy.py` | Name of the base economy participation role |
|
||||||
| `EXP_REWARDS` | `economy.py` | EXP per command |
|
| `EXP_REWARDS` | `core/economy.py` | EXP per command |
|
||||||
| `HOUSE_ID` | `economy.py` | Bot's user ID (house account for /rob) |
|
| `FISH` | `core/economy.py` | Fish species table (rarity, weight, coins, exp) |
|
||||||
| `MIN_BAIL` | `economy.py` | Minimum bail payment (350⬡) |
|
| `HOUSE_ID` | `core/economy.py` | Bot's user ID (house account for /rob) |
|
||||||
| `COIN` | `economy.py` | The coin emoji string |
|
| `MIN_BAIL` | `core/economy.py` | Minimum bail payment (350⬡) |
|
||||||
|
| `COIN` | `core/economy.py` | The coin emoji string |
|
||||||
| `_PAUSED` | `bot.py` | In-memory maintenance flag; toggled by `/pause`; blocks all non-admin commands |
|
| `_PAUSED` | `bot.py` | In-memory maintenance flag; toggled by `/pause`; blocks all non-admin commands |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
14
docs/PATCHNOTES.md
Normal file
14
docs/PATCHNOTES.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# TipiBOT changelog
|
||||||
|
|
||||||
|
Here you'll find an overview of TipiBOT updates. Latest changes are at the top.
|
||||||
|
Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`).
|
||||||
|
|
||||||
|
## v0.1.0 — 2026-05-03
|
||||||
|
|
||||||
|
- Added `/patchnotes`
|
||||||
|
- Fixed silent swallowing of database write errors — failed saves now show the user an error instead of appearing to succeed
|
||||||
|
- Fixed fish-sell bug that let the last fish be duplicated (sold and kept in inventory)
|
||||||
|
- Fixed RPS PvP duels having no bet escrow — bets are now held when the duel is accepted, paid out to the winner, and refunded on tie / timeout / cancel (previously the loser could spend their balance before the duel resolved and the winner would get nothing)
|
||||||
|
- Fixed multi-party transfers leaving coins in limbo on partial failures — `/give` and `/rob` now roll back the first commit if the second fails; heists try to refund the house when a participant payout fails
|
||||||
|
- Fixed Google Sheets I/O blocking the Discord gateway — sheet reads/writes now run in a worker thread so heartbeats stay alive during slow API calls
|
||||||
|
- Fixed migration script overwriting accumulated PocketBase state on re-run — fields not present in the legacy JSON are now preserved instead of being clobbered with defaults
|
||||||
@@ -13,9 +13,14 @@ Create your admin account on first launch via the Admin UI.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Create the `economy_users` collection
|
## 2. Create economy collections for both bot profiles
|
||||||
|
|
||||||
In the Admin UI → **Collections** → **New collection** → name it exactly `economy_users`.
|
In the Admin UI → **Collections** → **New collection** and create:
|
||||||
|
|
||||||
|
- `economy_users_dev`
|
||||||
|
- `economy_users_prod`
|
||||||
|
|
||||||
|
Use the same schema for both collections.
|
||||||
|
|
||||||
Add the following fields:
|
Add the following fields:
|
||||||
|
|
||||||
@@ -51,6 +56,8 @@ Add to your `.env`:
|
|||||||
PB_URL=http://127.0.0.1:8090
|
PB_URL=http://127.0.0.1:8090
|
||||||
PB_ADMIN_EMAIL=your-admin@email.com
|
PB_ADMIN_EMAIL=your-admin@email.com
|
||||||
PB_ADMIN_PASSWORD=your-admin-password
|
PB_ADMIN_PASSWORD=your-admin-password
|
||||||
|
PB_ECONOMY_COLLECTION_DEV=economy_users_dev
|
||||||
|
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
11386
logs/bot.log
11386
logs/bot.log
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
|||||||
2026-04-01 18:44:29 | WORK user=272518654715887618 earned=+54 lucky=False bal=24077
|
|
||||||
2026-04-01 20:02:11 | BEG user=272518654715887618 earned=+23 jailed=False bal=24100
|
|
||||||
2026-04-01 20:02:53 | ROULETTE_WIN user=178852380018868224 bet=15214708 colour=punane result=punane mult=1 bal=30429416
|
|
||||||
2026-04-01 20:03:42 | ROULETTE_WIN user=178852380018868224 bet=30429416 colour=must result=must mult=1 bal=60858832
|
|
||||||
2026-04-01 20:04:16 | ROULETTE_LOSE user=178852380018868224 bet=60858832 colour=punane result=must mult=1 bal=0
|
|
||||||
2026-04-01 20:06:37 | BEG user=401373976431165449 earned=+52 jailed=False bal=8446
|
|
||||||
2026-04-01 20:06:39 | WORK user=401373976431165449 earned=+60 lucky=False bal=8506
|
|
||||||
2026-04-01 20:06:52 | DAILY user=401373976431165449 earned=+750 streak=1 bal=9256
|
|
||||||
2026-04-01 20:07:07 | DAILY user=272518654715887618 earned=+825 streak=1 bal=24925
|
|
||||||
2026-04-01 20:07:12 | CRIME_WIN user=272518654715887618 earned=+331 bal=25256
|
|
||||||
2026-04-01 20:07:38 | CRIME_WIN user=401373976431165449 earned=+391 bal=9647
|
|
||||||
2026-04-01 20:07:49 | BUY user=401373976431165449 item=echolood cost=-8000 bal=1647
|
|
||||||
2026-04-01 20:09:16 | ROB_BLOCKED robber=824516445382901800 victim=340451525799182357 fine=-118 robber_bal=891 ac_uses_left=1
|
|
||||||
2026-04-01 20:09:23 | ROB_BLOCKED robber=401373976431165449 victim=340451525799182357 fine=-175 robber_bal=1472 ac_uses_left=0
|
|
||||||
2026-04-01 20:09:52 | ROB_WIN robber=178852380018868224 victim=340451525799182357 stolen=+34868 jackpot=False robber_bal=34868 victim_bal=140238
|
|
||||||
2026-04-01 20:10:48 | DAILY user=367347301322326016 earned=+712 streak=1 bal=8462
|
|
||||||
2026-04-01 20:10:55 | WORK user=367347301322326016 earned=+25 lucky=False bal=8487
|
|
||||||
2026-04-01 20:11:00 | BEG user=367347301322326016 earned=+15 jailed=False bal=8502
|
|
||||||
2026-04-01 20:11:37 | ROB_FAIL robber=272518654715887618 victim=340451525799182357 fine=-140 robber_bal=25116
|
|
||||||
2026-04-01 20:15:19 | HEIST_FAIL user=178852380018868224 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=33868
|
|
||||||
2026-04-01 20:15:19 | HEIST_FAIL user=340451525799182357 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=139238
|
|
||||||
2026-04-01 20:15:19 | HEIST_FAIL user=272518654715887618 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=24116
|
|
||||||
2026-04-01 20:15:19 | HEIST_FAIL user=209554152584380420 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=20112
|
|
||||||
2026-04-01 20:15:19 | HEIST_FAIL user=401373976431165449 fine=-220 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=1252
|
|
||||||
2026-04-01 20:15:19 | HEIST_FAIL user=344531774518591498 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=112701
|
|
||||||
2026-04-01 20:15:19 | HEIST_FAIL user=367347301322326016 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=7502
|
|
||||||
2026-04-01 20:15:45 | JAIL_FREE user=272518654715887618 method=doubles
|
|
||||||
2026-04-01 20:20:07 | JAIL_FREE user=344531774518591498 method=doubles
|
|
||||||
2026-04-01 20:20:14 | DAILY user=344531774518591498 earned=+825 streak=1 bal=113526
|
|
||||||
2026-04-01 20:20:16 | WORK user=344531774518591498 earned=+45 lucky=False bal=113571
|
|
||||||
2026-04-01 20:20:19 | WORK user=272518654715887618 earned=+55 lucky=False bal=24171
|
|
||||||
2026-04-01 20:20:19 | BEG user=344531774518591498 earned=+22 jailed=False bal=113593
|
|
||||||
2026-04-01 20:20:36 | BLACKJACK user=272518654715887618 payout=+0 net=-24171 bal=0
|
|
||||||
2026-04-01 20:21:03 | CRIME_FAIL user=344531774518591498 fine=-90 jailed=True bal=113503
|
|
||||||
2026-04-01 20:21:11 | FISH user=272518654715887618 fish=koger weight=590 value=15
|
|
||||||
2026-04-01 20:21:45 | ROB_WIN robber=344531774518591498 victim=340451525799182357 stolen=+15566 jackpot=False robber_bal=129069 victim_bal=123672
|
|
||||||
2026-04-01 20:25:40 | BAIL_PAID user=178852380018868224 fine=-8760 pct=26% bal=25108
|
|
||||||
2026-04-01 20:28:28 | BEG user=178852380018868224 earned=+28 jailed=False bal=25136
|
|
||||||
2026-04-01 20:28:30 | WORK user=178852380018868224 earned=+92 lucky=False bal=25228
|
|
||||||
2026-04-01 20:28:33 | DAILY user=178852380018868224 earned=+825 streak=1 bal=26053
|
|
||||||
2026-04-01 20:28:38 | CRIME_WIN user=178852380018868224 earned=+640 bal=26693
|
|
||||||
2026-04-01 20:35:35 | BEG user=401373976431165449 earned=+56 jailed=True bal=1308
|
|
||||||
2026-04-01 20:36:20 | JAIL_FREE user=401373976431165449 method=doubles
|
|
||||||
2026-04-01 20:37:47 | FISH user=401373976431165449 fish=angerjas weight=989 value=79
|
|
||||||
2026-04-01 20:46:02 | DAILY user=338622999127261185 earned=+300 streak=1 bal=300
|
|
||||||
2026-04-01 20:56:03 | BEG user=344531774518591498 earned=+60 jailed=False bal=129129
|
|
||||||
2026-04-01 20:56:29 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=must mult=1 bal=128129
|
|
||||||
2026-04-01 21:01:23 | BEG user=272518654715887618 earned=+33 jailed=False bal=33
|
|
||||||
2026-04-01 21:01:42 | FISH user=272518654715887618 fish=sarj weight=151 value=6
|
|
||||||
2026-04-01 21:02:38 | FISH user=344531774518591498 fish=viidikas weight=98 value=6
|
|
||||||
2026-04-01 21:02:57 | FISH_SELL user=344531774518591498 count=2 coins=+13 bal=128142
|
|
||||||
2026-04-01 21:03:10 | BEG user=401373976431165449 earned=+54 jailed=False bal=1362
|
|
||||||
2026-04-01 21:03:31 | FISH user=401373976431165449 fish=siig weight=584 value=63
|
|
||||||
2026-04-01 21:05:19 | WORK user=401373976431165449 earned=+131 lucky=False bal=1493
|
|
||||||
2026-04-01 21:05:38 | FISH user=401373976431165449 fish=siig weight=1624 value=112
|
|
||||||
2026-04-01 21:05:48 | FISH_SELL user=401373976431165449 count=3 coins=+254 bal=1747
|
|
||||||
2026-04-01 21:08:31 | BEG user=401373976431165449 earned=+32 jailed=False bal=1779
|
|
||||||
2026-04-01 21:08:45 | FISH user=401373976431165449 fish=tougjas weight=3051 value=207
|
|
||||||
2026-04-01 21:09:01 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=2779
|
|
||||||
2026-04-01 21:09:35 | BLACKJACK user=401373976431165449 payout=+3000 net=+1500 bal=4279
|
|
||||||
2026-04-01 21:09:35 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=must mult=1 bal=127142
|
|
||||||
2026-04-01 21:10:05 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=5279
|
|
||||||
2026-04-01 21:10:46 | ROULETTE_WIN user=344531774518591498 bet=1000 colour=punane result=punane mult=1 bal=128142
|
|
||||||
2026-04-01 21:11:03 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=must result=punane mult=1 bal=4279
|
|
||||||
2026-04-01 21:11:24 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=roheline mult=1 bal=127142
|
|
||||||
2026-04-01 21:15:50 | WORK user=338622999127261185 earned=+15 lucky=False bal=315
|
|
||||||
2026-04-01 21:15:54 | CRIME_WIN user=338622999127261185 earned=+453 bal=768
|
|
||||||
2026-04-01 21:16:00 | BEG user=338622999127261185 earned=+20 jailed=False bal=788
|
|
||||||
2026-04-01 21:16:13 | FISH user=338622999127261185 fish=ahven weight=422 value=14
|
|
||||||
2026-04-01 21:18:36 | BEG user=401373976431165449 earned=+20 jailed=False bal=4299
|
|
||||||
2026-04-01 21:18:52 | FISH user=401373976431165449 fish=karpkala weight=1920 value=47
|
|
||||||
2026-04-01 21:20:58 | SLOTS_TRIPLE user=344531774518591498 bet=1000 change=4000 bal=131142
|
|
||||||
2026-04-01 21:21:39 | SLOTS_MISS user=344531774518591498 bet=1000 change=-1000 bal=130142
|
|
||||||
2026-04-01 21:28:25 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=3299
|
|
||||||
2026-04-01 21:29:10 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=130642
|
|
||||||
2026-04-01 21:29:28 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=2299
|
|
||||||
2026-04-01 21:30:49 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=131142
|
|
||||||
2026-04-01 21:31:30 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=131642
|
|
||||||
2026-04-01 21:31:33 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=3299
|
|
||||||
2026-04-01 21:31:37 | BEG user=401373976431165449 earned=+68 jailed=False bal=3367
|
|
||||||
2026-04-01 21:32:05 | FISH user=401373976431165449 fish=latikas weight=2351 value=66
|
|
||||||
2026-04-01 21:33:14 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=2367
|
|
||||||
2026-04-01 21:35:09 | SLOTS_MISS user=344531774518591498 bet=1000 change=-1000 bal=130642
|
|
||||||
2026-04-01 21:35:14 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=1367
|
|
||||||
2026-04-01 21:48:35 | ROB_FAIL robber=338622999127261185 victim=340451525799182357 fine=-237 robber_bal=551
|
|
||||||
2026-04-01 21:49:59 | BEG user=401373976431165449 earned=+56 jailed=False bal=1423
|
|
||||||
2026-04-01 21:50:15 | FISH user=401373976431165449 fish=vimb weight=856 value=462
|
|
||||||
2026-04-01 22:11:02 | BEG user=401373976431165449 earned=+52 jailed=False bal=1475
|
|
||||||
2026-04-01 22:11:13 | WORK user=401373976431165449 earned=+73 lucky=False bal=1548
|
|
||||||
2026-04-01 22:11:27 | ROB_WIN robber=401373976431165449 victim=367347301322326016 stolen=+1818 jackpot=False robber_bal=3366 victim_bal=5684
|
|
||||||
2026-04-01 22:11:44 | FISH user=401373976431165449 fish=lohe weight=2973 value=313
|
|
||||||
2026-04-01 22:13:30 | ROULETTE_LOSE user=401373976431165449 bet=2000 colour=punane result=roheline mult=1 bal=1366
|
|
||||||
2026-04-01 22:14:33 | ROULETTE_LOSE user=401373976431165449 bet=1366 colour=punane result=must mult=1 bal=0
|
|
||||||
2026-04-01 22:40:19 | WORK user=367347301322326016 earned=+82 lucky=False bal=5766
|
|
||||||
2026-04-01 22:40:23 | CRIME_FAIL user=367347301322326016 fine=-100 jailed=True bal=5666
|
|
||||||
2026-04-01 22:46:27 | WORK user=344531774518591498 earned=+116 lucky=False bal=130758
|
|
||||||
2026-04-01 22:46:30 | BEG user=344531774518591498 earned=+58 jailed=False bal=130816
|
|
||||||
2026-04-01 22:46:35 | CRIME_WIN user=344531774518591498 earned=+419 bal=131235
|
|
||||||
2026-04-01 22:46:44 | ROB_FAIL robber=344531774518591498 victim=340451525799182357 fine=-246 robber_bal=130989
|
|
||||||
2026-04-01 22:47:01 | FISH user=344531774518591498 fish=viidikas weight=80 value=5
|
|
||||||
2026-04-01 22:48:58 | WORK user=178852380018868224 earned=+106 lucky=True bal=26799
|
|
||||||
2026-04-01 22:49:03 | CRIME_WIN user=178852380018868224 earned=+486 bal=27285
|
|
||||||
2026-04-01 22:49:05 | BEG user=178852380018868224 earned=+76 jailed=False bal=27361
|
|
||||||
2026-04-01 22:52:25 | BEG user=401373976431165449 earned=+44 jailed=False bal=44
|
|
||||||
2026-04-01 22:52:29 | WORK user=401373976431165449 earned=+103 lucky=False bal=147
|
|
||||||
2026-04-01 22:52:39 | CRIME_FAIL user=401373976431165449 fine=-125 jailed=False bal=22
|
|
||||||
2026-04-01 22:53:01 | FISH user=401373976431165449 fish=latikas weight=1217 value=40
|
|
||||||
2026-04-01 22:57:11 | SLOTS_PAIR user=344531774518591498 bet=10000 change=5000 bal=135989
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ Requirements:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -21,10 +20,12 @@ from dotenv import load_dotenv
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
|
import config # noqa: E402
|
||||||
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
|
|
||||||
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
|
PB_URL = config.PB_URL
|
||||||
COLLECTION = "economy_users"
|
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
|
||||||
|
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
|
||||||
|
COLLECTION = config.PB_ECONOMY_COLLECTION
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# New fields to add
|
# New fields to add
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Ensure the project root is on sys.path so pb_client can be imported
|
# Ensure the project root is on sys.path so core modules can be imported
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
import pb_client # noqa: E402 (needs dotenv loaded first)
|
from core import pb_client # noqa: E402 (needs dotenv loaded first)
|
||||||
|
|
||||||
DATA_FILE = Path("data") / "economy.json"
|
DATA_FILE = Path("data") / "economy.json"
|
||||||
|
|
||||||
@@ -42,26 +42,32 @@ async def main() -> None:
|
|||||||
total = len(raw)
|
total = len(raw)
|
||||||
print(f"Found {total} user(s) in {DATA_FILE}")
|
print(f"Found {total} user(s) in {DATA_FILE}")
|
||||||
|
|
||||||
created = skipped = errors = 0
|
created = updated = errors = 0
|
||||||
|
|
||||||
for uid, user in raw.items():
|
for uid, user in raw.items():
|
||||||
try:
|
try:
|
||||||
record = dict(user)
|
|
||||||
record["user_id"] = uid
|
|
||||||
record.setdefault("balance", 0)
|
|
||||||
record.setdefault("exp", 0)
|
|
||||||
record.setdefault("items", [])
|
|
||||||
record.setdefault("item_uses", {})
|
|
||||||
record.setdefault("reminders", ["daily", "work", "beg", "crime", "rob"])
|
|
||||||
record.setdefault("eco_banned", False)
|
|
||||||
record.setdefault("daily_streak", 0)
|
|
||||||
|
|
||||||
existing = await pb_client.get_record(uid)
|
existing = await pb_client.get_record(uid)
|
||||||
if existing:
|
if existing:
|
||||||
await pb_client.update_record(existing["id"], record)
|
# Merge JSON fields *onto* the existing record so values that have
|
||||||
|
# accumulated in PB (items, daily_streak, reminders, etc.) are not
|
||||||
|
# clobbered by JSON defaults on a re-run. JSON values take
|
||||||
|
# precedence only for keys that are actually present.
|
||||||
|
merged: dict = {k: v for k, v in existing.items() if not k.startswith("_") and k != "id"}
|
||||||
|
merged.update(user)
|
||||||
|
merged["user_id"] = uid
|
||||||
|
await pb_client.update_record(existing["id"], merged)
|
||||||
print(f" [UPDATE] {uid}")
|
print(f" [UPDATE] {uid}")
|
||||||
skipped += 1 # reuse skipped counter as "updated"
|
updated += 1
|
||||||
else:
|
else:
|
||||||
|
record = dict(user)
|
||||||
|
record["user_id"] = uid
|
||||||
|
record.setdefault("balance", 0)
|
||||||
|
record.setdefault("exp", 0)
|
||||||
|
record.setdefault("items", [])
|
||||||
|
record.setdefault("item_uses", {})
|
||||||
|
record.setdefault("reminders", ["daily", "work", "beg", "crime", "rob"])
|
||||||
|
record.setdefault("eco_banned", False)
|
||||||
|
record.setdefault("daily_streak", 0)
|
||||||
await pb_client.create_record(record)
|
await pb_client.create_record(record)
|
||||||
print(f" [CREATE] {uid}")
|
print(f" [CREATE] {uid}")
|
||||||
created += 1
|
created += 1
|
||||||
@@ -69,7 +75,9 @@ async def main() -> None:
|
|||||||
print(f" [ERROR] {uid}: {exc}")
|
print(f" [ERROR] {uid}: {exc}")
|
||||||
errors += 1
|
errors += 1
|
||||||
|
|
||||||
print(f"\nDone. Created: {created} Skipped: {skipped} Errors: {errors}")
|
print(f"\nDone. Created: {created} Updated: {updated} Errors: {errors}")
|
||||||
|
if errors:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
180
scripts/reset_pb_collections.py
Normal file
180
scripts/reset_pb_collections.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""Destructively recreate economy PocketBase collections for dev + economy profiles.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/reset_pb_collections.py --confirm
|
||||||
|
|
||||||
|
This will DELETE and recreate the collections configured by:
|
||||||
|
- PB_ECONOMY_COLLECTION_DEV
|
||||||
|
- PB_ECONOMY_COLLECTION_ECONOMY
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
import config # noqa: E402
|
||||||
|
|
||||||
|
PB_URL = config.PB_URL
|
||||||
|
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
|
||||||
|
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
|
def _text_field(name: str, required: bool = False) -> dict:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"type": "text",
|
||||||
|
"required": required,
|
||||||
|
"options": {"min": None, "max": None, "pattern": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _number_field(name: str) -> dict:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"type": "number",
|
||||||
|
"required": False,
|
||||||
|
"options": {"min": None, "max": None, "noDecimal": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_field(name: str) -> dict:
|
||||||
|
return {"name": name, "type": "bool", "required": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_field(name: str) -> dict:
|
||||||
|
return {"name": name, "type": "json", "required": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _collection_payload(name: str) -> dict:
|
||||||
|
fields = [
|
||||||
|
_text_field("user_id", required=True),
|
||||||
|
_number_field("balance"),
|
||||||
|
_number_field("exp"),
|
||||||
|
_number_field("daily_streak"),
|
||||||
|
_text_field("last_daily"),
|
||||||
|
_text_field("last_work"),
|
||||||
|
_text_field("last_beg"),
|
||||||
|
_text_field("last_crime"),
|
||||||
|
_text_field("last_rob"),
|
||||||
|
_text_field("last_heist"),
|
||||||
|
_text_field("last_streak_date"),
|
||||||
|
_text_field("jailed_until"),
|
||||||
|
_text_field("last_fish"),
|
||||||
|
_json_field("items"),
|
||||||
|
_json_field("item_uses"),
|
||||||
|
_json_field("reminders"),
|
||||||
|
_json_field("prestige_upgrades"),
|
||||||
|
_json_field("fish_book"),
|
||||||
|
_json_field("fish_inventory"),
|
||||||
|
_bool_field("eco_banned"),
|
||||||
|
_bool_field("jailbreak_used"),
|
||||||
|
_number_field("heist_global_cd_until"),
|
||||||
|
_number_field("peak_balance"),
|
||||||
|
_number_field("lifetime_earned"),
|
||||||
|
_number_field("lifetime_lost"),
|
||||||
|
_number_field("work_count"),
|
||||||
|
_number_field("beg_count"),
|
||||||
|
_number_field("total_wagered"),
|
||||||
|
_number_field("biggest_win"),
|
||||||
|
_number_field("biggest_loss"),
|
||||||
|
_number_field("slots_jackpots"),
|
||||||
|
_number_field("crimes_attempted"),
|
||||||
|
_number_field("crimes_succeeded"),
|
||||||
|
_number_field("times_jailed"),
|
||||||
|
_number_field("total_bail_paid"),
|
||||||
|
_number_field("heists_joined"),
|
||||||
|
_number_field("heists_won"),
|
||||||
|
_number_field("total_given"),
|
||||||
|
_number_field("total_received"),
|
||||||
|
_number_field("best_daily_streak"),
|
||||||
|
_number_field("prestige_level"),
|
||||||
|
_number_field("prestige_points"),
|
||||||
|
_number_field("season_total_exp"),
|
||||||
|
_number_field("total_fish_caught"),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"type": "base",
|
||||||
|
"fields": fields,
|
||||||
|
"listRule": None,
|
||||||
|
"viewRule": None,
|
||||||
|
"createRule": None,
|
||||||
|
"updateRule": None,
|
||||||
|
"deleteRule": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _auth_token(session: aiohttp.ClientSession) -> str:
|
||||||
|
async with session.post(
|
||||||
|
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
||||||
|
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise RuntimeError(f"Auth failed ({resp.status}): {await resp.text()}")
|
||||||
|
return (await resp.json())["token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_if_exists(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None:
|
||||||
|
async with session.get(f"{PB_URL}/api/collections/{name}", headers=headers) as resp:
|
||||||
|
if resp.status == 404:
|
||||||
|
print(f"[SKIP] {name} does not exist")
|
||||||
|
return
|
||||||
|
if resp.status != 200:
|
||||||
|
raise RuntimeError(f"Could not fetch {name} ({resp.status}): {await resp.text()}")
|
||||||
|
|
||||||
|
async with session.delete(f"{PB_URL}/api/collections/{name}", headers=headers) as resp:
|
||||||
|
if resp.status not in (200, 204):
|
||||||
|
raise RuntimeError(f"Delete failed for {name} ({resp.status}): {await resp.text()}")
|
||||||
|
print(f"[DELETE] {name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_collection(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None:
|
||||||
|
payload = _collection_payload(name)
|
||||||
|
async with session.post(f"{PB_URL}/api/collections", json=payload, headers=headers) as resp:
|
||||||
|
if resp.status not in (200, 201):
|
||||||
|
raise RuntimeError(f"Create failed for {name} ({resp.status}): {await resp.text()}")
|
||||||
|
print(f"[CREATE] {name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--confirm", action="store_true", help="Required flag to run destructive reset")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.confirm:
|
||||||
|
raise SystemExit("Refusing to run without --confirm (this operation deletes collections).")
|
||||||
|
|
||||||
|
targets = []
|
||||||
|
for name in [config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY]:
|
||||||
|
if name and name not in targets:
|
||||||
|
targets.append(name)
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
raise SystemExit("No target collections configured.")
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=20)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
token = await _auth_token(session)
|
||||||
|
headers = {"Authorization": token}
|
||||||
|
|
||||||
|
for name in targets:
|
||||||
|
await _delete_if_exists(session, headers, name)
|
||||||
|
await _create_collection(session, headers, name)
|
||||||
|
|
||||||
|
print("\nDone. Collections recreated:")
|
||||||
|
for name in targets:
|
||||||
|
print(f" - {name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
48
strings.py
48
strings.py
@@ -171,6 +171,7 @@ CMD: dict[str, str] = {
|
|||||||
"fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)",
|
"fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)",
|
||||||
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
|
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
|
||||||
"fishsell": "Müü kalu oma inventarist",
|
"fishsell": "Müü kalu oma inventarist",
|
||||||
|
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -752,6 +753,20 @@ SEND_UI: dict[str, str] = {
|
|||||||
"forbidden": "❌ Mul pole õigust kanalisse {channel} kirjutada.",
|
"forbidden": "❌ Mul pole õigust kanalisse {channel} kirjutada.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /patchnotes UI strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PATCHNOTES_UI: dict[str, str] = {
|
||||||
|
"title": "📝 Muudatuste logi — {version}",
|
||||||
|
"footer": "Versioon {idx}/{total}",
|
||||||
|
"btn_newer": "◀ Uuem",
|
||||||
|
"btn_older": "Vanem ▶",
|
||||||
|
"select_placeholder": "Vali versioon…",
|
||||||
|
"empty_file": "ℹ️ Muudatuste logi on hetkel tühi.",
|
||||||
|
"empty_version": "_(selle versiooni kohta märkmeid pole)_",
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /allowchannel /denychannel /channels UI strings
|
# /allowchannel /denychannel /channels UI strings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -858,6 +873,11 @@ BIRTHDAY_UI: dict[str, str] = {
|
|||||||
"footer": "Leht {month}/12 · {month_name}",
|
"footer": "Leht {month}/12 · {month_name}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BIRTHDAY_MONTHS: list[str] = [
|
||||||
|
"Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni",
|
||||||
|
"Juuli", "August", "September", "Oktoober", "November", "Detsember",
|
||||||
|
]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /check summary strings
|
# /check summary strings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -870,6 +890,7 @@ CHECK_UI: dict[str, str] = {
|
|||||||
"stat_uid": "Kasutaja ID",
|
"stat_uid": "Kasutaja ID",
|
||||||
"stat_discord": "Discordi kasutajanimi",
|
"stat_discord": "Discordi kasutajanimi",
|
||||||
"stat_bday": "Sünnipäev",
|
"stat_bday": "Sünnipäev",
|
||||||
|
"no_name": "(no name)",
|
||||||
"done": "**Kontroll lõpetatud!**",
|
"done": "**Kontroll lõpetatud!**",
|
||||||
"already_ok": "✅ Juba korras: {count}",
|
"already_ok": "✅ Juba korras: {count}",
|
||||||
"fixed": "🔧 Parandatud: {count}",
|
"fixed": "🔧 Parandatud: {count}",
|
||||||
@@ -878,9 +899,35 @@ CHECK_UI: dict[str, str] = {
|
|||||||
"errors": "⚠️ Vead: {count}",
|
"errors": "⚠️ Vead: {count}",
|
||||||
"details_header": "**Üksikasjad:**",
|
"details_header": "**Üksikasjad:**",
|
||||||
"details_more": "... ja {count} rohkem",
|
"details_more": "... ja {count} rohkem",
|
||||||
|
"detail_error": "⚠️ {error}",
|
||||||
|
"detail_nickname": "hüüdnimi",
|
||||||
|
"detail_roles_added": "+rollid: {roles}",
|
||||||
|
"detail_changed": "🔧 **{name}**: {parts}",
|
||||||
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
|
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /status UI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STATUS_UI: dict[str, str] = {
|
||||||
|
"title": "🖥️ Boti olek",
|
||||||
|
"uptime_field": "🕐 Uptime",
|
||||||
|
"uptime_val": "{hours}t {minutes}m {seconds}s",
|
||||||
|
"latency_field": "📡 Latency",
|
||||||
|
"latency_val": "{ms} ms",
|
||||||
|
"ram_field": "🧠 RAM (RSS)",
|
||||||
|
"ram_val": "{mb} MB",
|
||||||
|
"cpu_field": "⚙️ CPU",
|
||||||
|
"cpu_val": "{percent}%",
|
||||||
|
"tasks_field": "🔄 Async tasks",
|
||||||
|
"eco_players_field": "👤 Eco players",
|
||||||
|
"members_cache_field": "📋 Liikmed (cache)",
|
||||||
|
"log_files_field": "📂 Log files",
|
||||||
|
"log_line": "`{name}` - {size_kb} KB",
|
||||||
|
"none": "-",
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Admin command responses and DMs
|
# Admin command responses and DMs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1089,6 +1136,7 @@ JAILBREAK_UI: dict[str, str] = {
|
|||||||
|
|
||||||
LEADERBOARD_UI: dict[str, str] = {
|
LEADERBOARD_UI: dict[str, str] = {
|
||||||
"house_entry": "🤖 {name} *(maja)* - {balance}",
|
"house_entry": "🤖 {name} *(maja)* - {balance}",
|
||||||
|
"house_default_name": "TipiBOT",
|
||||||
"no_entries": "Keegi ei ole veel punkte teeninud.",
|
"no_entries": "Keegi ei ole veel punkte teeninud.",
|
||||||
"footer": "Lehekülg {page}/{total} · {count} mängijat",
|
"footer": "Lehekülg {page}/{total} · {count} mängijat",
|
||||||
"btn_coins": "🪙 Mündid",
|
"btn_coins": "🪙 Mündid",
|
||||||
|
|||||||
Reference in New Issue
Block a user