forked from sass/tipibot
Compare commits
2 Commits
master
...
691f160a09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
691f160a09 | ||
|
|
3c2b4342a2 |
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
*.md
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
compose.yaml
|
||||||
|
credentials.json
|
||||||
14
.env.example
14
.env.example
@@ -2,12 +2,15 @@
|
|||||||
# Profile-specific Discord bot tokens (from https://discord.com/developers/applications)
|
# Profile-specific Discord bot tokens (from https://discord.com/developers/applications)
|
||||||
DISCORD_TOKEN_DEV=your-dev-bot-token-here
|
DISCORD_TOKEN_DEV=your-dev-bot-token-here
|
||||||
DISCORD_TOKEN_ECONOMY=your-economy-bot-token-here
|
DISCORD_TOKEN_ECONOMY=your-economy-bot-token-here
|
||||||
|
DISCORD_BOT_LAN=your-lan-bot-token-here
|
||||||
|
|
||||||
# Legacy fallback token (optional, backward compatibility)
|
# Legacy fallback token (optional, backward compatibility)
|
||||||
DISCORD_TOKEN=
|
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
|
||||||
|
SHEET_ID_DEV=
|
||||||
|
SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw
|
||||||
|
|
||||||
# Path to Google service account credentials JSON
|
# Path to Google service account credentials JSON
|
||||||
GOOGLE_CREDS_PATH=credentials.json
|
GOOGLE_CREDS_PATH=credentials.json
|
||||||
@@ -15,6 +18,7 @@ GOOGLE_CREDS_PATH=credentials.json
|
|||||||
# Profile-specific guild (server) IDs - right-click your server with dev mode on
|
# Profile-specific guild (server) IDs - right-click your server with dev mode on
|
||||||
GUILD_ID_DEV=your-dev-guild-id-here
|
GUILD_ID_DEV=your-dev-guild-id-here
|
||||||
GUILD_ID_ECONOMY=your-economy-guild-id-here
|
GUILD_ID_ECONOMY=your-economy-guild-id-here
|
||||||
|
GUILD_ID_LAN=1301145356750426192
|
||||||
|
|
||||||
# Legacy fallback guild ID (optional, backward compatibility)
|
# Legacy fallback guild ID (optional, backward compatibility)
|
||||||
GUILD_ID=
|
GUILD_ID=
|
||||||
@@ -39,6 +43,16 @@ PB_ADMIN_PASSWORD=your-pb-admin-password
|
|||||||
# Profile-specific PocketBase collections
|
# Profile-specific PocketBase collections
|
||||||
PB_ECONOMY_COLLECTION_DEV=economy_users_dev
|
PB_ECONOMY_COLLECTION_DEV=economy_users_dev
|
||||||
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod
|
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod
|
||||||
|
PB_ECONOMY_COLLECTION_LAN=economy_users_lan
|
||||||
|
PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan
|
||||||
|
|
||||||
# Legacy fallback collection name (optional, backward compatibility)
|
# Legacy fallback collection name (optional, backward compatibility)
|
||||||
PB_ECONOMY_COLLECTION=
|
PB_ECONOMY_COLLECTION=
|
||||||
|
|
||||||
|
# Fienta LAN registration sync
|
||||||
|
# Fienta production URLs:
|
||||||
|
# https://veebikonks.tipilan.ee/fienta/purchase
|
||||||
|
# https://veebikonks.tipilan.ee/fienta/registration
|
||||||
|
FIENTA_WEBHOOK_SECRET=optional-secret-for-/fienta/webhook
|
||||||
|
FIENTA_WEBHOOK_PORT=8090
|
||||||
|
FIENTA_ADMIN_ALERT_CHANNEL_ID=1478302279894302812
|
||||||
|
|||||||
@@ -1,35 +1,17 @@
|
|||||||
name: Test & Deploy
|
name: Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
deploy:
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
steps:
|
steps:
|
||||||
- name: Run test suite
|
- name: Deploy
|
||||||
run: |
|
run: |
|
||||||
REPO=$(git -C /root/tipibot remote get-url origin)
|
cd ~/tipibot
|
||||||
rm -rf /tmp/tipibot-ci
|
git pull
|
||||||
git clone --quiet "$REPO" /tmp/tipibot-ci
|
source .venv/bin/activate
|
||||||
cd /tmp/tipibot-ci
|
pip install -r requirements.txt
|
||||||
git checkout --quiet ${{ github.sha }}
|
systemctl restart tipibot
|
||||||
python3 -m venv /opt/tipibot-ci-venv
|
|
||||||
/opt/tipibot-ci-venv/bin/pip install -q -r requirements-dev.txt
|
|
||||||
/opt/tipibot-ci-venv/bin/python -m pytest tests/ -q
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: linux
|
|
||||||
needs: test
|
|
||||||
steps:
|
|
||||||
- name: Deploy
|
|
||||||
run: |
|
|
||||||
cd /root/tipibot
|
|
||||||
git pull
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
sudo systemctl restart tipibot_dev # dev bot first, as canary
|
|
||||||
sleep 5
|
|
||||||
systemctl is-active --quiet tipibot_dev # dev crashed on startup -> abort
|
|
||||||
sudo systemctl restart tipibot_eco # eco bot only if dev survived
|
|
||||||
|
|||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create data and logs directories
|
||||||
|
RUN mkdir -p data logs
|
||||||
|
|
||||||
|
CMD ["python", "bot.py"]
|
||||||
156
README.md
156
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 two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes, then run `python scripts/sync_pb_schema.py` to add every field the bot persists. Re-run it after any update that introduces new fields (PocketBase silently drops writes to fields missing from the 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,14 +88,18 @@ cp .env.example .env
|
|||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `BOT_PROFILE` | Runtime profile: `dev` or `economy` |
|
| `BOT_PROFILE` | Runtime profile: `dev`, `economy`, or `lan` |
|
||||||
| `DISCORD_TOKEN_DEV` | Dev bot token from Discord Developer Portal |
|
| `DISCORD_TOKEN_DEV` | Dev bot token from Discord Developer Portal |
|
||||||
| `DISCORD_TOKEN_ECONOMY` | Economy bot token from Discord Developer Portal |
|
| `DISCORD_TOKEN_ECONOMY` | Economy bot token from Discord Developer Portal |
|
||||||
|
| `DISCORD_BOT_LAN` | LAN bot token from Discord Developer Portal |
|
||||||
| `DISCORD_TOKEN` | Legacy fallback token (optional) |
|
| `DISCORD_TOKEN` | Legacy fallback token (optional) |
|
||||||
| `SHEET_ID` | ID from the Google Sheet URL |
|
| `SHEET_ID` | ID from the Google Sheet URL |
|
||||||
|
| `SHEET_ID_DEV` | Optional dev/member sheet override |
|
||||||
|
| `SHEET_ID_LAN` | LAN public registration sheet ID |
|
||||||
| `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) |
|
| `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) |
|
||||||
| `GUILD_ID_DEV` | Dev bot guild ID |
|
| `GUILD_ID_DEV` | Dev bot guild ID |
|
||||||
| `GUILD_ID_ECONOMY` | Economy bot guild ID |
|
| `GUILD_ID_ECONOMY` | Economy bot guild ID |
|
||||||
|
| `GUILD_ID_LAN` | LAN bot guild ID |
|
||||||
| `GUILD_ID` | Legacy fallback guild ID (optional) |
|
| `GUILD_ID` | Legacy fallback guild ID (optional) |
|
||||||
| `BIRTHDAY_CHANNEL_ID_DEV` | Channel for birthday `@here` pings in dev profile |
|
| `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_ECONOMY` | Optional birthday channel in economy profile |
|
||||||
@@ -106,7 +110,12 @@ cp .env.example .env
|
|||||||
| `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_DEV` | PocketBase collection used by `BOT_PROFILE=dev` |
|
||||||
| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
|
| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
|
||||||
|
| `PB_ECONOMY_COLLECTION_LAN` | PocketBase economy collection used by `BOT_PROFILE=lan` |
|
||||||
|
| `PB_FIENTA_COLLECTION_LAN` | PocketBase collection for LAN Fienta registration records |
|
||||||
| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) |
|
| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) |
|
||||||
|
| `FIENTA_WEBHOOK_SECRET` | Optional secret path token for `/fienta/webhook/<secret>` testing |
|
||||||
|
| `FIENTA_WEBHOOK_PORT` | LAN Fienta webhook listen port (default: `8090`) |
|
||||||
|
| `FIENTA_ADMIN_ALERT_CHANNEL_ID` | Discord channel for LAN Fienta sync alerts |
|
||||||
|
|
||||||
### 6. Install & Run
|
### 6. Install & Run
|
||||||
|
|
||||||
@@ -222,7 +231,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 `core/pb_client.py`). The currency is **TipiCOIN** (⬡), displayed as a custom Discord emoji configured in `core/economy.py → COIN`.
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -245,15 +254,15 @@ The house is listed at **#0** on the leaderboard. Players can attempt to rob it
|
|||||||
|
|
||||||
| Command | Cooldown | Base payout | Notes |
|
| Command | Cooldown | Base payout | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
|
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h. LAN Pilet doubles the reward. Botikoobas adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
|
||||||
| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
|
| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
|
||||||
| `/beg` | 5min | 10–40 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
|
| `/beg` | 5min | 10–40 ⬡ | XL hiirematt reduces cooldown to 3min. Mehhaaniline klaviatuur multiplies earnings ×2. |
|
||||||
| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. |
|
| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with CAT6). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Mänguritool skips jail on fail. |
|
||||||
| `/fish` | 2min | varies | Interactive minigame. Cast → wait for bite → press button within 2s → keep in inventory or sell immediately. Ussipurk reduces cooldown to 90s. |
|
| `/fish` | 2min | varies | Interactive minigame. Cast → wait for bite → press button within 2s → keep in inventory or sell immediately. Ussipurk reduces cooldown to 90s. |
|
||||||
|
|
||||||
### Daily streak
|
### Daily streak
|
||||||
|
|
||||||
The streak increments each time you claim `/daily` within the cooldown window. Missing a day resets it to 1 **unless** you own the TipiLAN karikas item.
|
The streak increments each time you claim `/daily` within the cooldown window. Missing a day resets it to 1 **unless** you own the TipiLAN trofee item.
|
||||||
|
|
||||||
| Streak | Multiplier | Payout (base) |
|
| Streak | Multiplier | Payout (base) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -262,7 +271,7 @@ The streak increments each time you claim `/daily` within the cooldown window. M
|
|||||||
| 7–13 days | ×2.0 | 300 ⬡ |
|
| 7–13 days | ×2.0 | 300 ⬡ |
|
||||||
| 14+ days | ×3.0 | 450 ⬡ |
|
| 14+ days | ×3.0 | 450 ⬡ |
|
||||||
|
|
||||||
> With LAN pilet (×2 daily) and a 14-day streak (×3.0) the base payout reaches **900 ⬡**. Add Bot Farm 5% interest on top.
|
> With LAN Pilet (×2 daily) and a 14-day streak (×3.0) the base payout reaches **900 ⬡**. Add Botikoobas 5% interest on top.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -279,13 +288,13 @@ 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 | +2 to +25 (varies by rarity: common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25) |
|
| `/fish` catch | +3 to +15 (varies by rarity) |
|
||||||
|
|
||||||
**Level formula:** `level = max(1, floor(√(total_exp ÷ 10)))`
|
**Level formula:** `level = floor(√(total_exp ÷ 10))`
|
||||||
|
|
||||||
| Level | EXP required | Milestone |
|
| Level | EXP required | Milestone |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | 0 | TipiNOOB role |
|
| 1 | 10 | TipiNOOB role |
|
||||||
| 5 | 250 | TipiGRINDER role |
|
| 5 | 250 | TipiGRINDER role |
|
||||||
| 10 | 1 000 | TipiHUSTLER role · **T2 shop unlocks** |
|
| 10 | 1 000 | TipiHUSTLER role · **T2 shop unlocks** |
|
||||||
| 20 | 4 000 | TipiCHAD role · **T3 shop unlocks** |
|
| 20 | 4 000 | TipiCHAD role · **T3 shop unlocks** |
|
||||||
@@ -340,7 +349,6 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e
|
|||||||
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
|
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
|
||||||
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
||||||
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
|
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
|
||||||
| `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. |
|
|
||||||
| `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). |
|
| `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). |
|
||||||
| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
|
| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
|
||||||
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
||||||
@@ -352,7 +360,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e
|
|||||||
|
|
||||||
### Jail system
|
### Jail system
|
||||||
|
|
||||||
`/crime` fail (without Gaming tool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked.
|
`/crime` fail (without Mänguritool) jails you for **30 minutes**. While jailed, `/work`, `/beg`, `/crime`, `/rob`, and `/give` are blocked.
|
||||||
|
|
||||||
#### `/jailbreak`
|
#### `/jailbreak`
|
||||||
Press the roll button - both dice are rolled simultaneously with an animated reveal. **3 attempts** per sentence. Matching values (doubles) = free instantly. If all 3 fail you pay bail:
|
Press the roll button - both dice are rolled simultaneously with an animated reveal. **3 attempts** per sentence. Matching values (doubles) = free instantly. If all 3 fail you pay bail:
|
||||||
@@ -381,19 +389,6 @@ Spend PP in `/prestigeshop`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Quests
|
|
||||||
|
|
||||||
`/quests` shows your personal quest board: **3 daily** and **2 weekly** quests with progress bars and a claim button.
|
|
||||||
|
|
||||||
- Quests track counters you're already grinding: work/beg counts, coins earned, amount wagered, fish caught, crimes succeeded, coins given, heists joined.
|
|
||||||
- Progress starts counting from the moment the quest set rolls - stats earned before that don't count.
|
|
||||||
- The daily set resets at **UTC midnight**, the weekly set on the **ISO week** rollover. Each player gets their own rotation.
|
|
||||||
- Rewards are TipiCOINs (prestige coin multiplier applies) plus EXP through the normal level-up path.
|
|
||||||
|
|
||||||
> Schema note: the quest fields live in PocketBase - after pulling this feature run `python scripts/sync_pb_schema.py` once (it reconciles both the dev and prod collections with everything the bot persists).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Fishing
|
### Fishing
|
||||||
|
|
||||||
`/fish` is an interactive minigame with a **2-minute cooldown** (90s with Ussipurk):
|
`/fish` is an interactive minigame with a **2-minute cooldown** (90s with Ussipurk):
|
||||||
@@ -421,34 +416,39 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
|
|||||||
| Item | Cost | Effect |
|
| Item | Cost | Effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Mängurihiir | 500 ⬡ | `/work` earns +50% |
|
| Mängurihiir | 500 ⬡ | `/work` earns +50% |
|
||||||
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
| XL hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
||||||
|
| Anticheat | 750 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
|
||||||
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
|
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
|
||||||
| Anticheat | 1 000 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
|
|
||||||
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h |
|
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h |
|
||||||
| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
| LAN Pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
||||||
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
| Botikoobas | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
||||||
|
|
||||||
#### Tier 2 - level 10 required (TipiHUSTLER+)
|
#### Tier 2 - level 10 required (TipiHUSTLER+)
|
||||||
|
|
||||||
| Item | Cost | Effect |
|
| Item | Cost | Effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Mehaaniline klaviatuur | 1 800 ⬡ | `/beg` earns ×2 |
|
| Mehhaaniline klaviatuur | 1 800 ⬡ | `/beg` earns ×2 |
|
||||||
| Ultralai monitor | 2 500 ⬡ | `/work` cooldown 1h → 40min |
|
| Ultralai monitor | 2 500 ⬡ | `/work` cooldown 1h → 40min |
|
||||||
| Eraldiseisev mikrofon | 2 800 ⬡ | `/crime` win earns +30% |
|
| Mikrofon | 2 800 ⬡ | `/crime` win earns +30% |
|
||||||
| Reguleeritav laud | 3 500 ⬡ | `/work` earns +25% (stacks with Mängurihiir → ×1.875 combined) |
|
| Reguleeritav laud | 3 500 ⬡ | `/work` earns +25% (stacks with Mängurihiir → ×1.875 combined) |
|
||||||
| Cat6 kaabel | 3 500 ⬡ | `/crime` success rate 60% → 75% |
|
| CAT6 netikaabel | 3 500 ⬡ | `/crime` success rate 60% → 75% |
|
||||||
| Jellyfin server | 4 000 ⬡ | `/rob` success rate 45% → 60% |
|
| Jellyfin server | 4 000 ⬡ | `/rob` success rate 45% → 60% |
|
||||||
|
|
||||||
|
#### Tier 2 - level 10 required (TipiHUSTLER+) - continued
|
||||||
|
|
||||||
|
| Item | Cost | Effect |
|
||||||
|
|---|---|---|
|
||||||
| Ussipurk | 3 500 ⬡ | `/fish` cooldown 2min → 90s |
|
| Ussipurk | 3 500 ⬡ | `/fish` cooldown 2min → 90s |
|
||||||
|
|
||||||
#### Tier 3 - level 20 required (TipiCHAD+)
|
#### Tier 3 - level 20 required (TipiCHAD+)
|
||||||
|
|
||||||
| Item | Cost | Effect |
|
| Item | Cost | Effect |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| TipiLAN trofee | 6 000 ⬡ | Daily streak survives missed days |
|
||||||
|
| 360hz monitor | 7 500 ⬡ | Slots jackpot 10× → 15×, triple 4× → 6× |
|
||||||
|
| Mänguritool | 9 000 ⬡ | `/crime` fail never sends you to jail |
|
||||||
| Kalavõrk | 5 000 ⬡ | All fish caught are bumped up one rarity tier |
|
| Kalavõrk | 5 000 ⬡ | All fish caught are bumped up one rarity tier |
|
||||||
| TipiLAN karikas | 6 000 ⬡ | Daily streak survives missed days |
|
|
||||||
| 360Hz monitor | 7 500 ⬡ | Slots triple multipliers ×1.5 (jackpot ×25 → ×37) |
|
|
||||||
| Echolood | 8 000 ⬡ | Fishing bite window 2s → 3s |
|
| Echolood | 8 000 ⬡ | Fishing bite window 2s → 3s |
|
||||||
| Gaming tool | 9 000 ⬡ | `/crime` fail never sends you to jail |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -457,7 +457,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 `core/economy.py` to any Discord emoji string:
|
Change `COIN` in `economy.py` to any Discord emoji string:
|
||||||
```python
|
```python
|
||||||
COIN = "<:tipicoin:YOUR_EMOJI_ID>"
|
COIN = "<:tipicoin:YOUR_EMOJI_ID>"
|
||||||
```
|
```
|
||||||
@@ -466,12 +466,12 @@ COIN = "<:tipicoin:YOUR_EMOJI_ID>"
|
|||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
Logs are written under `logs/<BOT_PROFILE>/` (auto-created on startup), so dev and economy profiles keep separate log streams.
|
All logs are written to the `logs/` directory (auto-created on startup).
|
||||||
|
|
||||||
| File | Rotation | Contents |
|
| File | Rotation | Contents |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `logs/<profile>/bot.log` | 5 MB x 5 backups | All INFO+ events: commands, errors, member sync |
|
| `logs/bot.log` | 5 MB x 5 backups | All INFO+ events: commands, errors, member sync |
|
||||||
| `logs/<profile>/transactions.log` | Daily, 30 days | Economy transactions only: every balance change with user, amount, new balance |
|
| `logs/transactions.log` | Daily, 30 days | Economy transactions only: every balance change with user, amount, new balance |
|
||||||
|
|
||||||
The terminal output is **colour-coded** by log level (green = INFO, yellow = WARNING, red = ERROR).
|
The terminal output is **colour-coded** by log level (green = INFO, yellow = WARNING, red = ERROR).
|
||||||
|
|
||||||
@@ -482,60 +482,28 @@ Every slash command invocation is logged with the user ID, display name, and all
|
|||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── bot.py # Discord client, event handlers, shared helpers; wires command modules together
|
├── bot.py # Discord client, all slash commands, event handlers
|
||||||
├── strings.py # All user-facing strings (command descriptions, help text, errors)
|
├── economy.py # TipiCOIN business logic, constants (SHOP, COOLDOWNS, etc.)
|
||||||
├── config.py # Environment variable loader
|
├── pb_client.py # Async PocketBase REST client (auth + CRUD for economy_users)
|
||||||
├── core/
|
├── strings.py # All user-facing strings, command descriptions, help text
|
||||||
│ ├── economy/ # TipiCOIN business logic (re-exported via core/economy/__init__.py)
|
├── member_sync.py # Role/nickname/birthday sync logic
|
||||||
│ │ ├── store.py # User records, per-user locks, time/cooldown helpers, txn log
|
├── sheets.py # Google Sheets read/write + in-memory cache
|
||||||
│ │ ├── house.py # House account (fines in, heists out)
|
├── config.py # Environment variable loader
|
||||||
│ │ ├── levels.py # EXP, levels, vanity roles
|
├── requirements.txt # Python dependencies
|
||||||
│ │ ├── shop.py # Item catalogue + /buy
|
├── .env.example # Template for secrets
|
||||||
│ │ ├── income.py # /daily, /work, /beg, /crime, /rob, /give
|
├── .env # Your secrets (gitignored)
|
||||||
│ │ ├── gambling.py # /roulette, /slots, /rps, /blackjack
|
├── credentials.json # Google service account key (gitignored)
|
||||||
│ │ ├── fishing.py # Fish catalogue + minigame flows
|
|
||||||
│ │ ├── quests.py # Daily/weekly quest system
|
|
||||||
│ │ ├── prestige.py # Prestige resets + upgrade shop
|
|
||||||
│ │ ├── jail.py # Jail, jailbreak, bail
|
|
||||||
│ │ ├── heist.py # Group heists against the house
|
|
||||||
│ │ ├── leaderboards.py # All leaderboard queries
|
|
||||||
│ │ └── admin.py # Admin mutations + season reset
|
|
||||||
│ ├── pb_client.py # Async PocketBase REST client (auth + CRUD for economy_users)
|
|
||||||
│ ├── sheets.py # Google Sheets read/write + in-memory cache
|
|
||||||
│ └── member_sync.py # Role/nickname/birthday sync logic
|
|
||||||
├── commands/
|
|
||||||
│ ├── dev_member_commands.py # /check, /member (dev profile)
|
|
||||||
│ ├── 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_quests_commands.py # /quests (daily/weekly quest board + claim)
|
|
||||||
│ ├── 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)
|
||||||
│ ├── PATCHNOTES.md # Player-facing patch notes (surfaced via /patchnotes)
|
│ ├── CHANGELOG.md # Version history
|
||||||
│ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions
|
│ └── POCKETBASE_SETUP.md # PocketBase collection schema + setup instructions
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── migrate_to_pb.py # One-time legacy migration: economy.json → PocketBase
|
│ ├── migrate_to_pb.py # One-time migration: economy.json → PocketBase
|
||||||
│ ├── sync_pb_schema.py # Reconcile PB collections with the full user schema (dev + prod)
|
│ └── add_stats_fields.py # Schema migration: add new fields to economy_users collection
|
||||||
│ ├── add_stats_fields.py # Older partial schema migration (superseded by sync_pb_schema.py)
|
├── data/
|
||||||
│ ├── add_quest_fields.py # Older partial schema migration (superseded by sync_pb_schema.py)
|
│ └── birthday_sent.json # Birthday dedup log (auto-created)
|
||||||
│ └── reset_pb_collections.py # Destructive: deletes & recreates economy collections (--confirm required)
|
├── pb_data/ # PocketBase database files (auto-created, gitignored)
|
||||||
├── requirements.txt # Python dependencies
|
└── logs/
|
||||||
├── .env.example # Template for secrets
|
├── bot.log # General rotating log (auto-created)
|
||||||
├── .env # Your secrets (gitignored)
|
└── transactions.log # Daily economy transaction log (auto-created)
|
||||||
├── credentials.json # Google service account key (gitignored)
|
|
||||||
├── 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)
|
|
||||||
```
|
```
|
||||||
|
|||||||
171
bot.py
171
bot.py
@@ -18,11 +18,11 @@ from discord.ext import tasks
|
|||||||
|
|
||||||
import colorlog
|
import colorlog
|
||||||
import psutil
|
import psutil
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import strings as S
|
import strings as S
|
||||||
from core import economy, pb_client, sheets
|
from core import economy, lan_fienta, pb_client, sheets
|
||||||
from core.admin import is_bot_admin
|
|
||||||
from core.member_sync import SyncResult
|
from core.member_sync import SyncResult
|
||||||
from commands.dev_member_commands import register_dev_member_commands
|
from commands.dev_member_commands import register_dev_member_commands
|
||||||
from commands.dev_member_runtime import handle_member_join, run_birthday_daily
|
from commands.dev_member_runtime import handle_member_join, run_birthday_daily
|
||||||
@@ -32,12 +32,11 @@ from commands.economy_fish_commands import register_economy_fish_commands
|
|||||||
from commands.economy_games_commands import register_economy_games_commands
|
from commands.economy_games_commands import register_economy_games_commands
|
||||||
from commands.economy_income_commands import register_economy_income_commands
|
from commands.economy_income_commands import register_economy_income_commands
|
||||||
from commands.economy_prestige_commands import register_prestige_commands
|
from commands.economy_prestige_commands import register_prestige_commands
|
||||||
from commands.economy_quests_commands import register_economy_quests_commands
|
|
||||||
from commands.economy_profile_commands import register_economy_profile_commands
|
from commands.economy_profile_commands import register_economy_profile_commands
|
||||||
from commands.economy_support_commands import register_economy_support_commands
|
from commands.economy_support_commands import register_economy_support_commands
|
||||||
|
from commands.lan_fienta_commands import register_lan_fienta_commands
|
||||||
from commands.ops_channel_commands import register_ops_channel_commands
|
from commands.ops_channel_commands import register_ops_channel_commands
|
||||||
from commands.ops_admin_commands import register_ops_admin_commands
|
from commands.ops_admin_commands import register_ops_admin_commands
|
||||||
from commands.info_commands import register_info_commands
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -84,7 +83,7 @@ _txn_logger = logging.getLogger("tipiCOIN.txn")
|
|||||||
_txn_logger.addHandler(_txn_h)
|
_txn_logger.addHandler(_txn_h)
|
||||||
_txn_logger.propagate = False # don't double-log to console/bot.log
|
_txn_logger.propagate = False # don't double-log to console/bot.log
|
||||||
|
|
||||||
log = logging.getLogger(f"tipilan.{config.BOT_PROFILE}")
|
log = logging.getLogger("tipilan")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Bot setup
|
# Bot setup
|
||||||
@@ -97,6 +96,7 @@ tree = app_commands.CommandTree(bot)
|
|||||||
|
|
||||||
GUILD_OBJ = discord.Object(id=config.GUILD_ID)
|
GUILD_OBJ = discord.Object(id=config.GUILD_ID)
|
||||||
IS_DEV_PROFILE = config.BOT_PROFILE == "dev"
|
IS_DEV_PROFILE = config.BOT_PROFILE == "dev"
|
||||||
|
IS_LAN_PROFILE = config.BOT_PROFILE == "lan"
|
||||||
TALLINN_TZ = ZoneInfo("Europe/Tallinn")
|
TALLINN_TZ = ZoneInfo("Europe/Tallinn")
|
||||||
_start_time = datetime.datetime.now()
|
_start_time = datetime.datetime.now()
|
||||||
_process = psutil.Process()
|
_process = psutil.Process()
|
||||||
@@ -115,6 +115,8 @@ _RESTART_FILE = _DATA_DIR / "restart_channel.json"
|
|||||||
_BOT_CONFIG = _DATA_DIR / "bot_config.json"
|
_BOT_CONFIG = _DATA_DIR / "bot_config.json"
|
||||||
_PAUSED = False # maintenance mode: blocks non-admin commands when True
|
_PAUSED = False # maintenance mode: blocks non-admin commands when True
|
||||||
_DEV_ONLY_COMMANDS: tuple[str, ...] = ("birthdays", "check", "member")
|
_DEV_ONLY_COMMANDS: tuple[str, ...] = ("birthdays", "check", "member")
|
||||||
|
_LAN_ONLY_COMMANDS: tuple[str, ...] = ("fientasync",)
|
||||||
|
_FIENTA_RUNNER: web.AppRunner | None = None
|
||||||
|
|
||||||
|
|
||||||
def _apply_profile_command_filters() -> None:
|
def _apply_profile_command_filters() -> None:
|
||||||
@@ -164,6 +166,64 @@ def _member_cache_size() -> int:
|
|||||||
return len(sheets.get_cache())
|
return len(sheets.get_cache())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fienta webhook server (LAN profile)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def _fienta_health(_: web.Request) -> web.Response:
|
||||||
|
return web.json_response({"ok": True, "profile": config.BOT_PROFILE})
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_fienta_payload(payload: dict, source: str) -> None:
|
||||||
|
try:
|
||||||
|
summary = await lan_fienta.process_payload(bot, payload)
|
||||||
|
log.info("Fienta %s webhook processed: %s", source, summary.short())
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Fienta %s webhook processing failed: %s", source, exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def _accept_fienta_payload(request: web.Request, source: str) -> web.Response:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({"ok": False, "error": "invalid JSON"}, status=400)
|
||||||
|
asyncio.create_task(_process_fienta_payload(payload, source))
|
||||||
|
return web.json_response({"ok": True, "accepted": True, "source": source})
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_fienta_secret_webhook(request: web.Request) -> web.Response:
|
||||||
|
if not config.FIENTA_WEBHOOK_SECRET:
|
||||||
|
return web.json_response({"ok": False, "error": "webhook secret not configured"}, status=503)
|
||||||
|
if request.match_info.get("secret") != config.FIENTA_WEBHOOK_SECRET:
|
||||||
|
return web.json_response({"ok": False, "error": "not found"}, status=404)
|
||||||
|
return await _accept_fienta_payload(request, "secret")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_fienta_purchase(request: web.Request) -> web.Response:
|
||||||
|
return await _accept_fienta_payload(request, "purchase")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_fienta_registration(request: web.Request) -> web.Response:
|
||||||
|
return await _accept_fienta_payload(request, "registration")
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_fienta_webhook() -> None:
|
||||||
|
global _FIENTA_RUNNER
|
||||||
|
if not IS_LAN_PROFILE or _FIENTA_RUNNER is not None:
|
||||||
|
return
|
||||||
|
app = web.Application(client_max_size=5 * 1024 * 1024)
|
||||||
|
app.router.add_get("/health", _fienta_health)
|
||||||
|
app.router.add_post("/fienta/purchase", _handle_fienta_purchase)
|
||||||
|
app.router.add_post("/fienta/registration", _handle_fienta_registration)
|
||||||
|
app.router.add_post("/fienta/webhook/{secret}", _handle_fienta_secret_webhook)
|
||||||
|
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "0.0.0.0", config.FIENTA_WEBHOOK_PORT)
|
||||||
|
await site.start()
|
||||||
|
_FIENTA_RUNNER = runner
|
||||||
|
log.info("LAN Fienta webhook listening on 0.0.0.0:%s", config.FIENTA_WEBHOOK_PORT)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# EXP / Level role helpers
|
# EXP / Level role helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -244,6 +304,7 @@ async def _award_exp(interaction: discord.Interaction, amount: int) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@tree.interaction_check
|
||||||
async def _log_command(interaction: discord.Interaction) -> bool:
|
async def _log_command(interaction: discord.Interaction) -> bool:
|
||||||
"""Log every slash command invocation and enforce allowed-channel restriction."""
|
"""Log every slash command invocation and enforce allowed-channel restriction."""
|
||||||
if interaction.command:
|
if interaction.command:
|
||||||
@@ -286,11 +347,6 @@ async def _log_command(interaction: discord.Interaction) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# CommandTree.interaction_check is a method meant to be overridden, not a
|
|
||||||
# decorator - `@tree.interaction_check` silently registers nothing.
|
|
||||||
tree.interaction_check = _log_command
|
|
||||||
|
|
||||||
|
|
||||||
def _load_bday_log() -> dict:
|
def _load_bday_log() -> dict:
|
||||||
try:
|
try:
|
||||||
return json.loads(_BDAY_LOG.read_text(encoding="utf-8"))
|
return json.loads(_BDAY_LOG.read_text(encoding="utf-8"))
|
||||||
@@ -341,8 +397,6 @@ async def before_birthday_daily():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_presence_index = 0
|
_presence_index = 0
|
||||||
_economy_count: int = 0
|
_economy_count: int = 0
|
||||||
_economy_count_fetched: float = 0.0
|
|
||||||
_ECONOMY_COUNT_TTL = 300 # seconds; the player count changes rarely
|
|
||||||
_PRESENCES: list = [
|
_PRESENCES: list = [
|
||||||
lambda g: discord.Activity(
|
lambda g: discord.Activity(
|
||||||
type=discord.ActivityType.watching,
|
type=discord.ActivityType.watching,
|
||||||
@@ -364,20 +418,14 @@ _PRESENCES: list = [
|
|||||||
|
|
||||||
@tasks.loop(seconds=20)
|
@tasks.loop(seconds=20)
|
||||||
async def _rotate_presence() -> None:
|
async def _rotate_presence() -> None:
|
||||||
global _presence_index, _economy_count, _economy_count_fetched
|
global _presence_index, _economy_count
|
||||||
guild = bot.get_guild(config.GUILD_ID)
|
guild = bot.get_guild(config.GUILD_ID)
|
||||||
if time.monotonic() - _economy_count_fetched > _ECONOMY_COUNT_TTL:
|
|
||||||
try:
|
|
||||||
_economy_count = await pb_client.count_records()
|
|
||||||
_economy_count_fetched = time.monotonic()
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("Presence: failed to fetch economy count: %s", e)
|
|
||||||
activity = _PRESENCES[_presence_index % len(_PRESENCES)](guild)
|
|
||||||
try:
|
try:
|
||||||
await bot.change_presence(status=discord.Status.online, activity=activity)
|
_economy_count = await pb_client.count_records()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# gateway may be mid-reconnect; skip this rotation instead of tracebacking
|
log.warning("Presence: failed to fetch economy count: %s", e)
|
||||||
log.warning("Presence update skipped: %s", e)
|
activity = _PRESENCES[_presence_index % len(_PRESENCES)](guild)
|
||||||
|
await bot.change_presence(status=discord.Status.online, activity=activity)
|
||||||
_presence_index += 1
|
_presence_index += 1
|
||||||
|
|
||||||
|
|
||||||
@@ -394,32 +442,23 @@ async def on_ready():
|
|||||||
"""Load sheet data and sync slash commands on startup."""
|
"""Load sheet data and sync slash commands on startup."""
|
||||||
log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id)
|
log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id)
|
||||||
economy.set_house(bot.user.id)
|
economy.set_house(bot.user.id)
|
||||||
_log_config_gaps()
|
|
||||||
|
|
||||||
# PocketBase silently drops writes to fields missing from the collection
|
|
||||||
# schema, so surface any drift loudly instead of letting features no-op.
|
|
||||||
try:
|
|
||||||
missing = await economy.missing_schema_fields()
|
|
||||||
if missing:
|
|
||||||
log.error(
|
|
||||||
"PocketBase collection '%s' is missing %d schema field(s): %s "
|
|
||||||
"- writes to them are silently dropped! Run scripts/sync_pb_schema.py.",
|
|
||||||
config.PB_ECONOMY_COLLECTION, len(missing), ", ".join(missing),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.info("PocketBase schema check passed (%s)", config.PB_ECONOMY_COLLECTION)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("Could not verify PocketBase schema: %s", e)
|
|
||||||
|
|
||||||
_apply_profile_command_filters()
|
_apply_profile_command_filters()
|
||||||
|
|
||||||
# Pull sheet data into cache
|
# Pull sheet data into cache
|
||||||
if IS_DEV_PROFILE:
|
if IS_DEV_PROFILE:
|
||||||
try:
|
try:
|
||||||
data = await sheets.refresh()
|
data = sheets.refresh()
|
||||||
log.info("Loaded %d member rows from Google Sheets", len(data))
|
log.info("Loaded %d member rows from Google Sheets", len(data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Failed to load sheet on startup: %s", e)
|
log.error("Failed to load sheet on startup: %s", e)
|
||||||
|
elif IS_LAN_PROFILE:
|
||||||
|
try:
|
||||||
|
created = await lan_fienta.ensure_storage()
|
||||||
|
if created:
|
||||||
|
log.info("Created LAN Fienta PocketBase collection '%s'", config.PB_FIENTA_COLLECTION_LAN)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Failed to prepare LAN Fienta storage: %s", e)
|
||||||
|
|
||||||
# Sync slash commands to the guild only; wipe any leftover global registrations
|
# Sync slash commands to the guild only; wipe any leftover global registrations
|
||||||
tree.copy_global_to(guild=GUILD_OBJ)
|
tree.copy_global_to(guild=GUILD_OBJ)
|
||||||
@@ -438,6 +477,10 @@ async def on_ready():
|
|||||||
_rotate_presence.start()
|
_rotate_presence.start()
|
||||||
log.info("Rich presence rotation started")
|
log.info("Rich presence rotation started")
|
||||||
|
|
||||||
|
# Start Fienta webhook for LAN registration sync
|
||||||
|
if IS_LAN_PROFILE:
|
||||||
|
await _start_fienta_webhook()
|
||||||
|
|
||||||
# Re-schedule any reminder tasks lost on restart
|
# Re-schedule any reminder tasks lost on restart
|
||||||
await _restore_reminders()
|
await _restore_reminders()
|
||||||
|
|
||||||
@@ -467,6 +510,11 @@ async def on_resumed():
|
|||||||
@bot.event
|
@bot.event
|
||||||
async def on_member_join(member: discord.Member):
|
async def on_member_join(member: discord.Member):
|
||||||
"""When someone joins, look them up in the sheet and sync."""
|
"""When someone joins, look them up in the sheet and sync."""
|
||||||
|
if IS_LAN_PROFILE:
|
||||||
|
summary = await lan_fienta.sync_member_join(bot, member)
|
||||||
|
if summary.roles_synced or summary.alerts:
|
||||||
|
log.info("LAN join Fienta sync for %s: %s", member, summary.short())
|
||||||
|
return
|
||||||
if not IS_DEV_PROFILE:
|
if not IS_DEV_PROFILE:
|
||||||
return
|
return
|
||||||
await handle_member_join(
|
await handle_member_join(
|
||||||
@@ -491,6 +539,9 @@ if IS_DEV_PROFILE:
|
|||||||
mark_announced_today=_mark_announced_today,
|
mark_announced_today=_mark_announced_today,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if IS_LAN_PROFILE:
|
||||||
|
register_lan_fienta_commands(tree, bot, log)
|
||||||
|
|
||||||
register_ops_admin_commands(
|
register_ops_admin_commands(
|
||||||
tree,
|
tree,
|
||||||
bot,
|
bot,
|
||||||
@@ -514,8 +565,6 @@ register_ops_channel_commands(
|
|||||||
set_allowed_channels=_set_allowed_channels,
|
set_allowed_channels=_set_allowed_channels,
|
||||||
)
|
)
|
||||||
|
|
||||||
register_info_commands(tree, bot, log)
|
|
||||||
|
|
||||||
|
|
||||||
@tree.command(name="ping", description=S.CMD["ping"])
|
@tree.command(name="ping", description=S.CMD["ping"])
|
||||||
async def cmd_ping(interaction: discord.Interaction):
|
async def cmd_ping(interaction: discord.Interaction):
|
||||||
@@ -527,6 +576,7 @@ async def cmd_ping(interaction: discord.Interaction):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_HELP_PAGE_SIZE = 10
|
_HELP_PAGE_SIZE = 10
|
||||||
_DEV_ONLY_HELP_TOKENS: tuple[str, ...] = tuple(f"/{name}" for name in _DEV_ONLY_COMMANDS)
|
_DEV_ONLY_HELP_TOKENS: tuple[str, ...] = tuple(f"/{name}" for name in _DEV_ONLY_COMMANDS)
|
||||||
|
_LAN_ONLY_HELP_TOKENS: tuple[str, ...] = tuple(f"/{name}" for name in _LAN_ONLY_COMMANDS)
|
||||||
|
|
||||||
|
|
||||||
def _visible_help_fields(category_key: str) -> list[tuple[str, str]]:
|
def _visible_help_fields(category_key: str) -> list[tuple[str, str]]:
|
||||||
@@ -539,6 +589,8 @@ def _visible_help_fields(category_key: str) -> list[tuple[str, str]]:
|
|||||||
blob = f"{name}\n{value}".lower()
|
blob = f"{name}\n{value}".lower()
|
||||||
if any(tok in blob for tok in _DEV_ONLY_HELP_TOKENS):
|
if any(tok in blob for tok in _DEV_ONLY_HELP_TOKENS):
|
||||||
continue
|
continue
|
||||||
|
if not IS_LAN_PROFILE and any(tok in blob for tok in _LAN_ONLY_HELP_TOKENS):
|
||||||
|
continue
|
||||||
visible.append((name, value))
|
visible.append((name, value))
|
||||||
return visible
|
return visible
|
||||||
|
|
||||||
@@ -623,7 +675,8 @@ class HelpSelect(discord.ui.Select):
|
|||||||
|
|
||||||
@tree.command(name="help", description=S.CMD["help"])
|
@tree.command(name="help", description=S.CMD["help"])
|
||||||
async def cmd_help(interaction: discord.Interaction):
|
async def cmd_help(interaction: discord.Interaction):
|
||||||
is_admin = is_bot_admin(interaction.user)
|
perms = interaction.user.guild_permissions if interaction.guild else None
|
||||||
|
is_admin = bool(perms and (perms.manage_roles or perms.manage_guild))
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
embed=_help_embed("üldine"), view=HelpView(is_admin), ephemeral=True
|
embed=_help_embed("üldine"), view=HelpView(is_admin), ephemeral=True
|
||||||
)
|
)
|
||||||
@@ -844,13 +897,6 @@ register_economy_fish_commands(
|
|||||||
active_games=_active_games,
|
active_games=_active_games,
|
||||||
)
|
)
|
||||||
|
|
||||||
register_economy_quests_commands(
|
|
||||||
tree,
|
|
||||||
bot,
|
|
||||||
coin=_coin,
|
|
||||||
award_exp=_award_exp,
|
|
||||||
)
|
|
||||||
|
|
||||||
register_economy_games_commands(
|
register_economy_games_commands(
|
||||||
tree,
|
tree,
|
||||||
coin=_coin,
|
coin=_coin,
|
||||||
@@ -895,25 +941,6 @@ async def on_app_command_error(interaction: discord.Interaction, error: app_comm
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def _log_config_gaps() -> None:
|
|
||||||
"""One startup line per unset optional config, so 'why doesn't X work on
|
|
||||||
prod' starts with a log grep instead of guesswork."""
|
|
||||||
gaps: list[str] = []
|
|
||||||
if IS_DEV_PROFILE:
|
|
||||||
if not config.SHEET_ID:
|
|
||||||
gaps.append("SHEET_ID not set - member sync/birthdays have no sheet")
|
|
||||||
if not config.BIRTHDAY_CHANNEL_ID:
|
|
||||||
gaps.append("BIRTHDAY_CHANNEL_ID not set - birthday pings disabled")
|
|
||||||
if not config.PB_ADMIN_EMAIL or not config.PB_ADMIN_PASSWORD:
|
|
||||||
gaps.append("PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD not set - economy database writes will fail")
|
|
||||||
if not config.BOT_ADMIN_ROLES:
|
|
||||||
gaps.append("DISCORD_ADMIN_ROLES not set - role-based bot-admin checks disabled")
|
|
||||||
for gap in gaps:
|
|
||||||
log.warning("Config: %s", gap)
|
|
||||||
if not gaps:
|
|
||||||
log.info("Config: all expected settings present for profile '%s'", config.BOT_PROFILE)
|
|
||||||
|
|
||||||
|
|
||||||
def _log_sync_result(member: discord.Member, result: SyncResult):
|
def _log_sync_result(member: discord.Member, result: SyncResult):
|
||||||
if result.nickname_changed:
|
if result.nickname_changed:
|
||||||
log.info(" → Nickname set for %s", member)
|
log.info(" → Nickname set for %s", member)
|
||||||
@@ -939,7 +966,11 @@ def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if not config.DISCORD_TOKEN:
|
if not config.DISCORD_TOKEN:
|
||||||
profile_key = "DISCORD_TOKEN_ECONOMY" if config.BOT_PROFILE == "economy" else "DISCORD_TOKEN_DEV"
|
profile_key = {
|
||||||
|
"dev": "DISCORD_TOKEN_DEV",
|
||||||
|
"economy": "DISCORD_TOKEN_ECONOMY",
|
||||||
|
"lan": "DISCORD_BOT_LAN",
|
||||||
|
}[config.BOT_PROFILE]
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"{profile_key} pole seadistatud profiilile '{config.BOT_PROFILE}'. "
|
f"{profile_key} pole seadistatud profiilile '{config.BOT_PROFILE}'. "
|
||||||
"Kopeeri .env.example failiks .env ja täida see."
|
"Kopeeri .env.example failiks .env ja täida see."
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import discord
|
|||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
|
||||||
from core import sheets
|
from core import sheets
|
||||||
from core.admin import bot_admin_check
|
|
||||||
import strings as S
|
import strings as S
|
||||||
from core.member_sync import announce_birthday, sync_member, today_local
|
from core.member_sync import announce_birthday, sync_member
|
||||||
|
|
||||||
|
|
||||||
class BirthdayPages(discord.ui.View):
|
class BirthdayPages(discord.ui.View):
|
||||||
@@ -45,7 +44,7 @@ def _build_birthday_pages(
|
|||||||
Returns (pages, start_index) where start_index is the current month.
|
Returns (pages, start_index) where start_index is the current month.
|
||||||
"""
|
"""
|
||||||
rows = sheets.get_cache()
|
rows = sheets.get_cache()
|
||||||
today = today_local()
|
today = datetime.date.today()
|
||||||
|
|
||||||
by_month: dict[int, list[tuple[int, str, int | None]]] = {m: [] for m in range(1, 13)}
|
by_month: dict[int, list[tuple[int, str, int | None]]] = {m: [] for m in range(1, 13)}
|
||||||
|
|
||||||
@@ -158,7 +157,7 @@ def register_dev_member_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await sheets.refresh()
|
sheets.refresh()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
||||||
return
|
return
|
||||||
@@ -168,7 +167,7 @@ def register_dev_member_commands(
|
|||||||
|
|
||||||
@tree.command(name="check", description=S.CMD["check"])
|
@tree.command(name="check", description=S.CMD["check"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_roles=True)
|
||||||
async def cmd_check(interaction: discord.Interaction):
|
async def cmd_check(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
@@ -178,7 +177,7 @@ def register_dev_member_commands(
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await sheets.refresh()
|
data = sheets.refresh()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
||||||
return
|
return
|
||||||
@@ -196,7 +195,7 @@ def register_dev_member_commands(
|
|||||||
guild.members,
|
guild.members,
|
||||||
)
|
)
|
||||||
if guild_member:
|
if guild_member:
|
||||||
await sheets.set_user_id(discord_name, guild_member.id)
|
sheets.set_user_id(discord_name, guild_member.id)
|
||||||
ids_filled += 1
|
ids_filled += 1
|
||||||
|
|
||||||
data = sheets.get_cache()
|
data = sheets.get_cache()
|
||||||
@@ -237,14 +236,14 @@ def register_dev_member_commands(
|
|||||||
else:
|
else:
|
||||||
already_ok += 1
|
already_ok += 1
|
||||||
|
|
||||||
if result.birthday_today and not has_announced_today(member.id):
|
if result.birthday_soon and not has_announced_today(member.id):
|
||||||
birthday_pings += 1
|
birthday_pings += 1
|
||||||
await announce_birthday(member, bot)
|
await announce_birthday(member, bot)
|
||||||
mark_announced_today(member.id)
|
mark_announced_today(member.id)
|
||||||
|
|
||||||
if sync_updates:
|
if sync_updates:
|
||||||
try:
|
try:
|
||||||
await sheets.batch_set_synced(sync_updates)
|
sheets.batch_set_synced(sync_updates)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("/check batch_set_synced failed: %s", e)
|
log.error("/check batch_set_synced failed: %s", e)
|
||||||
|
|
||||||
@@ -282,7 +281,7 @@ def register_dev_member_commands(
|
|||||||
|
|
||||||
@tree.command(name="member", description=S.CMD["member"])
|
@tree.command(name="member", description=S.CMD["member"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_roles=True)
|
||||||
async def cmd_member(interaction: discord.Interaction, user: discord.Member):
|
async def cmd_member(interaction: discord.Interaction, user: discord.Member):
|
||||||
row = sheets.find_member(user.id, user.name)
|
row = sheets.find_member(user.id, user.name)
|
||||||
if row is None:
|
if row is None:
|
||||||
@@ -299,8 +298,8 @@ def register_dev_member_commands(
|
|||||||
for fmt in ["%d/%m/%Y", "%Y-%m-%d"]:
|
for fmt in ["%d/%m/%Y", "%Y-%m-%d"]:
|
||||||
try:
|
try:
|
||||||
bday = datetime.datetime.strptime(bday_str, fmt).date()
|
bday = datetime.datetime.strptime(bday_str, fmt).date()
|
||||||
if 1920 <= bday.year <= today_local().year:
|
if 1920 <= bday.year <= datetime.date.today().year:
|
||||||
today = today_local()
|
today = datetime.date.today()
|
||||||
age = today.year - bday.year - ((today.month, today.day) < (bday.month, bday.day))
|
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)
|
embed.add_field(name=S.MEMBER_UI["age_field"], value=S.MEMBER_UI["age_val"].format(age=age), inline=True)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async def run_birthday_daily(
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await sheets.refresh()
|
data = sheets.refresh()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Birthday task: sheet refresh failed: %s", e)
|
log.error("Birthday task: sheet refresh failed: %s", e)
|
||||||
data = sheets.get_cache()
|
data = sheets.get_cache()
|
||||||
@@ -68,13 +68,13 @@ async def handle_member_join(
|
|||||||
log.info("Member joined: %s (ID: %s)", member, member.id)
|
log.info("Member joined: %s (ID: %s)", member, member.id)
|
||||||
|
|
||||||
if not sheets.get_cache():
|
if not sheets.get_cache():
|
||||||
await sheets.refresh()
|
sheets.refresh()
|
||||||
|
|
||||||
result = await sync_member(member, member.guild)
|
result = await sync_member(member, member.guild)
|
||||||
|
|
||||||
if result.not_found:
|
if result.not_found:
|
||||||
try:
|
try:
|
||||||
await sheets.add_new_member_row(member.name, member.id)
|
sheets.add_new_member_row(member.name, member.id)
|
||||||
log.info(
|
log.info(
|
||||||
" → %s not in sheet, added new row (Discord=%s, ID=%s)",
|
" → %s not in sheet, added new row (Discord=%s, ID=%s)",
|
||||||
member,
|
member,
|
||||||
@@ -86,8 +86,8 @@ async def handle_member_join(
|
|||||||
return
|
return
|
||||||
|
|
||||||
log_sync_result(member, result)
|
log_sync_result(member, result)
|
||||||
await sheets.set_synced(member.id, result.synced)
|
sheets.set_synced(member.id, result.synced)
|
||||||
|
|
||||||
if result.birthday_today and not has_announced_today(member.id):
|
if result.birthday_soon and not has_announced_today(member.id):
|
||||||
await announce_birthday(member, bot)
|
await announce_birthday(member, bot)
|
||||||
mark_announced_today(member.id)
|
mark_announced_today(member.id)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import discord
|
|||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
|
||||||
from core import economy
|
from core import economy
|
||||||
from core.admin import bot_admin_check
|
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ def register_economy_admin_commands(
|
|||||||
) -> None:
|
) -> None:
|
||||||
@tree.command(name="adminseason", description=S.CMD["adminseason"])
|
@tree.command(name="adminseason", description=S.CMD["adminseason"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
@app_commands.describe(top_n=S.OPT["adminseason_top_n"])
|
@app_commands.describe(top_n=S.OPT["adminseason_top_n"])
|
||||||
async def cmd_adminseason(interaction: discord.Interaction, top_n: int = 10):
|
async def cmd_adminseason(interaction: discord.Interaction, top_n: int = 10):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
@@ -74,7 +73,7 @@ def register_economy_admin_commands(
|
|||||||
kogus=S.OPT["admincoins_kogus"],
|
kogus=S.OPT["admincoins_kogus"],
|
||||||
põhjus=S.OPT["admin_põhjus"],
|
põhjus=S.OPT["admin_põhjus"],
|
||||||
)
|
)
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_admincoins(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str):
|
async def cmd_admincoins(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str):
|
||||||
if kogus == 0:
|
if kogus == 0:
|
||||||
await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True)
|
||||||
@@ -113,7 +112,7 @@ def register_economy_admin_commands(
|
|||||||
minutid=S.OPT["adminjail_minutid"],
|
minutid=S.OPT["adminjail_minutid"],
|
||||||
põhjus=S.OPT["admin_põhjus"],
|
põhjus=S.OPT["admin_põhjus"],
|
||||||
)
|
)
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_adminjail(interaction: discord.Interaction, kasutaja: discord.Member, minutid: int, põhjus: str):
|
async def cmd_adminjail(interaction: discord.Interaction, kasutaja: discord.Member, minutid: int, põhjus: str):
|
||||||
if minutid <= 0:
|
if minutid <= 0:
|
||||||
await interaction.response.send_message(S.ERR["positive_duration"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["positive_duration"], ephemeral=True)
|
||||||
@@ -134,7 +133,7 @@ def register_economy_admin_commands(
|
|||||||
@tree.command(name="adminunjail", description=S.CMD["adminunjail"])
|
@tree.command(name="adminunjail", description=S.CMD["adminunjail"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"])
|
@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"])
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_adminunjail(interaction: discord.Interaction, kasutaja: discord.Member):
|
async def cmd_adminunjail(interaction: discord.Interaction, kasutaja: discord.Member):
|
||||||
await economy.do_admin_unjail(kasutaja.id, interaction.user.id)
|
await economy.do_admin_unjail(kasutaja.id, interaction.user.id)
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
@@ -149,7 +148,7 @@ def register_economy_admin_commands(
|
|||||||
kasutaja=S.OPT["admin_kasutaja"],
|
kasutaja=S.OPT["admin_kasutaja"],
|
||||||
põhjus=S.OPT["admin_põhjus"],
|
põhjus=S.OPT["admin_põhjus"],
|
||||||
)
|
)
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_adminban(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str):
|
async def cmd_adminban(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str):
|
||||||
if bot.user and kasutaja.id == bot.user.id:
|
if bot.user and kasutaja.id == bot.user.id:
|
||||||
await interaction.response.send_message(S.ERR["admin_ban_bot"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["admin_ban_bot"], ephemeral=True)
|
||||||
@@ -165,7 +164,7 @@ def register_economy_admin_commands(
|
|||||||
@tree.command(name="adminunban", description=S.CMD["adminunban"])
|
@tree.command(name="adminunban", description=S.CMD["adminunban"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"])
|
@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"])
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_adminunban(interaction: discord.Interaction, kasutaja: discord.Member):
|
async def cmd_adminunban(interaction: discord.Interaction, kasutaja: discord.Member):
|
||||||
await economy.do_admin_unban(kasutaja.id, interaction.user.id)
|
await economy.do_admin_unban(kasutaja.id, interaction.user.id)
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
@@ -180,7 +179,7 @@ def register_economy_admin_commands(
|
|||||||
kasutaja=S.OPT["admin_kasutaja"],
|
kasutaja=S.OPT["admin_kasutaja"],
|
||||||
põhjus=S.OPT["admin_põhjus"],
|
põhjus=S.OPT["admin_põhjus"],
|
||||||
)
|
)
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_adminreset(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str):
|
async def cmd_adminreset(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str):
|
||||||
if bot.user and kasutaja.id == bot.user.id:
|
if bot.user and kasutaja.id == bot.user.id:
|
||||||
await interaction.response.send_message(S.ERR["admin_reset_bot"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["admin_reset_bot"], ephemeral=True)
|
||||||
@@ -196,7 +195,7 @@ def register_economy_admin_commands(
|
|||||||
@tree.command(name="adminview", description=S.CMD["adminview"])
|
@tree.command(name="adminview", description=S.CMD["adminview"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"])
|
@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"])
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_adminview(interaction: discord.Interaction, kasutaja: discord.Member):
|
async def cmd_adminview(interaction: discord.Interaction, kasutaja: discord.Member):
|
||||||
res = await economy.do_admin_inspect(kasutaja.id)
|
res = await economy.do_admin_inspect(kasutaja.id)
|
||||||
data = res["data"]
|
data = res["data"]
|
||||||
@@ -239,7 +238,7 @@ def register_economy_admin_commands(
|
|||||||
kogus=S.OPT["adminexp_kogus"],
|
kogus=S.OPT["adminexp_kogus"],
|
||||||
põhjus=S.OPT["admin_põhjus"],
|
põhjus=S.OPT["admin_põhjus"],
|
||||||
)
|
)
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_adminexp(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str):
|
async def cmd_adminexp(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str):
|
||||||
if kogus == 0:
|
if kogus == 0:
|
||||||
await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True)
|
||||||
@@ -282,7 +281,7 @@ def register_economy_admin_commands(
|
|||||||
ese=S.OPT["adminitem_ese"],
|
ese=S.OPT["adminitem_ese"],
|
||||||
tegevus=S.OPT["adminitem_tegevus"],
|
tegevus=S.OPT["adminitem_tegevus"],
|
||||||
)
|
)
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_adminitem(interaction: discord.Interaction, kasutaja: discord.Member, ese: str, tegevus: str):
|
async def cmd_adminitem(interaction: discord.Interaction, kasutaja: discord.Member, ese: str, tegevus: str):
|
||||||
action = tegevus.strip().lower()
|
action = tegevus.strip().lower()
|
||||||
if action not in ("anna", "eemalda"):
|
if action not in ("anna", "eemalda"):
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import discord
|
|||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
|
||||||
from core import economy
|
from core import economy
|
||||||
from core.emoji import EMOJI as E
|
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
@@ -289,12 +288,12 @@ def register_economy_extra_commands(
|
|||||||
# /jailbreak - Monopoly-style dice escape
|
# /jailbreak - Monopoly-style dice escape
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
_DICE_EMOJI = [
|
_DICE_EMOJI = [
|
||||||
E["TipiYKS"],
|
"<:TipiYKS:1483103190491856916>",
|
||||||
E["TipiKAKS"],
|
"<:TipiKAKS:1483103215841972404>",
|
||||||
E["TipiKOLM"],
|
"<:TipiKOLM:1483103217846980781>",
|
||||||
E["TipiNELI"],
|
"<:TipiNELI:1483103237585240114>",
|
||||||
E["TipiVIIS"],
|
"<:TipiVIIS:1483103239036469289>",
|
||||||
E["TipiKUUS"],
|
"<:TipiKUUS:1483103253163020348>",
|
||||||
]
|
]
|
||||||
|
|
||||||
class JailbreakView(discord.ui.View):
|
class JailbreakView(discord.ui.View):
|
||||||
@@ -814,6 +813,7 @@ def register_economy_extra_commands(
|
|||||||
btn = discord.ui.Button(
|
btn = discord.ui.Button(
|
||||||
label=label,
|
label=label,
|
||||||
style=discord.ButtonStyle.primary if t == self._tier else discord.ButtonStyle.secondary,
|
style=discord.ButtonStyle.primary if t == self._tier else discord.ButtonStyle.secondary,
|
||||||
|
custom_id=f"shop_tier_{t}",
|
||||||
)
|
)
|
||||||
btn.callback = self._make_callback(t)
|
btn.callback = self._make_callback(t)
|
||||||
self.add_item(btn)
|
self.add_item(btn)
|
||||||
@@ -822,9 +822,8 @@ def register_economy_extra_commands(
|
|||||||
async def callback(interaction: discord.Interaction):
|
async def callback(interaction: discord.Interaction):
|
||||||
self._tier = tier
|
self._tier = tier
|
||||||
self._update_buttons()
|
self._update_buttons()
|
||||||
await interaction.response.defer()
|
|
||||||
self._user_data = await economy.get_user(interaction.user.id)
|
self._user_data = await economy.get_user(interaction.user.id)
|
||||||
await interaction.edit_original_response(
|
await interaction.response.edit_message(
|
||||||
embed=_shop_embed(self._tier, self._user_data),
|
embed=_shop_embed(self._tier, self._user_data),
|
||||||
view=self,
|
view=self,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import discord
|
|||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
|
||||||
from core import economy
|
from core import economy
|
||||||
from core.emoji import EMOJI as E
|
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
@@ -277,16 +276,18 @@ def register_economy_games_commands(
|
|||||||
bet_line_a = bet_line_b = ""
|
bet_line_a = bet_line_b = ""
|
||||||
if self.bet > 0:
|
if self.bet > 0:
|
||||||
if winner == "a":
|
if winner == "a":
|
||||||
await economy.do_rps_pvp_payout(self.player_a.id, self.bet)
|
res = await economy.do_give(self.player_b.id, self.player_a.id, self.bet)
|
||||||
bet_line_a = f"\n+{coin(self.bet)}"
|
|
||||||
bet_line_b = f"\n-{coin(self.bet)}"
|
|
||||||
elif winner == "b":
|
elif winner == "b":
|
||||||
await economy.do_rps_pvp_payout(self.player_b.id, self.bet)
|
res = await economy.do_give(self.player_a.id, self.player_b.id, self.bet)
|
||||||
bet_line_a = f"\n-{coin(self.bet)}"
|
|
||||||
bet_line_b = f"\n+{coin(self.bet)}"
|
|
||||||
else:
|
else:
|
||||||
await economy.do_rps_pvp_refund(self.player_a.id, self.bet)
|
res = {"ok": True}
|
||||||
await economy.do_rps_pvp_refund(self.player_b.id, self.bet)
|
|
||||||
|
if self.bet > 0 and winner is not None:
|
||||||
|
if res.get("ok"):
|
||||||
|
bet_line_a = f"\n{'+' if winner == 'a' else '-'}{coin(self.bet)}"
|
||||||
|
bet_line_b = f"\n{'+' if winner == 'b' else '-'}{coin(self.bet)}"
|
||||||
|
else:
|
||||||
|
bet_line_a = bet_line_b = S.RPS_UI["duel_broke"]
|
||||||
|
|
||||||
data_a = await economy.get_user(self.player_a.id)
|
data_a = await economy.get_user(self.player_a.id)
|
||||||
data_b = await economy.get_user(self.player_b.id)
|
data_b = await economy.get_user(self.player_b.id)
|
||||||
@@ -374,9 +375,6 @@ def register_economy_games_commands(
|
|||||||
if self.game._resolved:
|
if self.game._resolved:
|
||||||
return
|
return
|
||||||
self.game._resolved = True
|
self.game._resolved = True
|
||||||
if self.game.bet > 0:
|
|
||||||
await economy.do_rps_pvp_refund(self.game.player_a.id, self.game.bet)
|
|
||||||
await economy.do_rps_pvp_refund(self.game.player_b.id, self.game.bet)
|
|
||||||
active_games.discard(self.game.player_a.id)
|
active_games.discard(self.game.player_a.id)
|
||||||
active_games.discard(self.game.player_b.id)
|
active_games.discard(self.game.player_b.id)
|
||||||
for item in self.children:
|
for item in self.children:
|
||||||
@@ -433,33 +431,21 @@ def register_economy_games_commands(
|
|||||||
active_games.add(self.game.player_b.id)
|
active_games.add(self.game.player_b.id)
|
||||||
|
|
||||||
if self.game.bet > 0:
|
if self.game.bet > 0:
|
||||||
deposit_a = await economy.do_rps_pvp_deposit(self.game.player_a.id, self.game.bet)
|
data_a = await economy.get_user(self.game.player_a.id)
|
||||||
if not deposit_a.get("ok"):
|
data_b = await economy.get_user(self.game.player_b.id)
|
||||||
embed = discord.Embed(
|
for player, data in ((self.game.player_a, data_a), (self.game.player_b, data_b)):
|
||||||
title=S.TITLE["rps_duel_cancel"],
|
if data["balance"] < self.game.bet:
|
||||||
description=S.RPS_UI["duel_insufficient"].format(mention=self.game.player_a.mention),
|
embed = discord.Embed(
|
||||||
color=0xED4245,
|
title=S.TITLE["rps_duel_cancel"],
|
||||||
)
|
description=S.RPS_UI["duel_insufficient"].format(mention=player.mention),
|
||||||
await interaction.response.edit_message(embed=embed, view=None)
|
color=0xED4245,
|
||||||
async with self.game._lock:
|
)
|
||||||
self.game._resolved = True
|
await interaction.response.edit_message(embed=embed, view=None)
|
||||||
active_games.discard(self.game.player_a.id)
|
async with self.game._lock:
|
||||||
active_games.discard(self.game.player_b.id)
|
self.game._resolved = True
|
||||||
return
|
active_games.discard(self.game.player_a.id)
|
||||||
deposit_b = await economy.do_rps_pvp_deposit(self.game.player_b.id, self.game.bet)
|
active_games.discard(self.game.player_b.id)
|
||||||
if not deposit_b.get("ok"):
|
return
|
||||||
await economy.do_rps_pvp_refund(self.game.player_a.id, self.game.bet)
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=S.TITLE["rps_duel_cancel"],
|
|
||||||
description=S.RPS_UI["duel_insufficient"].format(mention=self.game.player_b.mention),
|
|
||||||
color=0xED4245,
|
|
||||||
)
|
|
||||||
await interaction.response.edit_message(embed=embed, view=None)
|
|
||||||
async with self.game._lock:
|
|
||||||
self.game._resolved = True
|
|
||||||
active_games.discard(self.game.player_a.id)
|
|
||||||
active_games.discard(self.game.player_b.id)
|
|
||||||
return
|
|
||||||
|
|
||||||
bet_str = S.RPS_UI["duel_active_bet"].format(bet=coin(self.game.bet)) if self.game.bet > 0 else ""
|
bet_str = S.RPS_UI["duel_active_bet"].format(bet=coin(self.game.bet)) if self.game.bet > 0 else ""
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
@@ -493,9 +479,6 @@ def register_economy_games_commands(
|
|||||||
if dm_failed:
|
if dm_failed:
|
||||||
async with self.game._lock:
|
async with self.game._lock:
|
||||||
self.game._resolved = True
|
self.game._resolved = True
|
||||||
if self.game.bet > 0:
|
|
||||||
await economy.do_rps_pvp_refund(self.game.player_a.id, self.game.bet)
|
|
||||||
await economy.do_rps_pvp_refund(self.game.player_b.id, self.game.bet)
|
|
||||||
active_games.discard(self.game.player_a.id)
|
active_games.discard(self.game.player_a.id)
|
||||||
active_games.discard(self.game.player_b.id)
|
active_games.discard(self.game.player_b.id)
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
@@ -612,7 +595,7 @@ def register_economy_games_commands(
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# /slots
|
# /slots
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
_SLOTS_SPIN = E["TipiSLOTS"]
|
_SLOTS_SPIN = "<a:TipiSLOTS:1483444233863037101>"
|
||||||
_SLOTS_DELAY = 0.7
|
_SLOTS_DELAY = 0.7
|
||||||
|
|
||||||
def _slots_embed(
|
def _slots_embed(
|
||||||
|
|||||||
@@ -22,13 +22,12 @@ def register_economy_income_commands(
|
|||||||
) -> None:
|
) -> None:
|
||||||
@tree.command(name="daily", description=S.CMD["daily"])
|
@tree.command(name="daily", description=S.CMD["daily"])
|
||||||
async def cmd_daily(interaction: discord.Interaction):
|
async def cmd_daily(interaction: discord.Interaction):
|
||||||
await interaction.response.defer()
|
|
||||||
res = await economy.do_daily(interaction.user.id)
|
res = await economy.do_daily(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.CD_MSG["daily"].format(ts=cd_ts(res["remaining"])),
|
S.CD_MSG["daily"].format(ts=cd_ts(res["remaining"])),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
@@ -52,7 +51,7 @@ def register_economy_income_commands(
|
|||||||
lines.append(S.DAILY_UI["footer"].format(streak_str=streak_str, balance=coin(res["balance"])))
|
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)
|
embed = discord.Embed(title=S.TITLE["daily"], description="\n".join(lines), color=0xF4C430)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
asyncio.create_task(maybe_remind(interaction.user.id, "daily"))
|
asyncio.create_task(maybe_remind(interaction.user.id, "daily"))
|
||||||
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["daily"]))
|
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["daily"]))
|
||||||
|
|
||||||
@@ -60,18 +59,17 @@ def register_economy_income_commands(
|
|||||||
async def cmd_work(interaction: discord.Interaction):
|
async def cmd_work(interaction: discord.Interaction):
|
||||||
if await check_cmd_rate(interaction):
|
if await check_cmd_rate(interaction):
|
||||||
return
|
return
|
||||||
await interaction.response.defer()
|
|
||||||
res = await economy.do_work(interaction.user.id)
|
res = await economy.do_work(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.CD_MSG["work"].format(ts=cd_ts(res["remaining"])),
|
S.CD_MSG["work"].format(ts=cd_ts(res["remaining"])),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])),
|
S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
@@ -86,7 +84,7 @@ def register_economy_income_commands(
|
|||||||
desc += S.WORK_UI["laud"]
|
desc += S.WORK_UI["laud"]
|
||||||
desc += S.WORK_UI["balance"].format(balance=coin(res["balance"]))
|
desc += S.WORK_UI["balance"].format(balance=coin(res["balance"]))
|
||||||
embed = discord.Embed(title=S.TITLE["work"], description=desc, color=0x57F287)
|
embed = discord.Embed(title=S.TITLE["work"], description=desc, color=0x57F287)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
asyncio.create_task(maybe_remind(interaction.user.id, "work"))
|
asyncio.create_task(maybe_remind(interaction.user.id, "work"))
|
||||||
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["work"]))
|
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["work"]))
|
||||||
|
|
||||||
@@ -94,13 +92,12 @@ def register_economy_income_commands(
|
|||||||
async def cmd_beg(interaction: discord.Interaction):
|
async def cmd_beg(interaction: discord.Interaction):
|
||||||
if await check_cmd_rate(interaction):
|
if await check_cmd_rate(interaction):
|
||||||
return
|
return
|
||||||
await interaction.response.defer()
|
|
||||||
res = await economy.do_beg(interaction.user.id)
|
res = await economy.do_beg(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.CD_MSG["beg"].format(ts=cd_ts(res["remaining"])),
|
S.CD_MSG["beg"].format(ts=cd_ts(res["remaining"])),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
@@ -117,7 +114,7 @@ def register_economy_income_commands(
|
|||||||
beg_lines.append(S.BEG_UI["klaviatuur"])
|
beg_lines.append(S.BEG_UI["klaviatuur"])
|
||||||
beg_lines.append(S.BEG_UI["balance"].format(balance=coin(res["balance"])))
|
beg_lines.append(S.BEG_UI["balance"].format(balance=coin(res["balance"])))
|
||||||
embed = discord.Embed(title=title, description="\n".join(beg_lines), color=color)
|
embed = discord.Embed(title=title, description="\n".join(beg_lines), color=color)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
asyncio.create_task(maybe_remind(interaction.user.id, "beg"))
|
asyncio.create_task(maybe_remind(interaction.user.id, "beg"))
|
||||||
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["beg"]))
|
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["beg"]))
|
||||||
|
|
||||||
@@ -125,18 +122,17 @@ def register_economy_income_commands(
|
|||||||
async def cmd_crime(interaction: discord.Interaction):
|
async def cmd_crime(interaction: discord.Interaction):
|
||||||
if await check_cmd_rate(interaction):
|
if await check_cmd_rate(interaction):
|
||||||
return
|
return
|
||||||
await interaction.response.defer()
|
|
||||||
res = await economy.do_crime(interaction.user.id)
|
res = await economy.do_crime(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.CD_MSG["crime"].format(ts=cd_ts(res["remaining"])),
|
S.CD_MSG["crime"].format(ts=cd_ts(res["remaining"])),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])),
|
S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
@@ -165,7 +161,7 @@ def register_economy_income_commands(
|
|||||||
+ S.CRIME_UI["balance"].format(balance=coin(res["balance"])),
|
+ S.CRIME_UI["balance"].format(balance=coin(res["balance"])),
|
||||||
color=0xED4245,
|
color=0xED4245,
|
||||||
)
|
)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
asyncio.create_task(maybe_remind(interaction.user.id, "crime"))
|
asyncio.create_task(maybe_remind(interaction.user.id, "crime"))
|
||||||
if res["success"]:
|
if res["success"]:
|
||||||
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["crime_win"]))
|
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["crime_win"]))
|
||||||
@@ -184,28 +180,27 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.send_message(S.ERR["rob_house_blocked"], ephemeral=True)
|
await interaction.response.send_message(S.ERR["rob_house_blocked"], ephemeral=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
await interaction.response.defer()
|
|
||||||
res = await economy.do_rob(interaction.user.id, sihtmärk.id)
|
res = await economy.do_rob(interaction.user.id, sihtmärk.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.CD_MSG["rob"].format(ts=cd_ts(res["remaining"])),
|
S.CD_MSG["rob"].format(ts=cd_ts(res["remaining"])),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])),
|
S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
elif res["reason"] == "broke":
|
elif res["reason"] == "broke":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.ERR["rob_too_poor"].format(name=sihtmärk.display_name),
|
S.ERR["rob_too_poor"].format(name=sihtmärk.display_name),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
elif res["reason"] == "target_jailed":
|
elif res["reason"] == "target_jailed":
|
||||||
await interaction.followup.send(
|
await interaction.response.send_message(
|
||||||
S.ERR["rob_target_jailed"].format(name=sihtmärk.display_name),
|
S.ERR["rob_target_jailed"].format(name=sihtmärk.display_name),
|
||||||
ephemeral=True,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
@@ -247,7 +242,7 @@ def register_economy_income_commands(
|
|||||||
),
|
),
|
||||||
color=0xED4245,
|
color=0xED4245,
|
||||||
)
|
)
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
asyncio.create_task(maybe_remind(interaction.user.id, "rob"))
|
asyncio.create_task(maybe_remind(interaction.user.id, "rob"))
|
||||||
if res["success"]:
|
if res["success"]:
|
||||||
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["rob_win"]))
|
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["rob_win"]))
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
_BAR_SLOTS = 8
|
|
||||||
|
|
||||||
|
|
||||||
def _bar(progress: int, goal: int) -> str:
|
|
||||||
"""Return an 8-slot ▰/▱ progress bar for a quest."""
|
|
||||||
filled = 0 if goal <= 0 else min(_BAR_SLOTS, round(_BAR_SLOTS * progress / goal))
|
|
||||||
return "▰" * filled + "▱" * (_BAR_SLOTS - filled)
|
|
||||||
|
|
||||||
|
|
||||||
def _has_claimable(view_data: dict) -> bool:
|
|
||||||
return any(
|
|
||||||
q["done"] and not q["claimed"]
|
|
||||||
for q in view_data["daily"] + view_data["weekly"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_economy_quests_commands(
|
|
||||||
tree: app_commands.CommandTree,
|
|
||||||
bot: discord.Client,
|
|
||||||
coin: Callable[[int], str],
|
|
||||||
award_exp: Callable[[discord.Interaction, int], Awaitable[None]],
|
|
||||||
) -> None:
|
|
||||||
def _render(view_data: dict) -> discord.Embed:
|
|
||||||
embed = discord.Embed(title=S.TITLE["quests"], color=0xF4C430)
|
|
||||||
sections = (
|
|
||||||
(S.QUEST_UI["daily_header"], view_data["daily"]),
|
|
||||||
(S.QUEST_UI["weekly_header"], view_data["weekly"]),
|
|
||||||
)
|
|
||||||
for header, quests in sections:
|
|
||||||
if quests:
|
|
||||||
blocks = []
|
|
||||||
for q in quests:
|
|
||||||
desc = S.QUEST_DESCRIPTIONS.get(q["id"], q["id"])
|
|
||||||
if q["claimed"]:
|
|
||||||
status = S.QUEST_UI["completed"]
|
|
||||||
elif q["done"]:
|
|
||||||
status = S.QUEST_UI["ready"]
|
|
||||||
else:
|
|
||||||
status = S.QUEST_UI["progress"].format(progress=q["progress"], max=q["goal"])
|
|
||||||
reward = S.QUEST_UI["reward"].format(coins=f"{q['coins']:,}", exp=q["exp"])
|
|
||||||
blocks.append(
|
|
||||||
f"{_bar(q['progress'], q['goal'])} **{desc}**\n{status} · {reward}"
|
|
||||||
)
|
|
||||||
value = "\n\n".join(blocks)
|
|
||||||
else:
|
|
||||||
value = S.QUEST_UI["empty"]
|
|
||||||
embed.add_field(name=header, value=value, inline=False)
|
|
||||||
return embed
|
|
||||||
|
|
||||||
class QuestView(discord.ui.View):
|
|
||||||
def __init__(self, invoker_id: int, view_data: dict):
|
|
||||||
super().__init__(timeout=180)
|
|
||||||
self.invoker_id = invoker_id
|
|
||||||
btn = discord.ui.Button(
|
|
||||||
label=S.QUEST_UI["claim_btn"],
|
|
||||||
style=discord.ButtonStyle.success,
|
|
||||||
disabled=not _has_claimable(view_data),
|
|
||||||
)
|
|
||||||
btn.callback = self._claim
|
|
||||||
self.add_item(btn)
|
|
||||||
|
|
||||||
async def _claim(self, interaction: discord.Interaction):
|
|
||||||
if interaction.user.id != self.invoker_id:
|
|
||||||
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
res = await economy.claim_quests(self.invoker_id)
|
|
||||||
except economy.DatabaseError:
|
|
||||||
await interaction.response.send_message(S.QUEST_UI["error"], ephemeral=True)
|
|
||||||
return
|
|
||||||
if not res["ok"]:
|
|
||||||
await interaction.response.send_message(S.QUEST_UI["nothing"], ephemeral=True)
|
|
||||||
return
|
|
||||||
new_data = await economy.get_quests(self.invoker_id)
|
|
||||||
await interaction.response.edit_message(
|
|
||||||
embed=_render(new_data), view=QuestView(self.invoker_id, new_data)
|
|
||||||
)
|
|
||||||
await interaction.followup.send(
|
|
||||||
S.QUEST_UI["claimed_msg"].format(
|
|
||||||
count=res["claimed"], coins=coin(res["coins"]), exp=res["exp"]
|
|
||||||
),
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
if res["exp"]:
|
|
||||||
asyncio.create_task(award_exp(interaction, res["exp"]))
|
|
||||||
|
|
||||||
@tree.command(name="quests", description=S.CMD["quests"])
|
|
||||||
async def cmd_quests(interaction: discord.Interaction):
|
|
||||||
await interaction.response.defer()
|
|
||||||
try:
|
|
||||||
data = await economy.get_quests(interaction.user.id)
|
|
||||||
except economy.DatabaseError:
|
|
||||||
await interaction.followup.send(S.QUEST_UI["error"], ephemeral=True)
|
|
||||||
return
|
|
||||||
await interaction.followup.send(embed=_render(data), view=QuestView(interaction.user.id, data))
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
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))
|
|
||||||
28
commands/lan_fienta_commands.py
Normal file
28
commands/lan_fienta_commands.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from core import lan_fienta
|
||||||
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
|
def register_lan_fienta_commands(
|
||||||
|
tree: app_commands.CommandTree,
|
||||||
|
bot: discord.Client,
|
||||||
|
log: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
@tree.command(name="fientasync", description=S.CMD["fientasync"])
|
||||||
|
@app_commands.guild_only()
|
||||||
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
|
async def cmd_fientasync(interaction: discord.Interaction):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
try:
|
||||||
|
summary = await lan_fienta.resync_all(bot)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("/fientasync failed")
|
||||||
|
await interaction.followup.send(f"❌ Fienta sync failed: `{exc}`", ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.followup.send(f"✅ Fienta sync done: `{summary.short()}`", ephemeral=True)
|
||||||
@@ -13,9 +13,7 @@ from pathlib import Path
|
|||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
|
||||||
from core.admin import bot_admin_check
|
|
||||||
import strings as S
|
import strings as S
|
||||||
from core.admin import bot_admin_check
|
|
||||||
|
|
||||||
|
|
||||||
def register_ops_admin_commands(
|
def register_ops_admin_commands(
|
||||||
@@ -34,7 +32,7 @@ def register_ops_admin_commands(
|
|||||||
) -> None:
|
) -> None:
|
||||||
@tree.command(name="status", description=S.CMD["status"])
|
@tree.command(name="status", description=S.CMD["status"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_status(interaction: discord.Interaction):
|
async def cmd_status(interaction: discord.Interaction):
|
||||||
mem = process.memory_info()
|
mem = process.memory_info()
|
||||||
cpu = process.cpu_percent(interval=0.1)
|
cpu = process.cpu_percent(interval=0.1)
|
||||||
@@ -97,7 +95,7 @@ def register_ops_admin_commands(
|
|||||||
|
|
||||||
@tree.command(name="sync", description=S.CMD["sync"])
|
@tree.command(name="sync", description=S.CMD["sync"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_sync(interaction: discord.Interaction):
|
async def cmd_sync(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
tree.copy_global_to(guild=guild_obj)
|
tree.copy_global_to(guild=guild_obj)
|
||||||
@@ -109,7 +107,7 @@ def register_ops_admin_commands(
|
|||||||
|
|
||||||
@tree.command(name="restart", description=S.CMD["restart"])
|
@tree.command(name="restart", description=S.CMD["restart"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_restart(interaction: discord.Interaction):
|
async def cmd_restart(interaction: discord.Interaction):
|
||||||
restart_file.write_text(json.dumps({"channel_id": interaction.channel_id}), encoding="utf-8")
|
restart_file.write_text(json.dumps({"channel_id": interaction.channel_id}), encoding="utf-8")
|
||||||
await interaction.response.send_message(S.MSG_RESTARTING, ephemeral=True)
|
await interaction.response.send_message(S.MSG_RESTARTING, ephemeral=True)
|
||||||
@@ -119,7 +117,7 @@ def register_ops_admin_commands(
|
|||||||
|
|
||||||
@tree.command(name="shutdown", description=S.CMD["shutdown"])
|
@tree.command(name="shutdown", description=S.CMD["shutdown"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_shutdown(interaction: discord.Interaction):
|
async def cmd_shutdown(interaction: discord.Interaction):
|
||||||
await interaction.response.send_message(S.MSG_SHUTTING_DOWN, ephemeral=True)
|
await interaction.response.send_message(S.MSG_SHUTTING_DOWN, ephemeral=True)
|
||||||
log.info("/shutdown triggered by %s", interaction.user)
|
log.info("/shutdown triggered by %s", interaction.user)
|
||||||
@@ -127,7 +125,7 @@ def register_ops_admin_commands(
|
|||||||
|
|
||||||
@tree.command(name="pause", description=S.CMD["pause"])
|
@tree.command(name="pause", description=S.CMD["pause"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@bot_admin_check()
|
@app_commands.default_permissions(manage_guild=True)
|
||||||
async def cmd_pause(interaction: discord.Interaction):
|
async def cmd_pause(interaction: discord.Interaction):
|
||||||
paused = not get_paused()
|
paused = not get_paused()
|
||||||
set_paused(paused)
|
set_paused(paused)
|
||||||
|
|||||||
35
compose.yaml
Normal file
35
compose.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
pocketbase:
|
||||||
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
|
container_name: tipibot-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- pb_data:/pb_data
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8090/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
container_name: tipibot
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PB_URL=http://pocketbase:8090
|
||||||
|
expose:
|
||||||
|
- "8090"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./credentials.json:/app/credentials.json:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
71
config.py
71
config.py
@@ -4,8 +4,8 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev"
|
BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev"
|
||||||
if BOT_PROFILE not in {"dev", "economy"}:
|
if BOT_PROFILE not in {"dev", "economy", "lan"}:
|
||||||
raise SystemExit("BOT_PROFILE must be either 'dev' or 'economy'.")
|
raise SystemExit("BOT_PROFILE must be either 'dev', 'economy', or 'lan'.")
|
||||||
|
|
||||||
|
|
||||||
def _env_int(name: str, default: int) -> int:
|
def _env_int(name: str, default: int) -> int:
|
||||||
@@ -18,17 +18,31 @@ def _env_int(name: str, default: int) -> int:
|
|||||||
_LEGACY_DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
|
_LEGACY_DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
|
||||||
DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "")
|
DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "")
|
||||||
DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "")
|
DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "")
|
||||||
DISCORD_TOKEN = (
|
DISCORD_BOT_LAN = os.getenv("DISCORD_BOT_LAN", "")
|
||||||
DISCORD_TOKEN_ECONOMY if BOT_PROFILE == "economy" else DISCORD_TOKEN_DEV
|
DISCORD_TOKEN = {
|
||||||
) or _LEGACY_DISCORD_TOKEN
|
"dev": DISCORD_TOKEN_DEV,
|
||||||
|
"economy": DISCORD_TOKEN_ECONOMY,
|
||||||
|
"lan": DISCORD_BOT_LAN,
|
||||||
|
}[BOT_PROFILE] or _LEGACY_DISCORD_TOKEN
|
||||||
|
|
||||||
SHEET_ID = os.getenv("SHEET_ID")
|
SHEET_ID_DEV = os.getenv("SHEET_ID_DEV", "").strip()
|
||||||
|
SHEET_ID_LAN = os.getenv("SHEET_ID_LAN", "").strip()
|
||||||
|
SHEET_ID = (
|
||||||
|
SHEET_ID_LAN
|
||||||
|
if BOT_PROFILE == "lan"
|
||||||
|
else SHEET_ID_DEV or os.getenv("SHEET_ID")
|
||||||
|
)
|
||||||
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
|
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
|
||||||
|
|
||||||
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
|
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
|
||||||
GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID)
|
GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID)
|
||||||
GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _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
|
GUILD_ID_LAN = _env_int("GUILD_ID_LAN", 0)
|
||||||
|
GUILD_ID = {
|
||||||
|
"dev": GUILD_ID_DEV,
|
||||||
|
"economy": GUILD_ID_ECONOMY,
|
||||||
|
"lan": GUILD_ID_LAN,
|
||||||
|
}[BOT_PROFILE]
|
||||||
|
|
||||||
_LEGACY_BIRTHDAY_CHANNEL_ID = _env_int("BIRTHDAY_CHANNEL_ID", 0)
|
_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_DEV = _env_int("BIRTHDAY_CHANNEL_ID_DEV", _LEGACY_BIRTHDAY_CHANNEL_ID)
|
||||||
@@ -42,30 +56,6 @@ BIRTHDAY_CHANNEL_ID = (
|
|||||||
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]
|
||||||
|
|
||||||
|
|
||||||
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
|
|
||||||
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
|
|
||||||
|
|
||||||
Multiple admin roles per guild are colon-separated; guild entries are comma-separated.
|
|
||||||
Repeating a guild_id across entries merges its roles.
|
|
||||||
"""
|
|
||||||
result: dict[int, set[int]] = {}
|
|
||||||
for entry in raw.split(","):
|
|
||||||
entry = entry.strip()
|
|
||||||
if not entry:
|
|
||||||
continue
|
|
||||||
parts = entry.split(":")
|
|
||||||
if len(parts) < 2 or not all(p.strip() for p in parts):
|
|
||||||
raise SystemExit(
|
|
||||||
f"DISCORD_ADMIN_ROLES: expected 'guild_id:role_id[:role_id...]', got {entry!r}"
|
|
||||||
)
|
|
||||||
guild_id = int(parts[0].strip())
|
|
||||||
result.setdefault(guild_id, set()).update(int(p.strip()) for p in parts[1:])
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
BOT_ADMIN_ROLES: dict[int, set[int]] = _parse_admin_roles(os.getenv("DISCORD_ADMIN_ROLES", ""))
|
|
||||||
|
|
||||||
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", "")
|
||||||
@@ -79,6 +69,21 @@ PB_ECONOMY_COLLECTION_ECONOMY = (
|
|||||||
os.getenv("PB_ECONOMY_COLLECTION_ECONOMY", "").strip()
|
os.getenv("PB_ECONOMY_COLLECTION_ECONOMY", "").strip()
|
||||||
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod")
|
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod")
|
||||||
)
|
)
|
||||||
PB_ECONOMY_COLLECTION = (
|
PB_ECONOMY_COLLECTION_LAN = (
|
||||||
PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV
|
os.getenv("PB_ECONOMY_COLLECTION_LAN", "").strip()
|
||||||
|
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_lan")
|
||||||
)
|
)
|
||||||
|
PB_ECONOMY_COLLECTION = {
|
||||||
|
"dev": PB_ECONOMY_COLLECTION_DEV,
|
||||||
|
"economy": PB_ECONOMY_COLLECTION_ECONOMY,
|
||||||
|
"lan": PB_ECONOMY_COLLECTION_LAN,
|
||||||
|
}[BOT_PROFILE]
|
||||||
|
|
||||||
|
PB_FIENTA_COLLECTION_LAN = (
|
||||||
|
os.getenv("PB_FIENTA_COLLECTION_LAN", "").strip()
|
||||||
|
or "fienta_registrations_lan"
|
||||||
|
)
|
||||||
|
|
||||||
|
FIENTA_WEBHOOK_SECRET = os.getenv("FIENTA_WEBHOOK_SECRET", "").strip()
|
||||||
|
FIENTA_WEBHOOK_PORT = _env_int("FIENTA_WEBHOOK_PORT", 8090)
|
||||||
|
FIENTA_ADMIN_ALERT_CHANNEL_ID = _env_int("FIENTA_ADMIN_ALERT_CHANNEL_ID", 0)
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import discord
|
|
||||||
from discord import app_commands
|
|
||||||
|
|
||||||
import config
|
|
||||||
|
|
||||||
|
|
||||||
def is_bot_admin(member: discord.abc.User | None) -> bool:
|
|
||||||
"""True when the member has any of the configured admin roles for their guild."""
|
|
||||||
if not isinstance(member, discord.Member) or member.guild is None:
|
|
||||||
return False
|
|
||||||
admin_role_ids = config.BOT_ADMIN_ROLES.get(member.guild.id)
|
|
||||||
if not admin_role_ids:
|
|
||||||
return False
|
|
||||||
return any(r.id in admin_role_ids for r in member.roles)
|
|
||||||
|
|
||||||
|
|
||||||
def bot_admin_check():
|
|
||||||
"""Slash-command decorator that gates execution behind ``is_bot_admin``."""
|
|
||||||
|
|
||||||
async def predicate(interaction: discord.Interaction) -> bool:
|
|
||||||
if is_bot_admin(interaction.user):
|
|
||||||
return True
|
|
||||||
raise app_commands.MissingPermissions(["bot_admin_role"])
|
|
||||||
|
|
||||||
return app_commands.check(predicate)
|
|
||||||
1792
core/economy.py
Normal file
1792
core/economy.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
|||||||
"""TipiCOIN economy - data layer and business logic.
|
|
||||||
|
|
||||||
Storage: PocketBase (see core/pb_client.py). All public async functions are the
|
|
||||||
single source of truth for mutations; see store.py for the locking rules.
|
|
||||||
|
|
||||||
This package re-exports everything so callers keep using `from core import
|
|
||||||
economy` + attribute access.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
|
||||||
|
|
||||||
from .store import *
|
|
||||||
from .store import (
|
|
||||||
_commit, _default_user, _is_jailed, _locked_by, _now, _parse_dt,
|
|
||||||
_prestige_mult, _txn, _user_lock, _user_locks,
|
|
||||||
)
|
|
||||||
from .house import *
|
|
||||||
from .house import _credit_house, _house_record_id
|
|
||||||
from .levels import *
|
|
||||||
from .shop import *
|
|
||||||
from .fishing import *
|
|
||||||
from .quests import *
|
|
||||||
from .quests import _ensure_quests, _pick_quests, _quest_view
|
|
||||||
from .income import *
|
|
||||||
from .jail import *
|
|
||||||
from .gambling import *
|
|
||||||
from .prestige import *
|
|
||||||
from .leaderboards import *
|
|
||||||
from .heist import *
|
|
||||||
from .admin import *
|
|
||||||
|
|
||||||
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
|
||||||
admin, fishing, gambling, heist, house, income, jail, leaderboards,
|
|
||||||
levels, prestige, quests, shop, store,
|
|
||||||
)
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"""Admin mutations and the season reset."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from .. import pb_client
|
|
||||||
from .store import _commit, _default_user, _locked_by, _now, _txn, get_user
|
|
||||||
from .levels import get_level
|
|
||||||
from .shop import SHOP
|
|
||||||
|
|
||||||
|
|
||||||
async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
|
|
||||||
"""Snapshot top_n by EXP, then full wipe: EXP, balance, items, item_uses.
|
|
||||||
Returns top list (uid, exp, level) captured before the reset."""
|
|
||||||
records = await pb_client.list_all_records()
|
|
||||||
top = sorted(
|
|
||||||
((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")),
|
|
||||||
key=lambda x: x[1],
|
|
||||||
reverse=True,
|
|
||||||
)[:top_n]
|
|
||||||
reset_fields = {
|
|
||||||
"exp": 0,
|
|
||||||
"balance": 0,
|
|
||||||
"items": [],
|
|
||||||
"item_uses": {},
|
|
||||||
"last_daily": None,
|
|
||||||
"last_work": None,
|
|
||||||
"last_beg": None,
|
|
||||||
"last_crime": None,
|
|
||||||
"last_rob": None,
|
|
||||||
"last_fish": None,
|
|
||||||
"daily_streak": 0,
|
|
||||||
"last_streak_date": None,
|
|
||||||
"season_total_exp": 0,
|
|
||||||
}
|
|
||||||
for record in records:
|
|
||||||
await pb_client.update_record(record["id"], reset_fields)
|
|
||||||
return [(uid, exp, get_level(exp)) for uid, exp in top]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Admin actions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict:
|
|
||||||
"""Give (positive) or take (negative) coins from a user. Balance is floored at 0."""
|
|
||||||
user = await get_user(target_id)
|
|
||||||
user["balance"] = max(0, user["balance"] + amount)
|
|
||||||
await _commit(target_id, user)
|
|
||||||
verb = f"+{amount}" if amount >= 0 else str(amount)
|
|
||||||
_txn("ADMIN_COINS", admin=admin_id, target=target_id, amount=verb, reason=reason, bal=user["balance"])
|
|
||||||
return {"ok": True, "balance": user["balance"], "change": amount}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict:
|
|
||||||
"""Manually jail a user for `minutes` minutes."""
|
|
||||||
user = await get_user(target_id)
|
|
||||||
user["jailed_until"] = (_now() + timedelta(minutes=minutes)).isoformat()
|
|
||||||
user["jailbreak_used"] = False
|
|
||||||
await _commit(target_id, user)
|
|
||||||
_txn("ADMIN_JAIL", admin=admin_id, target=target_id, minutes=minutes, reason=reason)
|
|
||||||
return {"ok": True, "jailed_until": user["jailed_until"]}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_admin_unjail(target_id: int, admin_id: int) -> dict:
|
|
||||||
"""Remove jail from a user."""
|
|
||||||
user = await get_user(target_id)
|
|
||||||
user["jailed_until"] = None
|
|
||||||
user["jailbreak_used"] = False
|
|
||||||
await _commit(target_id, user)
|
|
||||||
_txn("ADMIN_UNJAIL", admin=admin_id, target=target_id)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict:
|
|
||||||
"""Ban a user from all economy commands."""
|
|
||||||
user = await get_user(target_id)
|
|
||||||
user["eco_banned"] = True
|
|
||||||
await _commit(target_id, user)
|
|
||||||
_txn("ADMIN_BAN", admin=admin_id, target=target_id, reason=reason)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_admin_unban(target_id: int, admin_id: int) -> dict:
|
|
||||||
"""Lift an economy ban."""
|
|
||||||
user = await get_user(target_id)
|
|
||||||
user["eco_banned"] = False
|
|
||||||
await _commit(target_id, user)
|
|
||||||
_txn("ADMIN_UNBAN", admin=admin_id, target=target_id)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_admin_reset(target_id: int, admin_id: int) -> dict:
|
|
||||||
"""Wipe a user's economy data back to defaults."""
|
|
||||||
user = await get_user(target_id)
|
|
||||||
fresh = _default_user()
|
|
||||||
fresh["_pb_id"] = user.get("_pb_id") # type: ignore[typeddict-unknown-key]
|
|
||||||
await _commit(target_id, fresh)
|
|
||||||
_txn("ADMIN_RESET", admin=admin_id, target=target_id)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
async def do_admin_inspect(target_id: int) -> dict:
|
|
||||||
"""Return the user's full raw economy data."""
|
|
||||||
user = await get_user(target_id)
|
|
||||||
return {"ok": True, "data": dict(user)}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_admin_exp(target_id: int, amount: int, admin_id: int, reason: str) -> dict:
|
|
||||||
"""Give (positive) or take (negative) EXP from a user. EXP is floored at 0."""
|
|
||||||
user = await get_user(target_id)
|
|
||||||
old_exp = user.get("exp", 0)
|
|
||||||
old_level = get_level(old_exp)
|
|
||||||
user["exp"] = max(0, old_exp + amount)
|
|
||||||
user["season_total_exp"] = max(0, user.get("season_total_exp", 0) + amount)
|
|
||||||
new_level = get_level(user["exp"])
|
|
||||||
await _commit(target_id, user)
|
|
||||||
verb = f"+{amount}" if amount >= 0 else str(amount)
|
|
||||||
_txn("ADMIN_EXP", admin=admin_id, target=target_id, amount=verb, reason=reason, exp=user["exp"])
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"exp": user["exp"],
|
|
||||||
"change": amount,
|
|
||||||
"old_level": old_level,
|
|
||||||
"new_level": new_level,
|
|
||||||
"level_changed": new_level != old_level,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_admin_item(target_id: int, item_id: str, action: str, admin_id: int) -> dict:
|
|
||||||
"""Give or remove an item. action='give'|'remove'. Returns ok/reason."""
|
|
||||||
if item_id not in SHOP:
|
|
||||||
return {"ok": False, "reason": "invalid_item"}
|
|
||||||
user = await get_user(target_id)
|
|
||||||
items: list = list(user.get("items") or [])
|
|
||||||
item_uses: dict = dict(user.get("item_uses") or {})
|
|
||||||
if action == "give":
|
|
||||||
if item_id not in items:
|
|
||||||
items.append(item_id)
|
|
||||||
if item_id == "anticheat":
|
|
||||||
item_uses["anticheat"] = 2
|
|
||||||
user["items"] = items
|
|
||||||
user["item_uses"] = item_uses
|
|
||||||
await _commit(target_id, user)
|
|
||||||
_txn("ADMIN_ITEM_GIVE", admin=admin_id, target=target_id, item=item_id)
|
|
||||||
return {"ok": True, "action": "given", "item_id": item_id}
|
|
||||||
elif action == "remove":
|
|
||||||
if item_id not in items:
|
|
||||||
return {"ok": False, "reason": "not_owned"}
|
|
||||||
items.remove(item_id)
|
|
||||||
item_uses.pop(item_id, None)
|
|
||||||
user["items"] = items
|
|
||||||
user["item_uses"] = item_uses
|
|
||||||
await _commit(target_id, user)
|
|
||||||
_txn("ADMIN_ITEM_REMOVE", admin=admin_id, target=target_id, item=item_id)
|
|
||||||
return {"ok": True, "action": "removed", "item_id": item_id}
|
|
||||||
return {"ok": False, "reason": "invalid_action"}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
"""Fishing minigame: catalogue, rolls, catch/sell flows."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
|
||||||
from .store import (
|
|
||||||
COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
|
|
||||||
_prestige_mult, _txn, get_user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fish catalogue
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FISH_CATALOGUE: dict[str, dict] = {
|
|
||||||
# id: { rarity, weight=(min_g, max_g), coins=(min, max), exp }
|
|
||||||
"sarj": {"rarity": "common", "weight": (50, 500), "coins": (3, 18), "exp": 3},
|
|
||||||
"ahven": {"rarity": "common", "weight": (80, 700), "coins": (5, 22), "exp": 3},
|
|
||||||
"koger": {"rarity": "common", "weight": (100, 800), "coins": (5, 20), "exp": 3},
|
|
||||||
"viidikas": {"rarity": "common", "weight": (10, 120), "coins": (2, 8), "exp": 2},
|
|
||||||
"latikas": {"rarity": "uncommon", "weight": (300, 2500), "coins": (20, 70), "exp": 6},
|
|
||||||
"karpkala": {"rarity": "uncommon", "weight": (500, 4000), "coins": (25, 80), "exp": 7},
|
|
||||||
"linask": {"rarity": "uncommon", "weight": (200, 2000), "coins": (18, 60), "exp": 6},
|
|
||||||
"haug": {"rarity": "rare", "weight": (500, 6000), "coins": (50, 180), "exp": 10},
|
|
||||||
"angerjas": {"rarity": "rare", "weight": (200, 1800), "coins": (40, 120), "exp": 10},
|
|
||||||
"siig": {"rarity": "rare", "weight": (200, 2000), "coins": (45, 130), "exp": 10},
|
|
||||||
"forell": {"rarity": "epic", "weight": (400, 4500), "coins": (100, 280), "exp": 15},
|
|
||||||
"koha": {"rarity": "epic", "weight": (600, 7000), "coins": (120, 300), "exp": 15},
|
|
||||||
"tougjas": {"rarity": "epic", "weight": (400, 4000), "coins": (90, 250), "exp": 14},
|
|
||||||
"lohe": {"rarity": "legendary","weight": (1500, 12000), "coins": (250, 700), "exp": 25},
|
|
||||||
"vimb": {"rarity": "legendary","weight": (200, 1200), "coins": (200, 600), "exp": 25},
|
|
||||||
}
|
|
||||||
|
|
||||||
FISH_RARITY_WEIGHTS: dict[str, int] = {
|
|
||||||
"junk": 15,
|
|
||||||
"common": 45,
|
|
||||||
"uncommon": 22,
|
|
||||||
"rare": 12,
|
|
||||||
"epic": 5,
|
|
||||||
"legendary": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def roll_fish(rarity_bump: bool = False) -> tuple[str, int]:
|
|
||||||
"""Roll a random fish. Returns (fish_id, weight_grams) or ('junk', 0).
|
|
||||||
rarity_bump=True (kalavork item) shifts each catch one tier up.
|
|
||||||
"""
|
|
||||||
rarity_pool = list(FISH_RARITY_WEIGHTS.keys())
|
|
||||||
weights = list(FISH_RARITY_WEIGHTS.values())
|
|
||||||
chosen_rarity = random.choices(rarity_pool, weights=weights)[0]
|
|
||||||
if chosen_rarity == "junk":
|
|
||||||
return ("junk", 0)
|
|
||||||
if rarity_bump:
|
|
||||||
order = ["common", "uncommon", "rare", "epic", "legendary"]
|
|
||||||
idx = order.index(chosen_rarity) if chosen_rarity in order else 0
|
|
||||||
chosen_rarity = order[min(idx + 1, len(order) - 1)]
|
|
||||||
fish_of_rarity = [k for k, v in FISH_CATALOGUE.items() if v["rarity"] == chosen_rarity]
|
|
||||||
if not fish_of_rarity:
|
|
||||||
return ("junk", 0)
|
|
||||||
fish_id = random.choice(fish_of_rarity)
|
|
||||||
fish = FISH_CATALOGUE[fish_id]
|
|
||||||
weight = random.randint(fish["weight"][0], fish["weight"][1])
|
|
||||||
return (fish_id, weight)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /fish
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_fish_start(user_id: int) -> dict:
|
|
||||||
"""Check cooldown + jail, set cooldown. Call before starting the fishing minigame."""
|
|
||||||
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}
|
|
||||||
|
|
||||||
fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"]
|
|
||||||
if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd):
|
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
|
||||||
|
|
||||||
user["last_fish"] = _now().isoformat()
|
|
||||||
await _commit(user_id, user)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict:
|
|
||||||
"""Add catch to inventory + update fish_book. Returns catch info incl. pre-calculated value."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
|
|
||||||
if fish_id == "junk":
|
|
||||||
_txn("FISH_JUNK", user=user_id)
|
|
||||||
return {"ok": True, "type": "junk", "coins": 0, "exp": 0}
|
|
||||||
|
|
||||||
if fish_id not in FISH_CATALOGUE:
|
|
||||||
return {"ok": False, "reason": "invalid_fish"}
|
|
||||||
|
|
||||||
fish = FISH_CATALOGUE[fish_id]
|
|
||||||
min_c, max_c = fish["coins"]
|
|
||||||
w_min, w_max = fish["weight"]
|
|
||||||
weight_ratio = (weight - w_min) / max(1, w_max - w_min)
|
|
||||||
base_coins = int(min_c + weight_ratio * (max_c - min_c))
|
|
||||||
coin_mult, _ = _prestige_mult(user)
|
|
||||||
value = int(base_coins * coin_mult)
|
|
||||||
exp = fish["exp"]
|
|
||||||
|
|
||||||
book: dict = user.get("fish_book") or {}
|
|
||||||
prev_count = book.get(fish_id, 0)
|
|
||||||
book[fish_id] = prev_count + 1
|
|
||||||
user["fish_book"] = book
|
|
||||||
user["total_fish_caught"] = user.get("total_fish_caught", 0) + 1
|
|
||||||
|
|
||||||
inv: list = list(user.get("fish_inventory") or [])
|
|
||||||
inv.append({"fish_id": fish_id, "weight": weight, "value": value})
|
|
||||||
user["fish_inventory"] = inv
|
|
||||||
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("FISH", user=user_id, fish=fish_id, weight=weight, value=value)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"type": "fish",
|
|
||||||
"fish_id": fish_id,
|
|
||||||
"weight": weight,
|
|
||||||
"value": value,
|
|
||||||
"exp": exp,
|
|
||||||
"is_new": prev_count == 0,
|
|
||||||
"total_caught": book[fish_id],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict:
|
|
||||||
"""Sell fish from inventory. indices=None sells all. Returns coins earned."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
inv: list = list(user.get("fish_inventory") or [])
|
|
||||||
if not inv:
|
|
||||||
return {"ok": False, "reason": "empty"}
|
|
||||||
|
|
||||||
if indices is None:
|
|
||||||
to_sell = inv
|
|
||||||
remaining = []
|
|
||||||
else:
|
|
||||||
sell_idx = {
|
|
||||||
(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)]
|
|
||||||
|
|
||||||
if not to_sell:
|
|
||||||
return {"ok": False, "reason": "empty"}
|
|
||||||
|
|
||||||
total_coins = sum(entry["value"] for entry in to_sell)
|
|
||||||
user["fish_inventory"] = remaining
|
|
||||||
user["balance"] = user.get("balance", 0) + total_coins
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + total_coins
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("FISH_SELL", user=user_id, count=len(to_sell), coins=f"+{total_coins}", bal=user["balance"])
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"coins": total_coins,
|
|
||||||
"count": len(to_sell),
|
|
||||||
"balance": user["balance"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def do_fishbook(user_id: int) -> dict:
|
|
||||||
"""Return the user's fish book data including per-species inventory counts."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
book: dict = user.get("fish_book") or {}
|
|
||||||
inv: list = user.get("fish_inventory") or []
|
|
||||||
inv_counts: dict[str, int] = {}
|
|
||||||
for entry in inv:
|
|
||||||
fid = entry.get("fish_id", "")
|
|
||||||
inv_counts[fid] = inv_counts.get(fid, 0) + 1
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"book": book,
|
|
||||||
"inv_counts": inv_counts,
|
|
||||||
"total_fish_caught": user.get("total_fish_caught", 0),
|
|
||||||
"unique_caught": len(book),
|
|
||||||
"total_species": len(FISH_CATALOGUE),
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
"""Casino games: roulette, slots, RPS bets and escrow, blackjack."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
|
||||||
from ..emoji import EMOJI as E
|
|
||||||
from .store import _commit, _is_jailed, _locked_by, _txn, get_user
|
|
||||||
from .house import _credit_house
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /roulette
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
|
||||||
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"}
|
|
||||||
|
|
||||||
# Wheel: 18 red, 18 black, 1 green (37 slots - real roulette proportions)
|
|
||||||
result = random.choices(["punane", "must", "roheline"], weights=[18, 18, 1])[0]
|
|
||||||
won = result == colour
|
|
||||||
mult = 14 if colour == "roheline" else 1
|
|
||||||
change = bet * mult if won else -bet
|
|
||||||
user["balance"] = max(0, user["balance"] + change)
|
|
||||||
user["total_wagered"] = user.get("total_wagered", 0) + bet
|
|
||||||
if won:
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + abs(change)
|
|
||||||
user["biggest_win"] = max(user.get("biggest_win", 0), abs(change))
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
else:
|
|
||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
|
|
||||||
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
|
|
||||||
await _commit(user_id, user)
|
|
||||||
if not won:
|
|
||||||
await _credit_house(bet)
|
|
||||||
_txn("ROULETTE_" + ("WIN" if won else "LOSE"), user=user_id, bet=bet, colour=colour, result=result, mult=mult, bal=user["balance"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True, "won": won,
|
|
||||||
"result": result, "change": abs(change), "mult": mult,
|
|
||||||
"balance": user["balance"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /rps (bet resolution)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
|
||||||
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
|
|
||||||
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["total_wagered"] = user.get("total_wagered", 0) + bet
|
|
||||||
if outcome == "win":
|
|
||||||
user["balance"] += bet
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
|
|
||||||
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
elif outcome == "lose":
|
|
||||||
user["balance"] = max(0, user["balance"] - bet)
|
|
||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
|
|
||||||
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
|
|
||||||
# tie: no change
|
|
||||||
await _commit(user_id, user)
|
|
||||||
if outcome == "lose" and bet > 0:
|
|
||||||
await _credit_house(bet)
|
|
||||||
_txn("RPS_" + outcome.upper(), user=user_id, bet=bet, bal=user["balance"])
|
|
||||||
return {"ok": True, "balance": user["balance"]}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /rps PvP escrow (deposit/payout/refund)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
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"]}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
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"]}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
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_SYMBOLS: list[tuple[str, int]] = [
|
|
||||||
(E["TipiHEART"], 27),
|
|
||||||
(E["TipiFIRE"], 22),
|
|
||||||
(E["TipiTROLL"], 18),
|
|
||||||
(E["TipICRY"], 15),
|
|
||||||
(E["TipiSKULL"], 10),
|
|
||||||
(E["TipiKARIKAS"], 8),
|
|
||||||
]
|
|
||||||
_SLOTS_JACKPOT = E["TipiKARIKAS"]
|
|
||||||
_SLOTS_TRIPLE_MULT: dict[str, int] = {
|
|
||||||
E["TipiHEART"]: 4,
|
|
||||||
E["TipiFIRE"]: 5,
|
|
||||||
E["TipiTROLL"]: 7,
|
|
||||||
E["TipICRY"]: 10,
|
|
||||||
E["TipiSKULL"]: 15,
|
|
||||||
E["TipiKARIKAS"]: 25, # jackpot
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _spin() -> str:
|
|
||||||
symbols, weights = zip(*_SLOTS_SYMBOLS)
|
|
||||||
return random.choices(list(symbols), weights=list(weights), k=1)[0]
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_slots(user_id: int, bet: int) -> dict:
|
|
||||||
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"}
|
|
||||||
|
|
||||||
reels = [_spin(), _spin(), _spin()]
|
|
||||||
a, b, c = reels
|
|
||||||
|
|
||||||
has_360 = "monitor_360" in user["items"]
|
|
||||||
if a == b == c:
|
|
||||||
tier = "jackpot" if a == _SLOTS_JACKPOT else "triple"
|
|
||||||
base_mult = _SLOTS_TRIPLE_MULT.get(a, 4)
|
|
||||||
mult = int(base_mult * 1.5) if has_360 else base_mult
|
|
||||||
change = bet * (mult - 1)
|
|
||||||
elif a == b or b == c or a == c:
|
|
||||||
tier = "pair"
|
|
||||||
change = bet // 2
|
|
||||||
else:
|
|
||||||
tier = "miss"
|
|
||||||
change = -bet
|
|
||||||
|
|
||||||
user["balance"] = max(0, user["balance"] + change)
|
|
||||||
user["total_wagered"] = user.get("total_wagered", 0) + bet
|
|
||||||
if tier in ("jackpot", "triple", "pair"):
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + change
|
|
||||||
user["biggest_win"] = max(user.get("biggest_win", 0), change)
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
if tier == "jackpot":
|
|
||||||
user["slots_jackpots"] = user.get("slots_jackpots", 0) + 1
|
|
||||||
else:
|
|
||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
|
|
||||||
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
|
|
||||||
await _commit(user_id, user)
|
|
||||||
if tier == "miss":
|
|
||||||
await _credit_house(bet)
|
|
||||||
_txn("SLOTS_" + tier.upper(), user=user_id, bet=bet, change=change, bal=user["balance"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"reels": reels,
|
|
||||||
"tier": tier,
|
|
||||||
"change": change,
|
|
||||||
"balance": user["balance"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /blackjack
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
|
||||||
"""Deduct the initial blackjack bet. Returns ok/fail."""
|
|
||||||
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", "balance": user["balance"]}
|
|
||||||
user["balance"] -= bet
|
|
||||||
await _commit(user_id, user)
|
|
||||||
return {"ok": True, "balance": user["balance"]}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
|
|
||||||
"""Credit the net payout. House receives the difference when payout < total_invested."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
user["balance"] += payout
|
|
||||||
user["balance"] = max(0, user["balance"])
|
|
||||||
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
|
|
||||||
net = payout - total_invested
|
|
||||||
if net > 0:
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + net
|
|
||||||
user["biggest_win"] = max(user.get("biggest_win", 0), net)
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
elif net < 0:
|
|
||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
|
|
||||||
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
|
|
||||||
await _commit(user_id, user)
|
|
||||||
house_gain = total_invested - payout
|
|
||||||
if house_gain > 0:
|
|
||||||
await _credit_house(house_gain)
|
|
||||||
_txn("BLACKJACK", user=user_id, payout=f"{payout:+}", net=f"{net:+}", bal=user["balance"])
|
|
||||||
return {"ok": True, "balance": user["balance"]}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"""Bank heist: group robbery of the house."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
|
|
||||||
from .. import pb_client
|
|
||||||
from ..pb_client import DatabaseError
|
|
||||||
from . import house
|
|
||||||
from .store import HEIST_JAIL, _commit, _now, _txn, _user_lock, get_user
|
|
||||||
from .house import _credit_house, _refund_house_safe, _refund_user_safe
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /heist
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def do_heist_check(user_id: int) -> dict:
|
|
||||||
"""Check whether a user is eligible to join a heist."""
|
|
||||||
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}
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
|
||||||
"""Apply heist outcome to all participants. On win, steals from house.
|
|
||||||
|
|
||||||
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()
|
|
||||||
payout_each = 0
|
|
||||||
failed_users: list[int] = []
|
|
||||||
|
|
||||||
if success and house.HOUSE_ID is not None:
|
|
||||||
try:
|
|
||||||
house = await get_user(house.HOUSE_ID)
|
|
||||||
pct = random.uniform(0.20, 0.55)
|
|
||||||
total = max(300, int(house["balance"] * pct))
|
|
||||||
payout_each = total // len(user_ids)
|
|
||||||
# Atomic decrement (capped at the balance we read) instead of a full
|
|
||||||
# record commit, so concurrent _credit_house increments aren't lost.
|
|
||||||
debit = min(total, house["balance"])
|
|
||||||
if debit > 0:
|
|
||||||
await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item]
|
|
||||||
except DatabaseError:
|
|
||||||
return {"ok": False, "reason": "db_error"}
|
|
||||||
_txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit)
|
|
||||||
|
|
||||||
for uid in user_ids:
|
|
||||||
async with _user_lock(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["heists_joined"] = user.get("heists_joined", 0) + 1
|
|
||||||
fine_credited = False
|
|
||||||
if success:
|
|
||||||
user["balance"] += payout_each
|
|
||||||
user["heists_won"] = user.get("heists_won", 0) + 1
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
_txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"])
|
|
||||||
else:
|
|
||||||
fine = max(150, min(1000, int(user["balance"] * 0.15)))
|
|
||||||
user["balance"] = max(0, user["balance"] - fine)
|
|
||||||
user["jailed_until"] = (now + HEIST_JAIL).isoformat()
|
|
||||||
user["jailbreak_used"] = False
|
|
||||||
user["times_jailed"] = user.get("times_jailed", 0) + 1
|
|
||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
|
|
||||||
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
|
|
||||||
if fine > 0:
|
|
||||||
try:
|
|
||||||
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.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
|
||||||
|
|
||||||
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""House account: the bot's own balance, fed by fines and lost bets."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import pb_client
|
|
||||||
from ..pb_client import DatabaseError
|
|
||||||
from .store import _log, _now, get_user, _commit
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# House account (bot user)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
HOUSE_ID: int | None = None
|
|
||||||
_house_pb_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def set_house(user_id: int) -> None:
|
|
||||||
"""Register the bot's Discord user ID as the house account."""
|
|
||||||
global HOUSE_ID, _house_pb_id
|
|
||||||
if HOUSE_ID != user_id:
|
|
||||||
_house_pb_id = None
|
|
||||||
HOUSE_ID = user_id
|
|
||||||
|
|
||||||
|
|
||||||
async def _house_record_id() -> str | None:
|
|
||||||
"""PocketBase record id of the house account (cached; creates the record on first use)."""
|
|
||||||
global _house_pb_id
|
|
||||||
if HOUSE_ID is None:
|
|
||||||
return None
|
|
||||||
if _house_pb_id is None:
|
|
||||||
house = await get_user(HOUSE_ID)
|
|
||||||
_house_pb_id = house.get("_pb_id") # type: ignore[typeddict-item]
|
|
||||||
return _house_pb_id
|
|
||||||
|
|
||||||
|
|
||||||
async def _credit_house(amount: int) -> None:
|
|
||||||
"""Add `amount` coins to the house via an atomic PocketBase increment.
|
|
||||||
|
|
||||||
Deliberately lock-free: callers hold per-user locks, so this must never
|
|
||||||
acquire one itself (see the locking rules above)."""
|
|
||||||
if amount <= 0:
|
|
||||||
return
|
|
||||||
record_id = await _house_record_id()
|
|
||||||
if record_id is None:
|
|
||||||
return
|
|
||||||
await pb_client.update_record(record_id, {"balance+": amount})
|
|
||||||
|
|
||||||
|
|
||||||
async def get_heist_global_cd() -> float:
|
|
||||||
"""Return unix timestamp until which no new heist can start. Persisted on house record."""
|
|
||||||
if HOUSE_ID is None:
|
|
||||||
return 0.0
|
|
||||||
house = await get_user(HOUSE_ID)
|
|
||||||
return float(house.get("heist_global_cd_until") or 0)
|
|
||||||
|
|
||||||
|
|
||||||
async def set_heist_global_cd(until: float) -> None:
|
|
||||||
"""Persist heist global cooldown expiry to the house account in PocketBase."""
|
|
||||||
record_id = await _house_record_id()
|
|
||||||
if record_id is None:
|
|
||||||
return
|
|
||||||
await pb_client.update_record(record_id, {"heist_global_cd_until": until})
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
await _credit_house(amount)
|
|
||||||
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 atomic debit of `amount` from the house (compensates a failed
|
|
||||||
user fine). Logs critical if it fails."""
|
|
||||||
if HOUSE_ID is None or amount <= 0:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
record_id = await _house_record_id()
|
|
||||||
if record_id:
|
|
||||||
await pb_client.update_record(record_id, {"balance-": amount})
|
|
||||||
except DatabaseError as exc:
|
|
||||||
_log.critical(
|
|
||||||
"House debit compensation failed (%s, related uid %s, amount %s): %s",
|
|
||||||
context, uid, amount, exc,
|
|
||||||
)
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
"""Income and social commands: daily, work, beg, crime, rob, give."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
import strings
|
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
|
||||||
from . import house
|
|
||||||
from .store import (
|
|
||||||
COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
|
|
||||||
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user,
|
|
||||||
)
|
|
||||||
from .house import _credit_house
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /daily
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_daily(user_id: int) -> dict:
|
|
||||||
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"}
|
|
||||||
|
|
||||||
daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"]
|
|
||||||
if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd):
|
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
|
||||||
|
|
||||||
today = _now().date()
|
|
||||||
last_str = user.get("last_streak_date")
|
|
||||||
last_date = date.fromisoformat(last_str) if last_str else None
|
|
||||||
|
|
||||||
if last_date is None:
|
|
||||||
streak = 1
|
|
||||||
elif (today - last_date).days == 1:
|
|
||||||
streak = user["daily_streak"] + 1
|
|
||||||
elif "karikas" in user["items"]:
|
|
||||||
streak = user["daily_streak"] # karikas: streak survives missed days
|
|
||||||
else:
|
|
||||||
streak = 1 # streak broken
|
|
||||||
|
|
||||||
# Streak multiplier tiers
|
|
||||||
if streak >= 14:
|
|
||||||
streak_mult = 3.0
|
|
||||||
elif streak >= 7:
|
|
||||||
streak_mult = 2.0
|
|
||||||
elif streak >= 3:
|
|
||||||
streak_mult = 1.5
|
|
||||||
else:
|
|
||||||
streak_mult = 1.0
|
|
||||||
|
|
||||||
vip = "lan_pass" in user["items"]
|
|
||||||
vip_mult = 2.0 if vip else 1.0
|
|
||||||
|
|
||||||
daily_plus_level = (user.get("prestige_upgrades") or {}).get("daily_plus", 0)
|
|
||||||
base = int(150 * (1.0 + daily_plus_level * PRESTIGE_SHOP["daily_plus"]["effect"]))
|
|
||||||
earned = int(base * streak_mult * vip_mult)
|
|
||||||
if "korvaklapid" in user["items"]:
|
|
||||||
earned += 25
|
|
||||||
coin_mult, _ = _prestige_mult(user)
|
|
||||||
earned = int(earned * coin_mult)
|
|
||||||
|
|
||||||
# Investor interest (capped at 500/day to prevent runaway wealth)
|
|
||||||
interest = 0
|
|
||||||
if "gaming_laptop" in user["items"] and user["balance"] > 0:
|
|
||||||
interest = min(int(user["balance"] * 0.05), 500)
|
|
||||||
earned += interest
|
|
||||||
|
|
||||||
user["balance"] += earned
|
|
||||||
user["last_daily"] = _now().isoformat()
|
|
||||||
user["daily_streak"] = streak
|
|
||||||
user["last_streak_date"] = today.isoformat()
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
|
|
||||||
user["best_daily_streak"] = max(user.get("best_daily_streak", 0), streak)
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("DAILY", user=user_id, earned=f"+{earned}", streak=streak, bal=user["balance"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"earned": earned,
|
|
||||||
"interest": interest,
|
|
||||||
"streak": streak,
|
|
||||||
"streak_mult": streak_mult,
|
|
||||||
"vip": vip,
|
|
||||||
"balance": user["balance"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /work
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_WORK_JOBS = strings.WORK_JOBS
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_work(user_id: int) -> dict:
|
|
||||||
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"}
|
|
||||||
|
|
||||||
work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"]
|
|
||||||
if cd := _cooldown_remaining(user, "work", override_cd=work_cd):
|
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
|
||||||
if jail := _is_jailed(user):
|
|
||||||
return {"ok": False, "reason": "jailed", "remaining": jail}
|
|
||||||
|
|
||||||
job, job_mult = random.choice(_WORK_JOBS)
|
|
||||||
base = random.randint(15, 75)
|
|
||||||
worker_mult = 1.5 if "gaming_hiir" in user["items"] else 1.0
|
|
||||||
desk_mult = 1.25 if "reguleeritav_laud" in user["items"] else 1.0
|
|
||||||
|
|
||||||
lucky = False
|
|
||||||
if "energiajook" in user["items"] and random.random() < 0.30:
|
|
||||||
lucky = True
|
|
||||||
|
|
||||||
work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0)
|
|
||||||
work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"]
|
|
||||||
coin_mult, _ = _prestige_mult(user)
|
|
||||||
earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult)
|
|
||||||
user["balance"] += earned
|
|
||||||
user["last_work"] = _now().isoformat()
|
|
||||||
user["work_count"] = user.get("work_count", 0) + 1
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("WORK", user=user_id, earned=f"+{earned}", lucky=lucky, bal=user["balance"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"earned": earned,
|
|
||||||
"job": job,
|
|
||||||
"lucky": lucky,
|
|
||||||
"hiir": worker_mult > 1.0,
|
|
||||||
"laud": desk_mult > 1.0,
|
|
||||||
"balance": user["balance"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /beg
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_BEG_LINES = strings.BEG_LINES
|
|
||||||
_BEG_JAIL_LINES = strings.BEG_JAIL_LINES
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_beg(user_id: int) -> dict:
|
|
||||||
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"}
|
|
||||||
|
|
||||||
beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"]
|
|
||||||
if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd):
|
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
|
||||||
|
|
||||||
jailed = bool(_is_jailed(user))
|
|
||||||
beg_mult = 2 if "klaviatuur" in user["items"] else 1
|
|
||||||
coin_mult, _ = _prestige_mult(user)
|
|
||||||
earned = int(random.randint(10, 40) * beg_mult * coin_mult)
|
|
||||||
user["balance"] += earned
|
|
||||||
user["last_beg"] = _now().isoformat()
|
|
||||||
user["beg_count"] = user.get("beg_count", 0) + 1
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("BEG", user=user_id, earned=f"+{earned}", jailed=jailed, bal=user["balance"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"earned": earned,
|
|
||||||
"text": random.choice(_BEG_JAIL_LINES if jailed else _BEG_LINES),
|
|
||||||
"klaviatuur": beg_mult > 1,
|
|
||||||
"jailed": jailed,
|
|
||||||
"balance": user["balance"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /crime
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_CRIME_WIN = strings.CRIME_WIN
|
|
||||||
_CRIME_LOSE = strings.CRIME_LOSE
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_crime(user_id: int) -> dict:
|
|
||||||
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 cd := _cooldown_remaining(user, "crime"):
|
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
|
||||||
if jail := _is_jailed(user):
|
|
||||||
return {"ok": False, "reason": "jailed", "remaining": jail}
|
|
||||||
|
|
||||||
user["last_crime"] = _now().isoformat()
|
|
||||||
|
|
||||||
win_chance = 0.75 if "cat6" in user["items"] else 0.60
|
|
||||||
user["crimes_attempted"] = user.get("crimes_attempted", 0) + 1
|
|
||||||
if random.random() < win_chance:
|
|
||||||
earned = random.randint(200, 500)
|
|
||||||
if "mikrofon" in user["items"]:
|
|
||||||
earned = int(earned * 1.3)
|
|
||||||
user["balance"] += earned
|
|
||||||
user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("CRIME_WIN", user=user_id, earned=f"+{earned}", bal=user["balance"])
|
|
||||||
return {
|
|
||||||
"ok": True, "success": True,
|
|
||||||
"earned": earned, "text": random.choice(_CRIME_WIN),
|
|
||||||
"mikrofon": "mikrofon" in user["items"],
|
|
||||||
"balance": user["balance"],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
fine = random.randint(50, 150)
|
|
||||||
user["balance"] = max(0, user["balance"] - fine)
|
|
||||||
jailed = "gaming_tool" not in user["items"]
|
|
||||||
if jailed:
|
|
||||||
user["jailed_until"] = (_now() + JAIL_DURATION).isoformat()
|
|
||||||
user["jailbreak_used"] = False
|
|
||||||
user["times_jailed"] = user.get("times_jailed", 0) + 1
|
|
||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
|
|
||||||
await _commit(user_id, user)
|
|
||||||
await _credit_house(fine)
|
|
||||||
_txn("CRIME_FAIL", user=user_id, fine=f"-{fine}", jailed=jailed, bal=user["balance"])
|
|
||||||
return {
|
|
||||||
"ok": True, "success": False,
|
|
||||||
"fine": fine, "text": random.choice(_CRIME_LOSE),
|
|
||||||
"jailed": jailed,
|
|
||||||
"balance": user["balance"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /rob
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0, 1)
|
|
||||||
async def do_rob(robber_id: int, target_id: int) -> dict:
|
|
||||||
try:
|
|
||||||
robber = await get_user(robber_id)
|
|
||||||
except DatabaseError:
|
|
||||||
return {"ok": False, "reason": "db_error"}
|
|
||||||
if robber.get("eco_banned"):
|
|
||||||
return {"ok": False, "reason": "banned"}
|
|
||||||
try:
|
|
||||||
target = await get_user(target_id)
|
|
||||||
except DatabaseError:
|
|
||||||
return {"ok": False, "reason": "db_error"}
|
|
||||||
|
|
||||||
if cd := _cooldown_remaining(robber, "rob"):
|
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
|
||||||
target_jailed = bool(_is_jailed(target))
|
|
||||||
if jail := _is_jailed(robber):
|
|
||||||
if not target_jailed:
|
|
||||||
return {"ok": False, "reason": "jailed", "remaining": jail}
|
|
||||||
elif target_jailed:
|
|
||||||
return {"ok": False, "reason": "target_jailed"}
|
|
||||||
is_house = (house.HOUSE_ID is not None and target_id == house.HOUSE_ID)
|
|
||||||
if is_house and target["balance"] < 50:
|
|
||||||
return {"ok": False, "reason": "broke"}
|
|
||||||
if not is_house and target["balance"] < 100:
|
|
||||||
return {"ok": False, "reason": "broke"}
|
|
||||||
|
|
||||||
robber["last_rob"] = _now().isoformat()
|
|
||||||
|
|
||||||
if "anticheat" in target["items"] and not is_house:
|
|
||||||
fine = random.randint(100, 200)
|
|
||||||
robber["balance"] = max(0, robber["balance"] - fine)
|
|
||||||
# Decrement anticheat uses
|
|
||||||
uses = target.get("item_uses", {}).get("anticheat", 2) - 1
|
|
||||||
if "item_uses" not in target:
|
|
||||||
target["item_uses"] = {}
|
|
||||||
if uses <= 0:
|
|
||||||
target["items"] = [i for i in target["items"] if i != "anticheat"]
|
|
||||||
target["item_uses"].pop("anticheat", None)
|
|
||||||
else:
|
|
||||||
target["item_uses"]["anticheat"] = uses
|
|
||||||
robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine
|
|
||||||
await _commit(robber_id, robber)
|
|
||||||
await _commit(target_id, target)
|
|
||||||
await _credit_house(fine)
|
|
||||||
_txn("ROB_BLOCKED", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"], ac_uses_left=uses)
|
|
||||||
return {"ok": True, "success": False, "reason": "valvur", "fine": fine}
|
|
||||||
|
|
||||||
# Robbing the house has lower success (35%) but jackpot chance
|
|
||||||
success_chance = 0.35 if is_house else (0.60 if "jellyfin" in robber["items"] else 0.45)
|
|
||||||
if random.random() < success_chance:
|
|
||||||
jackpot = is_house and random.random() < 0.10
|
|
||||||
if jackpot:
|
|
||||||
pct = 0.40
|
|
||||||
elif is_house:
|
|
||||||
pct = random.uniform(0.05, 0.15)
|
|
||||||
else:
|
|
||||||
pct = random.uniform(0.10, 0.25)
|
|
||||||
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
|
|
||||||
target["balance"] -= stolen
|
|
||||||
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["lifetime_earned"] = prev_lifetime_earned + stolen
|
|
||||||
robber["biggest_win"] = max(prev_biggest_win, stolen)
|
|
||||||
robber["peak_balance"] = max(prev_peak_balance, robber["balance"])
|
|
||||||
try:
|
|
||||||
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"])
|
|
||||||
return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot}
|
|
||||||
else:
|
|
||||||
fine = random.randint(100, 250)
|
|
||||||
robber["balance"] = max(0, robber["balance"] - fine)
|
|
||||||
robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine
|
|
||||||
robber["biggest_loss"] = max(robber.get("biggest_loss", 0), fine)
|
|
||||||
await _commit(robber_id, robber)
|
|
||||||
await _credit_house(fine)
|
|
||||||
_txn("ROB_FAIL", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"])
|
|
||||||
return {"ok": True, "success": False, "reason": "caught", "fine": fine, "balance": robber["balance"]}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /give
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0, 1)
|
|
||||||
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
|
||||||
try:
|
|
||||||
giver = await get_user(giver_id)
|
|
||||||
except DatabaseError:
|
|
||||||
return {"ok": False, "reason": "db_error"}
|
|
||||||
if giver.get("eco_banned"):
|
|
||||||
return {"ok": False, "reason": "banned"}
|
|
||||||
|
|
||||||
if rem := _is_jailed(giver):
|
|
||||||
return {"ok": False, "reason": "jailed", "remaining": rem}
|
|
||||||
|
|
||||||
if giver["balance"] < amount:
|
|
||||||
return {"ok": False, "reason": "insufficient"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
receiver = await get_user(receiver_id)
|
|
||||||
except DatabaseError:
|
|
||||||
return {"ok": False, "reason": "db_error"}
|
|
||||||
giver["balance"] -= amount
|
|
||||||
receiver["balance"] += amount
|
|
||||||
giver["total_given"] = giver.get("total_given", 0) + amount
|
|
||||||
receiver["total_received"] = receiver.get("total_received", 0) + amount
|
|
||||||
try:
|
|
||||||
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"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"giver_balance": giver["balance"],
|
|
||||||
"receiver_balance": receiver["balance"],
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""Jail, jailbreak and bail."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from .store import (
|
|
||||||
_commit, _is_jailed, _locked_by, _now, _txn, get_all_users_raw, get_user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_spam_jail(user_id: int) -> None:
|
|
||||||
"""Jail a user for 30 minutes due to suspected automated command spam."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
user["jailed_until"] = (_now() + timedelta(minutes=30)).isoformat()
|
|
||||||
user["jailbreak_used"] = False
|
|
||||||
user["times_jailed"] = user.get("times_jailed", 0) + 1
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("SPAM_JAIL", user=user_id, until=user["jailed_until"])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /jailbreak (Monopoly-style dice rolls)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def set_jailbreak_used(user_id: int) -> None:
|
|
||||||
"""Mark that the user has consumed their dice attempt for this jail sentence."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
user["jailbreak_used"] = True
|
|
||||||
await _commit(user_id, user)
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_jail_free(user_id: int) -> dict:
|
|
||||||
"""Remove jail status after rolling doubles."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
user["jailed_until"] = None
|
|
||||||
user["jailbreak_used"] = False
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("JAIL_FREE", user=user_id, method="doubles")
|
|
||||||
return {"ok": True, "balance": user["balance"]}
|
|
||||||
|
|
||||||
|
|
||||||
MIN_BAIL = 350
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_bail(user_id: int) -> dict:
|
|
||||||
"""Charge bail fine after exhausting jailbreak rolls and free the user.
|
|
||||||
Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
if user["balance"] < MIN_BAIL:
|
|
||||||
return {"ok": False, "reason": "broke", "balance": user["balance"]}
|
|
||||||
pct = random.uniform(0.20, 0.30)
|
|
||||||
fine = max(MIN_BAIL, int(user["balance"] * pct))
|
|
||||||
user["balance"] = max(0, user["balance"] - fine)
|
|
||||||
user["jailed_until"] = None
|
|
||||||
user["jailbreak_used"] = False
|
|
||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
|
|
||||||
user["total_bail_paid"] = user.get("total_bail_paid", 0) + fine
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("BAIL_PAID", user=user_id, fine=f"-{fine}", pct=f"{pct:.0%}", bal=user["balance"])
|
|
||||||
return {"ok": True, "fine": fine, "balance": user["balance"]}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /jailed
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def do_get_jailed() -> list[tuple[int, timedelta]]:
|
|
||||||
"""Return [(user_id, remaining)] for every user currently in jail."""
|
|
||||||
all_users = await get_all_users_raw()
|
|
||||||
result: list[tuple[int, timedelta]] = []
|
|
||||||
for uid_str, user in all_users.items():
|
|
||||||
if rem := _is_jailed(user):
|
|
||||||
result.append((int(uid_str), rem))
|
|
||||||
result.sort(key=lambda x: x[1])
|
|
||||||
return result
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""Leaderboard queries over the full collection."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import pb_client
|
|
||||||
from .levels import get_level
|
|
||||||
|
|
||||||
|
|
||||||
async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]:
|
|
||||||
"""Return top_n (user_id_str, balance) pairs sorted descending."""
|
|
||||||
records = await pb_client.list_all_records()
|
|
||||||
result = sorted(
|
|
||||||
((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")),
|
|
||||||
key=lambda x: x[1],
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
return result if top_n is None else result[:top_n]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]:
|
|
||||||
"""Return top_n (user_id_str, exp, level) sorted by EXP descending."""
|
|
||||||
records = await pb_client.list_all_records()
|
|
||||||
result = sorted(
|
|
||||||
((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")),
|
|
||||||
key=lambda x: x[1],
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
entries = [(uid, exp, get_level(exp)) for uid, exp in result]
|
|
||||||
return entries if top_n is None else entries[:top_n]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Extended leaderboards
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def get_leaderboard_season_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]:
|
|
||||||
"""Return (user_id, season_total_exp, prestige_level) sorted by season EXP."""
|
|
||||||
records = await pb_client.list_all_records()
|
|
||||||
result = sorted(
|
|
||||||
(
|
|
||||||
(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0))
|
|
||||||
for r in records if r.get("user_id")
|
|
||||||
),
|
|
||||||
key=lambda x: x[1],
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
return result if top_n is None else result[:top_n]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_leaderboard_prestige(top_n: int | None = 10) -> list[tuple[str, int, int]]:
|
|
||||||
"""Return (user_id, prestige_level, prestige_points) sorted by prestige_level then PP."""
|
|
||||||
records = await pb_client.list_all_records()
|
|
||||||
result = sorted(
|
|
||||||
(
|
|
||||||
(r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0))
|
|
||||||
for r in records if r.get("user_id")
|
|
||||||
),
|
|
||||||
key=lambda x: (x[1], x[2]),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
return result if top_n is None else result[:top_n]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_leaderboard_wagered(top_n: int | None = 10) -> list[tuple[str, int]]:
|
|
||||||
"""Return (user_id, total_wagered) sorted descending."""
|
|
||||||
records = await pb_client.list_all_records()
|
|
||||||
result = sorted(
|
|
||||||
((r["user_id"], r.get("total_wagered", 0)) for r in records if r.get("user_id")),
|
|
||||||
key=lambda x: x[1],
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
return result if top_n is None else result[:top_n]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]:
|
|
||||||
"""Return (user_id, total_fish_caught) sorted descending."""
|
|
||||||
records = await pb_client.list_all_records()
|
|
||||||
result = sorted(
|
|
||||||
((r["user_id"], r.get("total_fish_caught", 0)) for r in records if r.get("user_id")),
|
|
||||||
key=lambda x: x[1],
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
return result if top_n is None else result[:top_n]
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""EXP, levels and vanity role thresholds."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
|
|
||||||
from .store import _locked_by, _prestige_mult, get_user, _commit
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# EXP / Level system
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# EXP awarded per successful action
|
|
||||||
EXP_REWARDS: dict[str, int] = {
|
|
||||||
"daily": 50,
|
|
||||||
"work": 25,
|
|
||||||
"beg": 5,
|
|
||||||
"crime_win": 15,
|
|
||||||
"rob_win": 15,
|
|
||||||
"gamble_win": 10,
|
|
||||||
"heist_win": 25,
|
|
||||||
}
|
|
||||||
|
|
||||||
def gamble_exp(bet: int) -> int:
|
|
||||||
"""Scale EXP for a gambling win by bet size.
|
|
||||||
Returns 0 for bets < 10 coins to close micro-bet EXP farming.
|
|
||||||
10-99 → 5, 100-999 → 10, 1 000-9 999 → 15, 10 000+ → 20, 100 000+ → 25 (cap).
|
|
||||||
"""
|
|
||||||
return min(25, max(0, int(math.log10(max(1, bet))) * 5))
|
|
||||||
|
|
||||||
|
|
||||||
ECONOMY_ROLE = "ECONOMY"
|
|
||||||
|
|
||||||
# Vanity role milestones: (min_level, role_name) - highest first
|
|
||||||
LEVEL_ROLES: list[tuple[int, str]] = [
|
|
||||||
(30, "TipiLEGEND"),
|
|
||||||
(20, "TipiCHAD"),
|
|
||||||
(10, "TipiHUSTLER"),
|
|
||||||
(5, "TipiGRINDER"),
|
|
||||||
(1, "TipiNOOB"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_level(exp: int) -> int:
|
|
||||||
"""Level = max(1, floor(sqrt(exp/10))).
|
|
||||||
Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000."""
|
|
||||||
return max(1, int(math.sqrt(max(0, exp) / 10)))
|
|
||||||
|
|
||||||
|
|
||||||
def exp_for_level(level: int) -> int:
|
|
||||||
"""Minimum cumulative EXP to reach this level.
|
|
||||||
Recurrence: exp_for_level(L) = L*20 - 10 + exp_for_level(L-1), base 0.
|
|
||||||
Closed form: 10*level^2."""
|
|
||||||
if level <= 1:
|
|
||||||
return 0
|
|
||||||
return 10 * level * level
|
|
||||||
|
|
||||||
|
|
||||||
def level_role_name(level: int) -> str:
|
|
||||||
"""Return the vanity role name for a given level."""
|
|
||||||
for threshold, name in LEVEL_ROLES:
|
|
||||||
if level >= threshold:
|
|
||||||
return name
|
|
||||||
return LEVEL_ROLES[-1][1]
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def award_exp(user_id: int, amount: int) -> dict:
|
|
||||||
"""Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
_, exp_mult = _prestige_mult(user)
|
|
||||||
gained = max(1, int(amount * exp_mult))
|
|
||||||
old_exp = user.get("exp", 0)
|
|
||||||
new_exp = old_exp + gained
|
|
||||||
old_level = get_level(old_exp)
|
|
||||||
new_level = get_level(new_exp)
|
|
||||||
user["exp"] = new_exp
|
|
||||||
user["season_total_exp"] = user.get("season_total_exp", 0) + gained
|
|
||||||
await _commit(user_id, user)
|
|
||||||
return {"old_level": old_level, "new_level": new_level, "exp": new_exp, "gained": gained}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Prestige resets and the prestige upgrade shop."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
|
||||||
from ..emoji import EMOJI as E
|
|
||||||
from .store import PRESTIGE_SHOP, _commit, _locked_by, _txn, get_user
|
|
||||||
from .levels import get_level
|
|
||||||
|
|
||||||
|
|
||||||
PRESTIGE_ROLE = "TipiPRESTIGE"
|
|
||||||
PRESTIGE_MIN_LEVEL = 30 # minimum level required to prestige
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /prestige
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_prestige(user_id: int) -> dict:
|
|
||||||
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
|
|
||||||
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"}
|
|
||||||
|
|
||||||
exp = user.get("exp", 0)
|
|
||||||
level = get_level(exp)
|
|
||||||
if level < PRESTIGE_MIN_LEVEL:
|
|
||||||
return {"ok": False, "reason": "level_too_low", "level": level, "required": PRESTIGE_MIN_LEVEL}
|
|
||||||
|
|
||||||
pp_earned = max(1, exp // 1000)
|
|
||||||
new_prestige_level = user.get("prestige_level", 0) + 1
|
|
||||||
|
|
||||||
# Preserve: fish_book, fish_inventory, lifetime stats, prestige_points, season_total_exp, prestige_upgrades
|
|
||||||
user["balance"] = 0
|
|
||||||
user["exp"] = 0
|
|
||||||
user["items"] = []
|
|
||||||
user["item_uses"] = {}
|
|
||||||
user["last_daily"] = None
|
|
||||||
user["last_work"] = None
|
|
||||||
user["last_beg"] = None
|
|
||||||
user["last_crime"] = None
|
|
||||||
user["last_rob"] = None
|
|
||||||
user["last_fish"] = None
|
|
||||||
user["last_heist"] = None
|
|
||||||
user["daily_streak"] = 0
|
|
||||||
user["last_streak_date"] = None
|
|
||||||
user["jailed_until"] = None
|
|
||||||
user["jailbreak_used"] = False
|
|
||||||
user["prestige_level"] = new_prestige_level
|
|
||||||
user["prestige_points"] = user.get("prestige_points", 0) + pp_earned
|
|
||||||
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("PRESTIGE", user=user_id, pp_earned=pp_earned, prestige=new_prestige_level, old_exp=exp)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"pp_earned": pp_earned,
|
|
||||||
"prestige_level": new_prestige_level,
|
|
||||||
"prestige_points": user["prestige_points"],
|
|
||||||
"old_exp": exp,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict:
|
|
||||||
"""Spend PP to buy a prestige upgrade level."""
|
|
||||||
if upgrade_id not in PRESTIGE_SHOP:
|
|
||||||
return {"ok": False, "reason": "not_found"}
|
|
||||||
|
|
||||||
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"}
|
|
||||||
|
|
||||||
upgrade = PRESTIGE_SHOP[upgrade_id]
|
|
||||||
upgrades: dict = user.get("prestige_upgrades") or {}
|
|
||||||
current_level = upgrades.get(upgrade_id, 0)
|
|
||||||
|
|
||||||
if current_level >= upgrade["max_level"]:
|
|
||||||
return {"ok": False, "reason": "maxed", "max": upgrade["max_level"]}
|
|
||||||
|
|
||||||
pp = user.get("prestige_points", 0)
|
|
||||||
cost = upgrade["pp_cost"]
|
|
||||||
if pp < cost:
|
|
||||||
return {"ok": False, "reason": "insufficient_pp", "have": pp, "need": cost}
|
|
||||||
|
|
||||||
upgrades[upgrade_id] = current_level + 1
|
|
||||||
user["prestige_upgrades"] = upgrades
|
|
||||||
user["prestige_points"] = pp - cost
|
|
||||||
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("PRESTIGE_BUY", user=user_id, upgrade=upgrade_id,
|
|
||||||
new_level=upgrades[upgrade_id], pp_left=user["prestige_points"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"upgrade_id": upgrade_id,
|
|
||||||
"new_level": upgrades[upgrade_id],
|
|
||||||
"max_level": upgrade["max_level"],
|
|
||||||
"pp_remaining": user["prestige_points"],
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"""Daily/weekly quests tracked from lifetime counters."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
from .store import (
|
|
||||||
UserData, _commit, _locked_by, _log, _now, _prestige_mult, get_user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Quest system
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Quests reuse the monotonic lifetime counters already tracked on each user.
|
|
||||||
# Progress = (current counter value) - (snapshot taken when the quest was rolled).
|
|
||||||
# Reset is lazy & per-user: the active set is regenerated the first time a user
|
|
||||||
# interacts after the day / ISO-week rolls over (mirrors the streak-date logic).
|
|
||||||
# Rotation is seeded by user id + period key, so each player gets their own set.
|
|
||||||
class QuestDef(TypedDict):
|
|
||||||
stat: str # UserData counter field the quest tracks
|
|
||||||
goal: int
|
|
||||||
coins: int
|
|
||||||
exp: int
|
|
||||||
|
|
||||||
|
|
||||||
QUESTS_DAILY: dict[str, QuestDef] = {
|
|
||||||
"work3": {"stat": "work_count", "goal": 3, "coins": 150, "exp": 20},
|
|
||||||
"beg5": {"stat": "beg_count", "goal": 5, "coins": 100, "exp": 15},
|
|
||||||
"wager500": {"stat": "total_wagered", "goal": 500, "coins": 150, "exp": 20},
|
|
||||||
"fish2": {"stat": "total_fish_caught", "goal": 2, "coins": 150, "exp": 20},
|
|
||||||
"crime1": {"stat": "crimes_succeeded", "goal": 1, "coins": 200, "exp": 25},
|
|
||||||
"earn1000": {"stat": "lifetime_earned", "goal": 1000, "coins": 150, "exp": 20},
|
|
||||||
"give200": {"stat": "total_given", "goal": 200, "coins": 100, "exp": 15},
|
|
||||||
}
|
|
||||||
|
|
||||||
QUESTS_WEEKLY: dict[str, QuestDef] = {
|
|
||||||
"work20": {"stat": "work_count", "goal": 20, "coins": 1000, "exp": 100},
|
|
||||||
"fish15": {"stat": "total_fish_caught", "goal": 15, "coins": 1200, "exp": 100},
|
|
||||||
"wager5000": {"stat": "total_wagered", "goal": 5000, "coins": 1000, "exp": 100},
|
|
||||||
"crime5": {"stat": "crimes_succeeded", "goal": 5, "coins": 1200, "exp": 120},
|
|
||||||
"heist1": {"stat": "heists_joined", "goal": 1, "coins": 800, "exp": 80},
|
|
||||||
"earn10000": {"stat": "lifetime_earned", "goal": 10000, "coins": 1500, "exp": 150},
|
|
||||||
}
|
|
||||||
|
|
||||||
DAILY_QUEST_COUNT = 3
|
|
||||||
WEEKLY_QUEST_COUNT = 2
|
|
||||||
|
|
||||||
|
|
||||||
def _period_keys() -> tuple[str, str]:
|
|
||||||
"""Return (day_key, week_key) for the current UTC time."""
|
|
||||||
today = _now().date()
|
|
||||||
iso = today.isocalendar()
|
|
||||||
return today.isoformat(), f"{iso[0]}-W{iso[1]:02d}"
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]:
|
|
||||||
"""Deterministically choose `count` quest ids from `pool` for a period."""
|
|
||||||
rng = random.Random(seed)
|
|
||||||
return rng.sample(sorted(pool.keys()), min(count, len(pool)))
|
|
||||||
|
|
||||||
|
|
||||||
def _new_quest_block(
|
|
||||||
user: UserData, user_id: int, pool: dict[str, QuestDef], count: int,
|
|
||||||
period_val: str, period_field: str
|
|
||||||
) -> dict:
|
|
||||||
chosen = _pick_quests(pool, count, f"{user_id}:{period_field}:{period_val}")
|
|
||||||
return {
|
|
||||||
period_field: period_val,
|
|
||||||
"quests": {
|
|
||||||
qid: {"snap": int(user.get(pool[qid]["stat"], 0) or 0), "claimed": False}
|
|
||||||
for qid in chosen
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_quests(user: UserData, user_id: int) -> bool:
|
|
||||||
"""Roll fresh daily/weekly quest sets if their period elapsed.
|
|
||||||
Mutates `user` in place; returns True if anything changed (caller commits)."""
|
|
||||||
changed = False
|
|
||||||
day_key, week_key = _period_keys()
|
|
||||||
if (user.get("quest_daily") or {}).get("date") != day_key:
|
|
||||||
user["quest_daily"] = _new_quest_block(user, user_id, QUESTS_DAILY, DAILY_QUEST_COUNT, day_key, "date")
|
|
||||||
changed = True
|
|
||||||
if (user.get("quest_weekly") or {}).get("week") != week_key:
|
|
||||||
user["quest_weekly"] = _new_quest_block(user, user_id, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week")
|
|
||||||
changed = True
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def _quest_progress(user: UserData, pool: dict[str, QuestDef], qid: str, state: dict) -> int:
|
|
||||||
cur = int(user.get(pool[qid]["stat"], 0) or 0)
|
|
||||||
return max(0, cur - int(state.get("snap", 0)))
|
|
||||||
|
|
||||||
|
|
||||||
def _quest_view(user: UserData) -> dict:
|
|
||||||
def build(pool: dict[str, QuestDef], block: dict) -> list[dict]:
|
|
||||||
out: list[dict] = []
|
|
||||||
for qid, state in (block.get("quests") or {}).items():
|
|
||||||
if qid not in pool:
|
|
||||||
continue
|
|
||||||
d = pool[qid]
|
|
||||||
prog = min(d["goal"], _quest_progress(user, pool, qid, state))
|
|
||||||
out.append({
|
|
||||||
"id": qid, "goal": d["goal"], "coins": d["coins"], "exp": d["exp"],
|
|
||||||
"progress": prog, "done": prog >= d["goal"], "claimed": bool(state.get("claimed")),
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
return {
|
|
||||||
"daily": build(QUESTS_DAILY, user.get("quest_daily") or {}),
|
|
||||||
"weekly": build(QUESTS_WEEKLY, user.get("quest_weekly") or {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def get_quests(user_id: int) -> dict:
|
|
||||||
"""Return the user's active quests, rolling new sets if the period elapsed."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
if _ensure_quests(user, user_id):
|
|
||||||
saved = await _commit(user_id, user)
|
|
||||||
if saved is not None and "quest_daily" not in saved:
|
|
||||||
_log.warning(
|
|
||||||
"PocketBase collection has no quest fields - quest state is not "
|
|
||||||
"persisted and progress will stay at 0. Run scripts/add_quest_fields.py."
|
|
||||||
)
|
|
||||||
return _quest_view(user)
|
|
||||||
|
|
||||||
|
|
||||||
@_locked_by(0)
|
|
||||||
async def claim_quests(user_id: int) -> dict:
|
|
||||||
"""Grant coins for every completed-but-unclaimed quest and mark them claimed.
|
|
||||||
Coins (with prestige coin_mult) are paid here; EXP is returned raw for the
|
|
||||||
caller to award via the shared award_exp path (keeps level-up notices)."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
_ensure_quests(user, user_id)
|
|
||||||
coin_mult, _ = _prestige_mult(user)
|
|
||||||
total_coins = total_exp = claimed = 0
|
|
||||||
for pool, block in (
|
|
||||||
(QUESTS_DAILY, user.get("quest_daily") or {}),
|
|
||||||
(QUESTS_WEEKLY, user.get("quest_weekly") or {}),
|
|
||||||
):
|
|
||||||
for qid, state in (block.get("quests") or {}).items():
|
|
||||||
if qid not in pool or state.get("claimed"):
|
|
||||||
continue
|
|
||||||
if _quest_progress(user, pool, qid, state) < pool[qid]["goal"]:
|
|
||||||
continue
|
|
||||||
total_coins += pool[qid]["coins"]
|
|
||||||
total_exp += pool[qid]["exp"]
|
|
||||||
state["claimed"] = True
|
|
||||||
claimed += 1
|
|
||||||
if not claimed:
|
|
||||||
return {"ok": False, "reason": "nothing"}
|
|
||||||
coins_awarded = int(total_coins * coin_mult)
|
|
||||||
user["balance"] += coins_awarded
|
|
||||||
user["lifetime_earned"] = user.get("lifetime_earned", 0) + coins_awarded
|
|
||||||
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
||||||
await _commit(user_id, user)
|
|
||||||
return {"ok": True, "claimed": claimed, "coins": coins_awarded, "exp": total_exp, "balance": user["balance"]}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
"""Shop catalogue and purchases."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
import strings
|
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
|
||||||
from ..emoji import EMOJI as E
|
|
||||||
from .store import _locked_by, _txn, get_user, _commit
|
|
||||||
from .levels import get_level
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shop catalogue
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
class ShopItem(TypedDict):
|
|
||||||
name: str
|
|
||||||
emoji: str
|
|
||||||
cost: int
|
|
||||||
description: str
|
|
||||||
|
|
||||||
|
|
||||||
SHOP: dict[str, ShopItem] = {
|
|
||||||
"gaming_hiir": {
|
|
||||||
"name": "Mängurihiir",
|
|
||||||
"emoji": E["TipiHIIR"],
|
|
||||||
"cost": 500,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["gaming_hiir"],
|
|
||||||
},
|
|
||||||
"hiirematt": {
|
|
||||||
"name": "Hiirematt",
|
|
||||||
"emoji": E["TipiMATT"],
|
|
||||||
"cost": 600,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["hiirematt"],
|
|
||||||
},
|
|
||||||
"korvaklapid": {
|
|
||||||
"name": "K\u00f5rvaklapid",
|
|
||||||
"emoji": E["TipiKLAPID"],
|
|
||||||
"cost": 1200,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["korvaklapid"],
|
|
||||||
},
|
|
||||||
"lan_pass": {
|
|
||||||
"name": "LAN pilet",
|
|
||||||
"emoji": E["TipiPILET"],
|
|
||||||
"cost": 1200,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["lan_pass"],
|
|
||||||
},
|
|
||||||
"energiajook": {
|
|
||||||
"name": "Red Bull",
|
|
||||||
"emoji": E["TipiBULL"],
|
|
||||||
"cost": 800,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["energiajook"],
|
|
||||||
},
|
|
||||||
"gaming_laptop": {
|
|
||||||
"name": "Bot Farm",
|
|
||||||
"emoji": E["TipiLAP"],
|
|
||||||
"cost": 1500,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["gaming_laptop"],
|
|
||||||
},
|
|
||||||
"anticheat": {
|
|
||||||
"name": "Anticheat",
|
|
||||||
"emoji": E["TipiVAC"],
|
|
||||||
"cost": 1000,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["anticheat"],
|
|
||||||
},
|
|
||||||
# ----- Tier 2 -----
|
|
||||||
"reguleeritav_laud": {
|
|
||||||
"name": "Reguleeritav laud",
|
|
||||||
"emoji": E["TipiLAUD"],
|
|
||||||
"cost": 3500,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["reguleeritav_laud"],
|
|
||||||
},
|
|
||||||
"jellyfin": {
|
|
||||||
"name": "Jellyfin server",
|
|
||||||
"emoji": E["TipiSERVER"],
|
|
||||||
"cost": 4000,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["jellyfin"],
|
|
||||||
},
|
|
||||||
"mikrofon": {
|
|
||||||
"name": "Eraldiseisev mikrofon",
|
|
||||||
"emoji": E["TipiMIC"],
|
|
||||||
"cost": 2800,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["mikrofon"],
|
|
||||||
},
|
|
||||||
"klaviatuur": {
|
|
||||||
"name": "Mehaaniline klaviatuur",
|
|
||||||
"emoji": E["TipiKLAVA"],
|
|
||||||
"cost": 1800,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["klaviatuur"],
|
|
||||||
},
|
|
||||||
"monitor": {
|
|
||||||
"name": "Ultralai monitor",
|
|
||||||
"emoji": E["TipiMONITOR"],
|
|
||||||
"cost": 2500,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["monitor"],
|
|
||||||
},
|
|
||||||
"cat6": {
|
|
||||||
"name": "Cat6 kaabel",
|
|
||||||
"emoji": E["TipiCAT"],
|
|
||||||
"cost": 3500,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["cat6"],
|
|
||||||
},
|
|
||||||
# ----- Tier 3 -----
|
|
||||||
"monitor_360": {
|
|
||||||
"name": "360Hz monitor",
|
|
||||||
"emoji": E["TipiMONITOR2"],
|
|
||||||
"cost": 7500,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["monitor_360"],
|
|
||||||
},
|
|
||||||
"karikas": {
|
|
||||||
"name": "TipiLAN karikas",
|
|
||||||
"emoji": E["TipiKARIKAS"],
|
|
||||||
"cost": 6000,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["karikas"],
|
|
||||||
},
|
|
||||||
"gaming_tool": {
|
|
||||||
"name": "Gaming tool",
|
|
||||||
"emoji": E["TipiTOOL"],
|
|
||||||
"cost": 9000,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["gaming_tool"],
|
|
||||||
},
|
|
||||||
# ----- Fishing items -----
|
|
||||||
"ussipurk": {
|
|
||||||
"name": "Ussipurk",
|
|
||||||
"emoji": "🪣",
|
|
||||||
"cost": 3500,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["ussipurk"],
|
|
||||||
},
|
|
||||||
"kalavork": {
|
|
||||||
"name": "Kalavõrk",
|
|
||||||
"emoji": "🪝",
|
|
||||||
"cost": 5000,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["kalavork"],
|
|
||||||
},
|
|
||||||
"echolood": {
|
|
||||||
"name": "Echolood",
|
|
||||||
"emoji": "📡",
|
|
||||||
"cost": 8000,
|
|
||||||
"description": strings.ITEM_DESCRIPTIONS["echolood"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Tier grouping (used by /shop pagination)
|
|
||||||
SHOP_TIERS: dict[int, list[str]] = {
|
|
||||||
1: ["gaming_hiir", "hiirematt", "korvaklapid", "lan_pass", "energiajook", "anticheat", "gaming_laptop"],
|
|
||||||
2: ["reguleeritav_laud", "jellyfin", "mikrofon", "klaviatuur", "monitor", "cat6", "ussipurk"],
|
|
||||||
3: ["monitor_360", "karikas", "gaming_tool", "kalavork", "echolood"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Minimum level required to purchase Tier 2 / Tier 3 shop items
|
|
||||||
SHOP_LEVEL_REQ: dict[str, int] = {
|
|
||||||
"reguleeritav_laud": 10,
|
|
||||||
"jellyfin": 10,
|
|
||||||
"mikrofon": 10,
|
|
||||||
"klaviatuur": 10,
|
|
||||||
"monitor": 10,
|
|
||||||
"cat6": 10,
|
|
||||||
"ussipurk": 10,
|
|
||||||
"monitor_360": 20,
|
|
||||||
"karikas": 20,
|
|
||||||
"gaming_tool": 20,
|
|
||||||
"kalavork": 20,
|
|
||||||
"echolood": 20,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /buy
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_buy(user_id: int, item_id: str) -> dict:
|
|
||||||
if item_id not in SHOP:
|
|
||||||
return {"ok": False, "reason": "not_found"}
|
|
||||||
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"}
|
|
||||||
|
|
||||||
item = SHOP[item_id]
|
|
||||||
if item_id in user["items"]:
|
|
||||||
# Allow repurchase of anticheat if uses are depleted
|
|
||||||
if item_id == "anticheat" and user.get("item_uses", {}).get("anticheat", 2) <= 0:
|
|
||||||
user["items"] = [i for i in user["items"] if i != "anticheat"]
|
|
||||||
user.get("item_uses", {}).pop("anticheat", None)
|
|
||||||
else:
|
|
||||||
return {"ok": False, "reason": "owned"}
|
|
||||||
min_level = SHOP_LEVEL_REQ.get(item_id, 0)
|
|
||||||
if min_level > 0:
|
|
||||||
user_level = get_level(user.get("exp", 0))
|
|
||||||
if user_level < min_level:
|
|
||||||
return {"ok": False, "reason": "level_required", "min_level": min_level, "user_level": user_level}
|
|
||||||
if user["balance"] < item["cost"]:
|
|
||||||
return {"ok": False, "reason": "insufficient", "need": item["cost"] - user["balance"]}
|
|
||||||
|
|
||||||
user["balance"] -= item["cost"]
|
|
||||||
user["items"].append(item_id)
|
|
||||||
if item_id == "anticheat":
|
|
||||||
if "item_uses" not in user:
|
|
||||||
user["item_uses"] = {}
|
|
||||||
user["item_uses"]["anticheat"] = 2
|
|
||||||
await _commit(user_id, user)
|
|
||||||
_txn("BUY", user=user_id, item=item_id, cost=f"-{item['cost']}", bal=user["balance"])
|
|
||||||
|
|
||||||
return {"ok": True, "item": item, "balance": user["balance"]}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
"""Shared foundation: user records, locks, time, cooldowns, txn log."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import functools
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from .. import pb_client
|
|
||||||
from ..pb_client import DatabaseError
|
|
||||||
from ..emoji import EMOJI as E
|
|
||||||
|
|
||||||
|
|
||||||
def _clock() -> datetime:
|
|
||||||
"""Actual time source - a seam so tests can freeze time everywhere at once."""
|
|
||||||
return datetime.now(tz=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return _clock()
|
|
||||||
|
|
||||||
|
|
||||||
_txn_log = logging.getLogger("tipiCOIN.txn")
|
|
||||||
|
|
||||||
|
|
||||||
def _txn(event: str, **fields) -> None:
|
|
||||||
"""Log a single economy transaction to the transactions logger."""
|
|
||||||
body = " ".join(f"{k}={v}" for k, v in fields.items())
|
|
||||||
_txn_log.info("%-16s %s", event, body)
|
|
||||||
|
|
||||||
|
|
||||||
# Per-profile emoji values live in core/emoji.py; add new IDs there.
|
|
||||||
COIN = E["TipiCOIN"]
|
|
||||||
PP_EMOJI = E["TipiFIRE"]
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Prestige shop catalogue
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
class PrestigeItem(TypedDict):
|
|
||||||
emoji: str
|
|
||||||
max_level: int
|
|
||||||
pp_cost: int
|
|
||||||
effect: float
|
|
||||||
|
|
||||||
|
|
||||||
PRESTIGE_SHOP: dict[str, PrestigeItem] = {
|
|
||||||
"coin_mult": {
|
|
||||||
"emoji": E["TipiCOIN"],
|
|
||||||
"max_level": 5,
|
|
||||||
"pp_cost": 5,
|
|
||||||
"effect": 0.08,
|
|
||||||
},
|
|
||||||
"exp_mult": {
|
|
||||||
"emoji": "✨",
|
|
||||||
"max_level": 5,
|
|
||||||
"pp_cost": 5,
|
|
||||||
"effect": 0.08,
|
|
||||||
},
|
|
||||||
"daily_plus": {
|
|
||||||
"emoji": "📅",
|
|
||||||
"max_level": 3,
|
|
||||||
"pp_cost": 7,
|
|
||||||
"effect": 0.20,
|
|
||||||
},
|
|
||||||
"work_plus": {
|
|
||||||
"emoji": "💼",
|
|
||||||
"max_level": 3,
|
|
||||||
"pp_cost": 7,
|
|
||||||
"effect": 0.20,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cooldowns
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
COOLDOWNS: dict[str, timedelta] = {
|
|
||||||
"daily": timedelta(hours=20),
|
|
||||||
"work": timedelta(hours=1),
|
|
||||||
"beg": timedelta(minutes=5),
|
|
||||||
"crime": timedelta(hours=2),
|
|
||||||
"rob": timedelta(hours=2),
|
|
||||||
"fish": timedelta(minutes=2),
|
|
||||||
}
|
|
||||||
|
|
||||||
JAIL_DURATION = timedelta(minutes=30)
|
|
||||||
HEIST_JAIL = timedelta(hours=1, minutes=30)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# User schema
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
class UserData(TypedDict, total=False):
|
|
||||||
balance: int
|
|
||||||
exp: int # lifetime EXP (resets each season)
|
|
||||||
last_daily: str | None
|
|
||||||
last_work: str | None
|
|
||||||
last_beg: str | None
|
|
||||||
last_crime: str | None
|
|
||||||
last_rob: str | None
|
|
||||||
last_heist: str | None
|
|
||||||
daily_streak: int
|
|
||||||
last_streak_date: str | None # ISO date "YYYY-MM-DD"
|
|
||||||
items: list[str]
|
|
||||||
item_uses: dict # {item_id: remaining_uses} for consumables
|
|
||||||
jailed_until: str | None # ISO datetime or None
|
|
||||||
jailbreak_used: bool
|
|
||||||
reminders: list[str] # command names user wants DM reminders for
|
|
||||||
eco_banned: bool # if True, user cannot use any economy commands
|
|
||||||
# Lifetime statistics
|
|
||||||
peak_balance: int
|
|
||||||
lifetime_earned: int
|
|
||||||
lifetime_lost: int
|
|
||||||
work_count: int
|
|
||||||
beg_count: int
|
|
||||||
total_wagered: int
|
|
||||||
biggest_win: int
|
|
||||||
biggest_loss: int
|
|
||||||
slots_jackpots: int
|
|
||||||
crimes_attempted: int
|
|
||||||
crimes_succeeded: int
|
|
||||||
times_jailed: int
|
|
||||||
total_bail_paid: int
|
|
||||||
heists_joined: int
|
|
||||||
heists_won: int
|
|
||||||
total_given: int
|
|
||||||
total_received: int
|
|
||||||
best_daily_streak: int
|
|
||||||
heist_global_cd_until: float
|
|
||||||
# Prestige system
|
|
||||||
prestige_level: int
|
|
||||||
prestige_points: int
|
|
||||||
season_total_exp: int # cumulative EXP this season (survives prestige resets)
|
|
||||||
prestige_upgrades: dict # {upgrade_id: level}
|
|
||||||
# Fishing system
|
|
||||||
last_fish: str | None
|
|
||||||
fish_book: dict # {fish_id: times_caught}
|
|
||||||
total_fish_caught: int
|
|
||||||
fish_inventory: list # [{fish_id, weight, value}] - survives prestige
|
|
||||||
# Quest system
|
|
||||||
quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}}
|
|
||||||
quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}}
|
|
||||||
|
|
||||||
|
|
||||||
def _default_user() -> UserData:
|
|
||||||
return {
|
|
||||||
"balance": 0,
|
|
||||||
"exp": 0,
|
|
||||||
"last_daily": None,
|
|
||||||
"last_work": None,
|
|
||||||
"last_beg": None,
|
|
||||||
"last_crime": None,
|
|
||||||
"last_rob": None,
|
|
||||||
"last_heist": None,
|
|
||||||
"daily_streak": 0,
|
|
||||||
"last_streak_date": None,
|
|
||||||
"items": [],
|
|
||||||
"item_uses": {},
|
|
||||||
"jailed_until": None,
|
|
||||||
"jailbreak_used": False,
|
|
||||||
"reminders": ["daily", "work", "beg", "crime", "rob"],
|
|
||||||
"eco_banned": False,
|
|
||||||
# ── Lifetime stats ──────────────────────────────────────────────────
|
|
||||||
"peak_balance": 0,
|
|
||||||
"lifetime_earned": 0,
|
|
||||||
"lifetime_lost": 0,
|
|
||||||
"work_count": 0,
|
|
||||||
"beg_count": 0,
|
|
||||||
"total_wagered": 0,
|
|
||||||
"biggest_win": 0,
|
|
||||||
"biggest_loss": 0,
|
|
||||||
"slots_jackpots": 0,
|
|
||||||
"crimes_attempted": 0,
|
|
||||||
"crimes_succeeded": 0,
|
|
||||||
"times_jailed": 0,
|
|
||||||
"total_bail_paid": 0,
|
|
||||||
"heists_joined": 0,
|
|
||||||
"heists_won": 0,
|
|
||||||
"total_given": 0,
|
|
||||||
"total_received": 0,
|
|
||||||
"best_daily_streak": 0,
|
|
||||||
"heist_global_cd_until": 0.0,
|
|
||||||
# ── Prestige ─────────────────────────────────────────────────────────
|
|
||||||
"prestige_level": 0,
|
|
||||||
"prestige_points": 0,
|
|
||||||
"season_total_exp": 0,
|
|
||||||
"prestige_upgrades": {},
|
|
||||||
# ── Fishing ──────────────────────────────────────────────────────────
|
|
||||||
"last_fish": None,
|
|
||||||
"fish_book": {},
|
|
||||||
"total_fish_caught": 0,
|
|
||||||
"fish_inventory": [],
|
|
||||||
# ── Quests ───────────────────────────────────────────────────────────
|
|
||||||
"quest_daily": {},
|
|
||||||
"quest_weekly": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Persistence (PocketBase backend)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_log = logging.getLogger("tipiCOIN.economy")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Per-user write locks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Every mutation is a read-modify-write cycle (get_user → mutate → _commit);
|
|
||||||
# without serialization, two concurrent commands for the same user overwrite
|
|
||||||
# each other's commit. Locking rules that keep this deadlock-free:
|
|
||||||
# - a decorated function must never call another decorated function
|
|
||||||
# - house balance changes go through _credit_house (an atomic PocketBase
|
|
||||||
# increment, no lock), so they are safe while holding user locks
|
|
||||||
_user_locks: dict[int, asyncio.Lock] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _user_lock(user_id: int) -> asyncio.Lock:
|
|
||||||
lock = _user_locks.get(user_id)
|
|
||||||
if lock is None:
|
|
||||||
lock = _user_locks[user_id] = asyncio.Lock()
|
|
||||||
return lock
|
|
||||||
|
|
||||||
|
|
||||||
def _locked_by(*arg_positions: int):
|
|
||||||
"""Serialize the decorated function per user id found at the given
|
|
||||||
positional-argument indices. Multiple ids are acquired in sorted order so
|
|
||||||
two-user functions (do_give, do_rob) cannot deadlock each other."""
|
|
||||||
def decorator(fn):
|
|
||||||
@functools.wraps(fn)
|
|
||||||
async def wrapper(*args, **kwargs):
|
|
||||||
locks = [_user_lock(uid) for uid in sorted({args[pos] for pos in arg_positions})]
|
|
||||||
for lock in locks:
|
|
||||||
await lock.acquire()
|
|
||||||
try:
|
|
||||||
return await fn(*args, **kwargs)
|
|
||||||
finally:
|
|
||||||
for lock in reversed(locks):
|
|
||||||
lock.release()
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def missing_schema_fields() -> list[str]:
|
|
||||||
"""Compare the live PocketBase collection schema against every field the
|
|
||||||
bot persists. PocketBase silently drops writes to undeclared fields, so
|
|
||||||
any name returned here means broken features without error messages."""
|
|
||||||
live = await pb_client.get_collection_fields()
|
|
||||||
expected = set(_default_user()) | {"user_id"}
|
|
||||||
return sorted(expected - live)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_all_users_raw() -> dict[str, "UserData"]:
|
|
||||||
"""Return a snapshot of all user records."""
|
|
||||||
records = await pb_client.list_all_records()
|
|
||||||
result: dict[str, UserData] = {}
|
|
||||||
for record in records:
|
|
||||||
uid = record.get("user_id", "")
|
|
||||||
if not uid:
|
|
||||||
continue
|
|
||||||
user = _default_user()
|
|
||||||
for key in list(user.keys()):
|
|
||||||
if key in record:
|
|
||||||
user[key] = record[key] # type: ignore[literal-required]
|
|
||||||
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
|
|
||||||
result[uid] = user
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_dt(s: str | None) -> datetime | None:
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
dt = datetime.fromisoformat(s)
|
|
||||||
# Ensure timezone-aware
|
|
||||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _cooldown_remaining(
|
|
||||||
user: UserData, action: str, override_cd: timedelta | None = None
|
|
||||||
) -> timedelta | None:
|
|
||||||
"""Return remaining cooldown, or None if the action is ready."""
|
|
||||||
last = _parse_dt(user.get(f"last_{action}"))
|
|
||||||
if last is None:
|
|
||||||
return None
|
|
||||||
cd = override_cd if override_cd is not None else COOLDOWNS[action]
|
|
||||||
remaining = cd - (_now() - last)
|
|
||||||
return remaining if remaining.total_seconds() > 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_jailed(user: UserData) -> timedelta | None:
|
|
||||||
"""Return remaining jail time, or None if free."""
|
|
||||||
until = _parse_dt(user.get("jailed_until"))
|
|
||||||
if until is None:
|
|
||||||
return None
|
|
||||||
remaining = until - _now()
|
|
||||||
return remaining if remaining.total_seconds() > 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def jailed_remaining(user: UserData) -> timedelta | None:
|
|
||||||
"""Public wrapper - return remaining jail time, or None if free."""
|
|
||||||
return _is_jailed(user)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
def format_td(td: timedelta) -> str:
|
|
||||||
"""Human-readable timedelta: '1t 23m' / '45m 12s' / '8s'."""
|
|
||||||
total = int(td.total_seconds())
|
|
||||||
h, rem = divmod(total, 3600)
|
|
||||||
m, s = divmod(rem, 60)
|
|
||||||
if h:
|
|
||||||
return f"{h}t {m}m"
|
|
||||||
if m:
|
|
||||||
return f"{m}m {s}s"
|
|
||||||
return f"{s}s"
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user(user_id: int) -> UserData:
|
|
||||||
"""Fetch user data from PocketBase, creating a default record if first seen."""
|
|
||||||
uid = str(user_id)
|
|
||||||
try:
|
|
||||||
record = await pb_client.get_record(uid)
|
|
||||||
if record is None:
|
|
||||||
default = _default_user()
|
|
||||||
default["user_id"] = uid # type: ignore[typeddict-unknown-key]
|
|
||||||
record = await pb_client.create_record(default)
|
|
||||||
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()
|
|
||||||
for key in list(user.keys()):
|
|
||||||
if key in record:
|
|
||||||
user[key] = record[key] # type: ignore[literal-required]
|
|
||||||
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def _prestige_mult(user: UserData) -> tuple[float, float]:
|
|
||||||
"""Return (coin_mult, exp_mult) based on prestige upgrades. Both ≥1.0."""
|
|
||||||
upgrades: dict = user.get("prestige_upgrades") or {} # type: ignore[assignment]
|
|
||||||
coin_level = upgrades.get("coin_mult", 0)
|
|
||||||
exp_level = upgrades.get("exp_mult", 0)
|
|
||||||
return (
|
|
||||||
1.0 + coin_level * PRESTIGE_SHOP["coin_mult"]["effect"],
|
|
||||||
1.0 + exp_level * PRESTIGE_SHOP["exp_mult"]["effect"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal write helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def _commit(user_id: int, user: UserData) -> dict | None:
|
|
||||||
"""Persist the full user record. Returns the record as PocketBase stored it
|
|
||||||
(fields absent from the collection schema are silently dropped by PB)."""
|
|
||||||
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:
|
|
||||||
if record_id:
|
|
||||||
return await pb_client.update_record(record_id, clean)
|
|
||||||
else:
|
|
||||||
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
|
|
||||||
created = await pb_client.create_record(clean)
|
|
||||||
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
|
|
||||||
return created
|
|
||||||
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
|
|
||||||
_log.error("_commit failed for user %s: %s", user_id, exc)
|
|
||||||
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /reminders
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
@_locked_by(0)
|
|
||||||
async def do_set_reminders(user_id: int, commands: list[str]) -> None:
|
|
||||||
"""Overwrite the user's reminder list with the given command names."""
|
|
||||||
user = await get_user(user_id)
|
|
||||||
user["reminders"] = list(commands)
|
|
||||||
await _commit(user_id, user)
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"""Custom Discord emoji registry, keyed by symbolic name and resolved per BOT_PROFILE.
|
|
||||||
|
|
||||||
Emojis are uploaded as application emojis via the Discord Developer Portal
|
|
||||||
and are scoped to a single bot application. Dev and production are separate
|
|
||||||
applications, so the same logical emoji has a different ID in each — hence
|
|
||||||
two dicts below, selected by BOT_PROFILE.
|
|
||||||
|
|
||||||
To add a new emoji: upload it to both applications in the dev portal, grab
|
|
||||||
the two IDs, and add one line to each dict.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from config import BOT_PROFILE
|
|
||||||
|
|
||||||
|
|
||||||
_DEV: dict[str, str] = {
|
|
||||||
"TipiCOIN": "<:TipiCOIN:1483000209188589628>",
|
|
||||||
"TipiFIRE": "<:TipiFIRE:1483431381668335687>",
|
|
||||||
"TipiHIIR": "<:TipiHIIR:1483004306012504128>",
|
|
||||||
"TipiMATT": "<:TipiMATT:1483387697132208128>",
|
|
||||||
"TipiKLAPID": "<:TipiKLAPID:1483387694083084349>",
|
|
||||||
"TipiPILET": "<:TipiPILET:1483004308353060904>",
|
|
||||||
"TipiBULL": "<:TipiBULL:1483004310924300409>",
|
|
||||||
"TipiLAP": "<:TipiLAP:1483004307161874566>",
|
|
||||||
"TipiVAC": "<:TipiVAC:1483004309510819860>",
|
|
||||||
"TipiLAUD": "<:TipiLAUD:1483387695576125440>",
|
|
||||||
"TipiSERVER": "<:TipiSERVER:1483387701032910969>",
|
|
||||||
"TipiMIC": "<:TipiMIC:1483387698499551313>",
|
|
||||||
"TipiKLAVA": "<:TipiKLAVA:1483014339228078140>",
|
|
||||||
"TipiMONITOR": "<:TipiMONITOR:1483014340327243908>",
|
|
||||||
"TipiCAT": "<:TipiCAT:1483014337663602718>",
|
|
||||||
"TipiMONITOR2": "<:TipiMONITOR2:1483387699514839162>",
|
|
||||||
"TipiKARIKAS": "<:TipiKARIKAS:1483014841148112977>",
|
|
||||||
"TipiTOOL": "<:TipiTOOL:1483014341648187613>",
|
|
||||||
"TipiHEART": "<:TipiHEART:1483431377561976853>",
|
|
||||||
"TipiTROLL": "<:TipiTROLL:1483431380166774895>",
|
|
||||||
"TipICRY": "<:TipICRY:1483431288852709387>",
|
|
||||||
"TipiSKULL": "<:TipiSKULL:1483431378929451028>",
|
|
||||||
"TipiDICE": "<a:TipiDICE:1485923107108556950>",
|
|
||||||
"TipiYKS": "<:TipiYKS:1483103190491856916>",
|
|
||||||
"TipiKAKS": "<:TipiKAKS:1483103215841972404>",
|
|
||||||
"TipiKOLM": "<:TipiKOLM:1483103217846980781>",
|
|
||||||
"TipiNELI": "<:TipiNELI:1483103237585240114>",
|
|
||||||
"TipiVIIS": "<:TipiVIIS:1483103239036469289>",
|
|
||||||
"TipiKUUS": "<:TipiKUUS:1483103253163020348>",
|
|
||||||
"TipiSLOTS": "<a:TipiSLOTS:1483444233863037101>",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Production application emoji IDs (from the TipiBOT application's dev-portal
|
|
||||||
# Emojis tab). The display name in <:name:id> is cosmetic — Discord resolves
|
|
||||||
# by ID — so we keep the same logical names as _DEV even when the prod portal
|
|
||||||
# uses a different upload name (e.g. prod's "TipiFIVE" → key "TipiVIIS").
|
|
||||||
_ECONOMY: dict[str, str] = {
|
|
||||||
"TipiCOIN": "<:TipiCOIN:1511754551747940485>",
|
|
||||||
"TipiFIRE": "<:TipiFIRE:1511754615761272862>",
|
|
||||||
"TipiHIIR": "<:TipiHIIR:1511754556105822218>",
|
|
||||||
"TipiMATT": "<:TipiMATT:1511754595448131665>",
|
|
||||||
"TipiKLAPID": "<:TipiKLAPID:1511754589798666433>",
|
|
||||||
"TipiPILET": "<:TipiPILET:1511754560593727652>",
|
|
||||||
"TipiBULL": "<:TipiBULL:1511754564179595264>",
|
|
||||||
"TipiLAP": "<:TipiLAP:1511754558970269907>",
|
|
||||||
"TipiVAC": "<:TipiVAC:1511754562413924442>",
|
|
||||||
"TipiLAUD": "<:TipiLAUD:1511754592759713985>",
|
|
||||||
"TipiSERVER": "<:TipiSERVER:1511754601186066534>",
|
|
||||||
"TipiMIC": "<:TipiMIC:1511754597016801310>",
|
|
||||||
"TipiKLAVA": "<:TipiKLAVA:1511754567648542780>",
|
|
||||||
"TipiMONITOR": "<:TipiMONITOR:1511754570722971868>",
|
|
||||||
"TipiCAT": "<:TipiCAT:1511754566092193853>",
|
|
||||||
"TipiMONITOR2": "<:TipiMONITOR2:1511754598732402689>",
|
|
||||||
"TipiKARIKAS": "<:TipiKARIKAS:1511754574405435502>",
|
|
||||||
"TipiTOOL": "<:TipiTOOL:1511754572522061874>",
|
|
||||||
"TipiHEART": "<:TipiHEART:1511754608299737139>",
|
|
||||||
"TipiTROLL": "<:TipiTROLL:1511754612775063832>",
|
|
||||||
"TipICRY": "<:TipICRY:1511754603308515368>",
|
|
||||||
"TipiSKULL": "<:TipiSKULL:1511754610195566622>",
|
|
||||||
"TipiDICE": "<a:TipiDICE:1511753607119376504>",
|
|
||||||
"TipiYKS": "<:TipiYKS:1511754576368373951>",
|
|
||||||
"TipiKAKS": "<:TipiKAKS:1511754577928523997>",
|
|
||||||
"TipiKOLM": "<:TipiKOLM:1511754581078442005>",
|
|
||||||
"TipiNELI": "<:TipiNELI:1511754582571880509>",
|
|
||||||
"TipiVIIS": "<:TipiVIIS:1511754584182227005>",
|
|
||||||
"TipiKUUS": "<:TipiKUUS:1511754586262736977>",
|
|
||||||
"TipiSLOTS": "<a:TipiSLOTS:1511754521188106431>",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_EMOJI_SETS = {
|
|
||||||
"dev": _DEV,
|
|
||||||
"economy": _ECONOMY,
|
|
||||||
}
|
|
||||||
|
|
||||||
EMOJI: dict[str, str] = _EMOJI_SETS[BOT_PROFILE]
|
|
||||||
|
|
||||||
_missing = set(_DEV) - set(EMOJI)
|
|
||||||
if _missing:
|
|
||||||
raise RuntimeError(f"Emoji set {BOT_PROFILE!r} missing keys: {sorted(_missing)}")
|
|
||||||
868
core/lan_fienta.py
Normal file
868
core/lan_fienta.py
Normal file
@@ -0,0 +1,868 @@
|
|||||||
|
"""Fienta registration sync for the LAN bot profile.
|
||||||
|
|
||||||
|
The module keeps Fienta data in PocketBase, assigns Discord roles, and mirrors
|
||||||
|
public tournament teams into the LAN live registration sheet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import discord
|
||||||
|
import gspread
|
||||||
|
from google.oauth2.service_account import Credentials
|
||||||
|
|
||||||
|
import config
|
||||||
|
from . import pb_client
|
||||||
|
|
||||||
|
log = logging.getLogger("tipilan.fienta")
|
||||||
|
|
||||||
|
SCOPES = [
|
||||||
|
"https://www.googleapis.com/auth/spreadsheets",
|
||||||
|
"https://www.googleapis.com/auth/drive",
|
||||||
|
]
|
||||||
|
|
||||||
|
CS2_GENERAL_ROLE_ID = 1498736834656604251
|
||||||
|
LOL_GENERAL_ROLE_ID = 1498736949706490017
|
||||||
|
LANGUAGE_GENERAL_ROLE_ID = 1416417344984715366
|
||||||
|
CS2_CAPTAIN_ROLE_ID = 1498738332316860426
|
||||||
|
CS2_MANAGER_ROLE_ID = 1498738500558655679
|
||||||
|
|
||||||
|
EXISTING_LANGUAGE_ROLE_IDS = {
|
||||||
|
"EE": 1425781026482950245,
|
||||||
|
"LV": 1425781129528606740,
|
||||||
|
"FI": 1425781429618348073,
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIRMED_TEXT = "Kinnitatud"
|
||||||
|
PENDING_TEXT = "Kinnitamisel"
|
||||||
|
|
||||||
|
CANCELLED_STATUSES = {"CANCELLED", "REFUNDED", "EXPIRED", "VOIDED"}
|
||||||
|
BLOCKED_COUNTRY_CODES = {"BY", "RU"}
|
||||||
|
BLOCKED_COUNTRY_NAMES = {
|
||||||
|
"belarus",
|
||||||
|
"russia",
|
||||||
|
"russian federation",
|
||||||
|
"valgevene",
|
||||||
|
"venemaa",
|
||||||
|
"vene föderatsioon",
|
||||||
|
"vene foderatsioon",
|
||||||
|
}
|
||||||
|
|
||||||
|
TICKET_TYPES: dict[str, dict[str, Any]] = {
|
||||||
|
"595507": {
|
||||||
|
"game": "cs2",
|
||||||
|
"kind": "participant",
|
||||||
|
"sheet_public": True,
|
||||||
|
"main": True,
|
||||||
|
},
|
||||||
|
"595509": {
|
||||||
|
"game": "cs2",
|
||||||
|
"kind": "reserve",
|
||||||
|
"sheet_public": False,
|
||||||
|
"main": False,
|
||||||
|
},
|
||||||
|
"595510": {
|
||||||
|
"game": "cs2",
|
||||||
|
"kind": "manager",
|
||||||
|
"sheet_public": False,
|
||||||
|
"main": False,
|
||||||
|
},
|
||||||
|
"595912": {
|
||||||
|
"game": "lol",
|
||||||
|
"kind": "participant",
|
||||||
|
"sheet_public": True,
|
||||||
|
"main": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SHEET_CONFIG = {
|
||||||
|
"cs2": {"worksheet": "CS2", "start_row": 6, "end_row": 37, "cols": 6},
|
||||||
|
"lol": {"worksheet": "LoL", "start_row": 6, "end_row": 17, "cols": 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
COUNTRY_CODE_BY_NAME = {
|
||||||
|
"afghanistan": "AF",
|
||||||
|
"albaania": "AL",
|
||||||
|
"albania": "AL",
|
||||||
|
"andorra": "AD",
|
||||||
|
"armeenia": "AM",
|
||||||
|
"armenia": "AM",
|
||||||
|
"austria": "AT",
|
||||||
|
"austria vabariik": "AT",
|
||||||
|
"azerbaijan": "AZ",
|
||||||
|
"aserbaidžaan": "AZ",
|
||||||
|
"belgia": "BE",
|
||||||
|
"belgium": "BE",
|
||||||
|
"bosnia ja hertsegoviina": "BA",
|
||||||
|
"bosnia and herzegovina": "BA",
|
||||||
|
"bulgaaria": "BG",
|
||||||
|
"bulgaria": "BG",
|
||||||
|
"canada": "CA",
|
||||||
|
"kanada": "CA",
|
||||||
|
"croatia": "HR",
|
||||||
|
"eesti": "EE",
|
||||||
|
"estonia": "EE",
|
||||||
|
"est": "EE",
|
||||||
|
"denmark": "DK",
|
||||||
|
"taani": "DK",
|
||||||
|
"finland": "FI",
|
||||||
|
"soome": "FI",
|
||||||
|
"france": "FR",
|
||||||
|
"prantsusmaa": "FR",
|
||||||
|
"georgia": "GE",
|
||||||
|
"gruusia": "GE",
|
||||||
|
"germany": "DE",
|
||||||
|
"saksamaa": "DE",
|
||||||
|
"greece": "GR",
|
||||||
|
"kreeka": "GR",
|
||||||
|
"hungary": "HU",
|
||||||
|
"ungari": "HU",
|
||||||
|
"iceland": "IS",
|
||||||
|
"island": "IS",
|
||||||
|
"ireland": "IE",
|
||||||
|
"iirimaa": "IE",
|
||||||
|
"italy": "IT",
|
||||||
|
"itaalia": "IT",
|
||||||
|
"japan": "JP",
|
||||||
|
"jaapan": "JP",
|
||||||
|
"kazakhstan": "KZ",
|
||||||
|
"kasahstan": "KZ",
|
||||||
|
"latvia": "LV",
|
||||||
|
"läti": "LV",
|
||||||
|
"lati": "LV",
|
||||||
|
"liechtenstein": "LI",
|
||||||
|
"lithuania": "LT",
|
||||||
|
"leedu": "LT",
|
||||||
|
"luxembourg": "LU",
|
||||||
|
"luksemburg": "LU",
|
||||||
|
"malta": "MT",
|
||||||
|
"moldova": "MD",
|
||||||
|
"montenegro": "ME",
|
||||||
|
"netherlands": "NL",
|
||||||
|
"holland": "NL",
|
||||||
|
"madalmaad": "NL",
|
||||||
|
"norway": "NO",
|
||||||
|
"norra": "NO",
|
||||||
|
"poland": "PL",
|
||||||
|
"poola": "PL",
|
||||||
|
"portugal": "PT",
|
||||||
|
"romania": "RO",
|
||||||
|
"rumeenia": "RO",
|
||||||
|
"serbia": "RS",
|
||||||
|
"slovakia": "SK",
|
||||||
|
"slovakkia": "SK",
|
||||||
|
"slovenia": "SI",
|
||||||
|
"sloveenia": "SI",
|
||||||
|
"spain": "ES",
|
||||||
|
"hispaania": "ES",
|
||||||
|
"sweden": "SE",
|
||||||
|
"rootsi": "SE",
|
||||||
|
"switzerland": "CH",
|
||||||
|
"šveits": "CH",
|
||||||
|
"sveits": "CH",
|
||||||
|
"turkey": "TR",
|
||||||
|
"türgi": "TR",
|
||||||
|
"ukraine": "UA",
|
||||||
|
"ukraina": "UA",
|
||||||
|
"united kingdom": "GB",
|
||||||
|
"suurbritannia": "GB",
|
||||||
|
"great britain": "GB",
|
||||||
|
"united states": "US",
|
||||||
|
"usa": "US",
|
||||||
|
"ameerika ühendriigid": "US",
|
||||||
|
"ameerika uhendriigid": "US",
|
||||||
|
"belarus": "BY",
|
||||||
|
"valgevene": "BY",
|
||||||
|
"russia": "RU",
|
||||||
|
"russian federation": "RU",
|
||||||
|
"venemaa": "RU",
|
||||||
|
}
|
||||||
|
|
||||||
|
COUNTRY_ROLE_COLOURS = {
|
||||||
|
"EE": 0x0072CE,
|
||||||
|
"LV": 0x9E3039,
|
||||||
|
"FI": 0x003580,
|
||||||
|
"LT": 0xFDB913,
|
||||||
|
"SE": 0x006AA7,
|
||||||
|
"DE": 0xDD0000,
|
||||||
|
"PL": 0xDC143C,
|
||||||
|
"UA": 0x0057B7,
|
||||||
|
"GB": 0x012169,
|
||||||
|
"US": 0x3C3B6E,
|
||||||
|
}
|
||||||
|
|
||||||
|
_client: gspread.Client | None = None
|
||||||
|
_spreadsheet: gspread.Spreadsheet | None = None
|
||||||
|
_sync_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncSummary:
|
||||||
|
saved: int = 0
|
||||||
|
created: int = 0
|
||||||
|
updated: int = 0
|
||||||
|
roles_synced: int = 0
|
||||||
|
unmatched: int = 0
|
||||||
|
sheet_rows: int = 0
|
||||||
|
alerts: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def short(self) -> str:
|
||||||
|
return (
|
||||||
|
f"saved={self.saved}, created={self.created}, updated={self.updated}, "
|
||||||
|
f"roles={self.roles_synced}, unmatched={self.unmatched}, sheet_rows={self.sheet_rows}, "
|
||||||
|
f"alerts={len(self.alerts)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text_field(name: str, required: bool = False) -> dict:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"type": "text",
|
||||||
|
"required": required,
|
||||||
|
"options": {"min": None, "max": None, "pattern": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_field(name: str) -> dict:
|
||||||
|
return {"name": name, "type": "bool", "required": False}
|
||||||
|
|
||||||
|
|
||||||
|
def fienta_collection_payload() -> dict:
|
||||||
|
fields = [
|
||||||
|
_text_field("registration_key", required=True),
|
||||||
|
_text_field("order_id"),
|
||||||
|
_text_field("ticket_code"),
|
||||||
|
_text_field("order_status"),
|
||||||
|
_text_field("order_url"),
|
||||||
|
_text_field("payment_time"),
|
||||||
|
_text_field("game"),
|
||||||
|
_text_field("kind"),
|
||||||
|
_text_field("ticket_type_id"),
|
||||||
|
_text_field("ticket_title"),
|
||||||
|
_text_field("ticket_group_title"),
|
||||||
|
_text_field("team_name"),
|
||||||
|
_text_field("discord_username"),
|
||||||
|
_text_field("nickname"),
|
||||||
|
_text_field("country"),
|
||||||
|
_text_field("country_code"),
|
||||||
|
_text_field("riot_id"),
|
||||||
|
_text_field("steam64_id"),
|
||||||
|
_text_field("vrs_ranking"),
|
||||||
|
_bool_field("is_main"),
|
||||||
|
_bool_field("is_reserve"),
|
||||||
|
_bool_field("is_manager"),
|
||||||
|
_bool_field("is_captain"),
|
||||||
|
_bool_field("sheet_public"),
|
||||||
|
_bool_field("blocked_country"),
|
||||||
|
_bool_field("active"),
|
||||||
|
_bool_field("roles_synced"),
|
||||||
|
_text_field("last_sync_error"),
|
||||||
|
_text_field("updated_at"),
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"name": config.PB_FIENTA_COLLECTION_LAN,
|
||||||
|
"type": "base",
|
||||||
|
"fields": fields,
|
||||||
|
"listRule": None,
|
||||||
|
"viewRule": None,
|
||||||
|
"createRule": None,
|
||||||
|
"updateRule": None,
|
||||||
|
"deleteRule": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_storage() -> bool:
|
||||||
|
"""Create the LAN Fienta collection when it does not exist."""
|
||||||
|
return await pb_client.ensure_collection(
|
||||||
|
config.PB_FIENTA_COLLECTION_LAN,
|
||||||
|
fienta_collection_payload(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_accents(value: str) -> str:
|
||||||
|
normalized = unicodedata.normalize("NFKD", value)
|
||||||
|
return "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> str:
|
||||||
|
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_key(value: Any) -> str:
|
||||||
|
return _strip_accents(_norm(value)).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _discord_key(value: Any) -> str:
|
||||||
|
return _norm(value).lstrip("@").casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _country_code(country: str) -> str:
|
||||||
|
raw = _norm(country)
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
key = _norm_key(raw)
|
||||||
|
if key in COUNTRY_CODE_BY_NAME:
|
||||||
|
return COUNTRY_CODE_BY_NAME[key]
|
||||||
|
for name, code in COUNTRY_CODE_BY_NAME.items():
|
||||||
|
if _norm_key(name) == key:
|
||||||
|
return code
|
||||||
|
upper = raw.upper()
|
||||||
|
if re.fullmatch(r"[A-Z]{2}", upper):
|
||||||
|
return upper
|
||||||
|
letters = re.sub(r"[^A-Z]", "", _strip_accents(upper))
|
||||||
|
return (letters[:2] or "XX").upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blocked_country(country: str, code: str) -> bool:
|
||||||
|
country_key = _norm_key(country)
|
||||||
|
return code in BLOCKED_COUNTRY_CODES or any(
|
||||||
|
_norm_key(name) == country_key for name in BLOCKED_COUNTRY_NAMES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _role_safe_name(name: str) -> str:
|
||||||
|
cleaned = re.sub(r"\s+", " ", _norm(name)).strip("@# ")
|
||||||
|
return cleaned[:90] or "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _team_role_name(game: str, team_name: str) -> str:
|
||||||
|
prefix = "CS2" if game == "cs2" else "LoL"
|
||||||
|
return f"[{prefix}] {_role_safe_name(team_name)}"[:100]
|
||||||
|
|
||||||
|
|
||||||
|
def _language_role_name(code: str) -> str:
|
||||||
|
return f"[{code.upper()}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _role_colour_for_country(code: str) -> discord.Color:
|
||||||
|
if code in COUNTRY_ROLE_COLOURS:
|
||||||
|
return discord.Color(COUNTRY_ROLE_COLOURS[code])
|
||||||
|
seed = sum(ord(ch) for ch in code)
|
||||||
|
hue = seed % 6
|
||||||
|
colours = [0x5865F2, 0x57F287, 0xFEE75C, 0xEB459E, 0xED4245, 0x00A8FC]
|
||||||
|
return discord.Color(colours[hue])
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return dt.datetime.now(dt.timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
order = payload.get("order") or {}
|
||||||
|
order_id = _norm(order.get("id"))
|
||||||
|
status = _norm(order.get("status")).upper()
|
||||||
|
payment = order.get("payment") or {}
|
||||||
|
payment_time = _norm(payment.get("time"))
|
||||||
|
order_url = _norm(order.get("order_url"))
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for ticket in order.get("tickets") or []:
|
||||||
|
ticket_code = _norm(ticket.get("code"))
|
||||||
|
for idx, row in enumerate(ticket.get("rows") or []):
|
||||||
|
ticket_type = row.get("ticket_type") or {}
|
||||||
|
ticket_type_id = _norm(ticket_type.get("id"))
|
||||||
|
mapping = TICKET_TYPES.get(ticket_type_id)
|
||||||
|
if not mapping:
|
||||||
|
continue
|
||||||
|
|
||||||
|
attendee = row.get("attendee") or {}
|
||||||
|
country = _norm(attendee.get("country"))
|
||||||
|
code = _country_code(country)
|
||||||
|
kind = str(mapping["kind"])
|
||||||
|
nickname = (
|
||||||
|
_norm(attendee.get("nickname_134815"))
|
||||||
|
or _norm(attendee.get("nickname_134816"))
|
||||||
|
or _norm(attendee.get("full_name"))
|
||||||
|
)
|
||||||
|
captain_raw = _norm(attendee.get("tiimi_kapten_134872")).casefold()
|
||||||
|
registration_key = f"{order_id}:{ticket_code}:{idx}"
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"registration_key": registration_key,
|
||||||
|
"order_id": order_id,
|
||||||
|
"ticket_code": ticket_code,
|
||||||
|
"order_status": status,
|
||||||
|
"order_url": order_url,
|
||||||
|
"payment_time": payment_time,
|
||||||
|
"game": mapping["game"],
|
||||||
|
"kind": kind,
|
||||||
|
"ticket_type_id": ticket_type_id,
|
||||||
|
"ticket_title": _norm(ticket_type.get("title")),
|
||||||
|
"ticket_group_title": _norm((ticket_type.get("ticket_type_group") or {}).get("title")),
|
||||||
|
"team_name": _norm(attendee.get("team_name_134821")),
|
||||||
|
"discord_username": _norm(attendee.get("discord_username_134871")),
|
||||||
|
"nickname": nickname,
|
||||||
|
"country": country,
|
||||||
|
"country_code": code,
|
||||||
|
"riot_id": _norm(attendee.get("riot_id_134870")),
|
||||||
|
"steam64_id": _norm(attendee.get("steam64_id_134819")),
|
||||||
|
"vrs_ranking": _norm(attendee.get("team_vrs_ranking_134825")),
|
||||||
|
"is_main": bool(mapping["main"]),
|
||||||
|
"is_reserve": kind == "reserve",
|
||||||
|
"is_manager": kind == "manager",
|
||||||
|
"is_captain": captain_raw in {"jah", "yes", "true", "1"},
|
||||||
|
"sheet_public": bool(mapping["sheet_public"]),
|
||||||
|
"blocked_country": _is_blocked_country(country, code),
|
||||||
|
"active": status == "COMPLETED",
|
||||||
|
"roles_synced": False,
|
||||||
|
"last_sync_error": "",
|
||||||
|
"updated_at": _now_iso(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def process_payload(bot: discord.Client, payload: dict[str, Any]) -> SyncSummary:
|
||||||
|
"""Store a Fienta webhook payload and resync LAN roles/sheets."""
|
||||||
|
async with _sync_lock:
|
||||||
|
summary = SyncSummary()
|
||||||
|
await ensure_storage()
|
||||||
|
rows = _ticket_rows(payload)
|
||||||
|
if not rows:
|
||||||
|
summary.alerts.append("Fienta webhook did not contain any known tournament ticket rows.")
|
||||||
|
await _send_alerts(bot, summary.alerts)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
_, created = await pb_client.upsert_record_by_field(
|
||||||
|
config.PB_FIENTA_COLLECTION_LAN,
|
||||||
|
"registration_key",
|
||||||
|
row["registration_key"],
|
||||||
|
row,
|
||||||
|
)
|
||||||
|
summary.saved += 1
|
||||||
|
if created:
|
||||||
|
summary.created += 1
|
||||||
|
else:
|
||||||
|
summary.updated += 1
|
||||||
|
|
||||||
|
resync = await resync_all(bot, send_alerts=False)
|
||||||
|
summary.roles_synced += resync.roles_synced
|
||||||
|
summary.unmatched += resync.unmatched
|
||||||
|
summary.sheet_rows += resync.sheet_rows
|
||||||
|
summary.alerts.extend(resync.alerts)
|
||||||
|
await _send_alerts(bot, summary.alerts)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
async def resync_all(bot: discord.Client, send_alerts: bool = True) -> SyncSummary:
|
||||||
|
"""Re-apply all stored Fienta registrations to Discord and Sheets."""
|
||||||
|
summary = SyncSummary()
|
||||||
|
await ensure_storage()
|
||||||
|
records = await _all_registration_records()
|
||||||
|
await _sync_roles(bot, records, summary)
|
||||||
|
sheet_rows, sheet_alerts = await asyncio.to_thread(_sync_public_sheets, records)
|
||||||
|
summary.sheet_rows += sheet_rows
|
||||||
|
summary.alerts.extend(sheet_alerts)
|
||||||
|
if send_alerts:
|
||||||
|
await _send_alerts(bot, summary.alerts)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_member_join(bot: discord.Client, member: discord.Member) -> SyncSummary:
|
||||||
|
"""Apply any stored registrations that match a newly joined member."""
|
||||||
|
if member.guild.id != config.GUILD_ID:
|
||||||
|
return SyncSummary()
|
||||||
|
summary = SyncSummary()
|
||||||
|
await ensure_storage()
|
||||||
|
target = _discord_key(member.name)
|
||||||
|
records = [
|
||||||
|
record
|
||||||
|
for record in await _all_registration_records()
|
||||||
|
if _discord_key(record.get("discord_username")) == target
|
||||||
|
]
|
||||||
|
if not records:
|
||||||
|
return summary
|
||||||
|
await _sync_roles(bot, records, summary, preloaded_member=member)
|
||||||
|
await _send_alerts(bot, summary.alerts)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
async def count_records() -> int:
|
||||||
|
await ensure_storage()
|
||||||
|
return await pb_client.count_records_in(config.PB_FIENTA_COLLECTION_LAN)
|
||||||
|
|
||||||
|
|
||||||
|
async def _all_registration_records() -> list[dict[str, Any]]:
|
||||||
|
return await pb_client.list_all_records_in(config.PB_FIENTA_COLLECTION_LAN)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_roles(
|
||||||
|
bot: discord.Client,
|
||||||
|
records: list[dict[str, Any]],
|
||||||
|
summary: SyncSummary,
|
||||||
|
preloaded_member: discord.Member | None = None,
|
||||||
|
) -> None:
|
||||||
|
guild = bot.get_guild(config.GUILD_ID)
|
||||||
|
if guild is None:
|
||||||
|
summary.alerts.append(f"LAN guild {config.GUILD_ID} is not available to the bot.")
|
||||||
|
return
|
||||||
|
if preloaded_member is None:
|
||||||
|
await _ensure_member_cache(guild)
|
||||||
|
|
||||||
|
captain_counts: dict[tuple[str, str], int] = {}
|
||||||
|
for record in records:
|
||||||
|
if (
|
||||||
|
record.get("game") == "cs2"
|
||||||
|
and record.get("is_main")
|
||||||
|
and record.get("is_captain")
|
||||||
|
and record.get("active")
|
||||||
|
):
|
||||||
|
key = ("cs2", _norm_key(record.get("team_name")))
|
||||||
|
captain_counts[key] = captain_counts.get(key, 0) + 1
|
||||||
|
for (_, team_key), count in captain_counts.items():
|
||||||
|
if count > 1:
|
||||||
|
team = next(
|
||||||
|
(_norm(r.get("team_name")) for r in records if _norm_key(r.get("team_name")) == team_key),
|
||||||
|
team_key,
|
||||||
|
)
|
||||||
|
summary.alerts.append(f"Multiple CS2 captains marked for team `{team}` ({count}).")
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
error = await _sync_record_roles(guild, record, summary, preloaded_member)
|
||||||
|
try:
|
||||||
|
await pb_client.update_record_in(
|
||||||
|
config.PB_FIENTA_COLLECTION_LAN,
|
||||||
|
record["id"],
|
||||||
|
{
|
||||||
|
"roles_synced": not bool(error),
|
||||||
|
"last_sync_error": error,
|
||||||
|
"updated_at": _now_iso(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
summary.alerts.append(
|
||||||
|
f"Could not update sync state for `{record.get('registration_key')}`: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_record_roles(
|
||||||
|
guild: discord.Guild,
|
||||||
|
record: dict[str, Any],
|
||||||
|
summary: SyncSummary,
|
||||||
|
preloaded_member: discord.Member | None = None,
|
||||||
|
) -> str:
|
||||||
|
team_name = _norm(record.get("team_name"))
|
||||||
|
username = _norm(record.get("discord_username"))
|
||||||
|
game = _norm(record.get("game"))
|
||||||
|
status = _norm(record.get("order_status")).upper()
|
||||||
|
country = _norm(record.get("country"))
|
||||||
|
country_code = _norm(record.get("country_code"))
|
||||||
|
|
||||||
|
if status in CANCELLED_STATUSES:
|
||||||
|
summary.alerts.append(
|
||||||
|
f"Registration `{record.get('registration_key')}` is {status}; no automatic role removal was done."
|
||||||
|
)
|
||||||
|
return "inactive order"
|
||||||
|
if not record.get("active"):
|
||||||
|
summary.alerts.append(
|
||||||
|
f"Registration `{record.get('registration_key')}` is `{status or 'UNKNOWN'}`; roles not assigned yet."
|
||||||
|
)
|
||||||
|
return "order not completed"
|
||||||
|
if record.get("blocked_country"):
|
||||||
|
summary.alerts.append(
|
||||||
|
f"Blocked country registration skipped: `{username}` / `{team_name}` / `{country}`."
|
||||||
|
)
|
||||||
|
return "blocked country"
|
||||||
|
if not username:
|
||||||
|
summary.unmatched += 1
|
||||||
|
summary.alerts.append(f"Registration `{record.get('registration_key')}` has no Discord username.")
|
||||||
|
return "missing Discord username"
|
||||||
|
if not team_name:
|
||||||
|
summary.alerts.append(f"Registration `{record.get('registration_key')}` has no team name.")
|
||||||
|
return "missing team name"
|
||||||
|
|
||||||
|
member = preloaded_member or _find_member_by_username(guild, username)
|
||||||
|
if member is None:
|
||||||
|
summary.unmatched += 1
|
||||||
|
summary.alerts.append(f"No Discord member found for `{username}` ({game.upper()} `{team_name}`).")
|
||||||
|
return "Discord member not found"
|
||||||
|
|
||||||
|
roles: list[discord.Role] = []
|
||||||
|
general_role_id = CS2_GENERAL_ROLE_ID if game == "cs2" else LOL_GENERAL_ROLE_ID
|
||||||
|
general_role = guild.get_role(general_role_id)
|
||||||
|
if general_role is None:
|
||||||
|
return f"general role {general_role_id} not found"
|
||||||
|
roles.append(general_role)
|
||||||
|
|
||||||
|
team_role = await _get_or_create_role(
|
||||||
|
guild,
|
||||||
|
_team_role_name(game, team_name),
|
||||||
|
anchor=general_role,
|
||||||
|
colour=general_role.color if general_role.color.value else discord.Color.default(),
|
||||||
|
)
|
||||||
|
roles.append(team_role)
|
||||||
|
|
||||||
|
language_general = guild.get_role(LANGUAGE_GENERAL_ROLE_ID)
|
||||||
|
if language_general:
|
||||||
|
roles.append(language_general)
|
||||||
|
if country_code:
|
||||||
|
country_role = await _get_or_create_country_role(guild, country_code, language_general)
|
||||||
|
roles.append(country_role)
|
||||||
|
else:
|
||||||
|
summary.alerts.append(f"Missing country for `{username}` ({game.upper()} `{team_name}`).")
|
||||||
|
else:
|
||||||
|
summary.alerts.append(f"Language general role {LANGUAGE_GENERAL_ROLE_ID} not found.")
|
||||||
|
|
||||||
|
if game == "cs2" and record.get("is_main") and record.get("is_captain"):
|
||||||
|
captain_role = guild.get_role(CS2_CAPTAIN_ROLE_ID)
|
||||||
|
if captain_role:
|
||||||
|
roles.append(captain_role)
|
||||||
|
else:
|
||||||
|
summary.alerts.append(f"CS2 Captain role {CS2_CAPTAIN_ROLE_ID} not found.")
|
||||||
|
if game == "cs2" and record.get("is_manager"):
|
||||||
|
manager_role = guild.get_role(CS2_MANAGER_ROLE_ID)
|
||||||
|
if manager_role:
|
||||||
|
roles.append(manager_role)
|
||||||
|
else:
|
||||||
|
summary.alerts.append(f"CS2 Manager role {CS2_MANAGER_ROLE_ID} not found.")
|
||||||
|
|
||||||
|
missing = [role for role in _unique_roles(roles) if role not in member.roles]
|
||||||
|
if missing:
|
||||||
|
try:
|
||||||
|
await member.add_roles(*missing, reason="Fienta LAN registration sync")
|
||||||
|
except discord.Forbidden:
|
||||||
|
return "bot lacks permission to add roles"
|
||||||
|
except discord.HTTPException as exc:
|
||||||
|
return f"Discord role add failed: {exc}"
|
||||||
|
summary.roles_synced += 1
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_member_cache(guild: discord.Guild) -> None:
|
||||||
|
try:
|
||||||
|
if not guild.chunked:
|
||||||
|
await guild.chunk(cache=True)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Could not chunk guild members for Fienta sync: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_member_by_username(guild: discord.Guild, username: str) -> discord.Member | None:
|
||||||
|
target = _discord_key(username)
|
||||||
|
if not target:
|
||||||
|
return None
|
||||||
|
for member in guild.members:
|
||||||
|
candidates = [member.name, getattr(member, "global_name", None), member.display_name]
|
||||||
|
if any(_discord_key(candidate) == target for candidate in candidates if candidate):
|
||||||
|
return member
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_roles(roles: list[discord.Role]) -> list[discord.Role]:
|
||||||
|
seen: set[int] = set()
|
||||||
|
result: list[discord.Role] = []
|
||||||
|
for role in roles:
|
||||||
|
if role.id not in seen:
|
||||||
|
seen.add(role.id)
|
||||||
|
result.append(role)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_country_role(
|
||||||
|
guild: discord.Guild,
|
||||||
|
country_code: str,
|
||||||
|
anchor: discord.Role,
|
||||||
|
) -> discord.Role:
|
||||||
|
code = country_code.upper()
|
||||||
|
existing_id = EXISTING_LANGUAGE_ROLE_IDS.get(code)
|
||||||
|
if existing_id:
|
||||||
|
role = guild.get_role(existing_id)
|
||||||
|
if role:
|
||||||
|
return role
|
||||||
|
return await _get_or_create_role(
|
||||||
|
guild,
|
||||||
|
_language_role_name(code),
|
||||||
|
anchor=anchor,
|
||||||
|
colour=_role_colour_for_country(code),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_role(
|
||||||
|
guild: discord.Guild,
|
||||||
|
name: str,
|
||||||
|
anchor: discord.Role,
|
||||||
|
colour: discord.Color,
|
||||||
|
) -> discord.Role:
|
||||||
|
role = discord.utils.get(guild.roles, name=name)
|
||||||
|
if role is None:
|
||||||
|
role = await guild.create_role(name=name, color=colour, reason="Fienta LAN registration sync")
|
||||||
|
await _move_role_under(guild, role, anchor)
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
async def _move_role_under(guild: discord.Guild, role: discord.Role, anchor: discord.Role) -> None:
|
||||||
|
if role.position == max(anchor.position - 1, 1):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await guild.edit_role_positions(positions={role: max(anchor.position - 1, 1)})
|
||||||
|
except discord.Forbidden:
|
||||||
|
log.warning("No permission to move role %s under %s", role.name, anchor.name)
|
||||||
|
except discord.HTTPException as exc:
|
||||||
|
log.warning("Could not move role %s under %s: %s", role.name, anchor.name, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_spreadsheet() -> gspread.Spreadsheet:
|
||||||
|
global _client, _spreadsheet
|
||||||
|
if _spreadsheet is not None:
|
||||||
|
return _spreadsheet
|
||||||
|
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
||||||
|
_client = gspread.authorize(creds)
|
||||||
|
_spreadsheet = _client.open_by_key(config.SHEET_ID)
|
||||||
|
return _spreadsheet
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_public_sheets(records: list[dict[str, Any]]) -> tuple[int, list[str]]:
|
||||||
|
alerts: list[str] = []
|
||||||
|
rows_written = 0
|
||||||
|
spreadsheet = _get_spreadsheet()
|
||||||
|
for game in ("cs2", "lol"):
|
||||||
|
cfg = SHEET_CONFIG[game]
|
||||||
|
try:
|
||||||
|
worksheet = spreadsheet.worksheet(cfg["worksheet"])
|
||||||
|
except gspread.WorksheetNotFound:
|
||||||
|
alerts.append(f"Worksheet `{cfg['worksheet']}` not found in LAN live sheet.")
|
||||||
|
continue
|
||||||
|
teams = _public_teams(records, game)
|
||||||
|
rows_written += _write_game_sheet(worksheet, game, teams, alerts)
|
||||||
|
return rows_written, alerts
|
||||||
|
|
||||||
|
|
||||||
|
def _public_teams(records: list[dict[str, Any]], game: str) -> list[dict[str, Any]]:
|
||||||
|
by_team: dict[str, dict[str, Any]] = {}
|
||||||
|
for record in records:
|
||||||
|
status = _norm(record.get("order_status")).upper()
|
||||||
|
if record.get("game") != game or not record.get("sheet_public"):
|
||||||
|
continue
|
||||||
|
if record.get("blocked_country") or status in CANCELLED_STATUSES:
|
||||||
|
continue
|
||||||
|
team_name = _norm(record.get("team_name"))
|
||||||
|
if not team_name:
|
||||||
|
continue
|
||||||
|
key = _norm_key(team_name)
|
||||||
|
team = by_team.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"team_name": team_name,
|
||||||
|
"lineup": [],
|
||||||
|
"vrs": "",
|
||||||
|
"payment_time": _norm(record.get("payment_time")),
|
||||||
|
"confirmed": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
team["lineup"].append(
|
||||||
|
{
|
||||||
|
"nickname": _norm(record.get("nickname")),
|
||||||
|
"country": _norm(record.get("country")),
|
||||||
|
"country_code": _norm(record.get("country_code")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not team["vrs"] and record.get("vrs_ranking"):
|
||||||
|
team["vrs"] = _norm(record.get("vrs_ranking"))
|
||||||
|
if _norm(record.get("payment_time")) < team["payment_time"]:
|
||||||
|
team["payment_time"] = _norm(record.get("payment_time"))
|
||||||
|
if status != "COMPLETED":
|
||||||
|
team["confirmed"] = False
|
||||||
|
return sorted(by_team.values(), key=lambda t: (t["payment_time"], _norm_key(t["team_name"])))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_game_sheet(
|
||||||
|
worksheet: gspread.Worksheet,
|
||||||
|
game: str,
|
||||||
|
teams: list[dict[str, Any]],
|
||||||
|
alerts: list[str],
|
||||||
|
) -> int:
|
||||||
|
cfg = SHEET_CONFIG[game]
|
||||||
|
start_row = int(cfg["start_row"])
|
||||||
|
end_row = int(cfg["end_row"])
|
||||||
|
capacity = end_row - start_row + 1
|
||||||
|
existing = worksheet.get(f"B{start_row}:B{end_row}")
|
||||||
|
by_name: dict[str, int] = {}
|
||||||
|
empty_rows: list[int] = []
|
||||||
|
for offset in range(capacity):
|
||||||
|
row_num = start_row + offset
|
||||||
|
value = ""
|
||||||
|
if offset < len(existing) and existing[offset]:
|
||||||
|
value = _norm(existing[offset][0])
|
||||||
|
if value:
|
||||||
|
by_name[_norm_key(value)] = row_num
|
||||||
|
else:
|
||||||
|
empty_rows.append(row_num)
|
||||||
|
|
||||||
|
rows_written = 0
|
||||||
|
for team in teams:
|
||||||
|
key = _norm_key(team["team_name"])
|
||||||
|
row_num = by_name.get(key)
|
||||||
|
if row_num is None:
|
||||||
|
if not empty_rows:
|
||||||
|
alerts.append(f"{game.upper()} live sheet is full; `{team['team_name']}` was not added.")
|
||||||
|
continue
|
||||||
|
row_num = empty_rows.pop(0)
|
||||||
|
by_name[key] = row_num
|
||||||
|
|
||||||
|
no = row_num - start_row + 1
|
||||||
|
lineup = "\n".join(_lineup_entry(player) for player in team["lineup"])
|
||||||
|
timestamp = _format_sheet_time(team["payment_time"])
|
||||||
|
status = CONFIRMED_TEXT if team["confirmed"] else PENDING_TEXT
|
||||||
|
if game == "cs2":
|
||||||
|
values = [[no, team["team_name"], lineup, team["vrs"], timestamp, status]]
|
||||||
|
worksheet.update(values, f"A{row_num}:F{row_num}", value_input_option="USER_ENTERED")
|
||||||
|
else:
|
||||||
|
values = [[no, team["team_name"], lineup, timestamp, status]]
|
||||||
|
worksheet.update(values, f"A{row_num}:E{row_num}", value_input_option="USER_ENTERED")
|
||||||
|
rows_written += 1
|
||||||
|
return rows_written
|
||||||
|
|
||||||
|
|
||||||
|
def _lineup_entry(player: dict[str, str]) -> str:
|
||||||
|
nickname = player.get("nickname") or "?"
|
||||||
|
country = player.get("country") or player.get("country_code") or "?"
|
||||||
|
return f"{nickname}, {country}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_sheet_time(raw: str) -> str:
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
parsed = dt.datetime.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
return parsed.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_alerts(bot: discord.Client, alerts: list[str]) -> None:
|
||||||
|
if not alerts or not config.FIENTA_ADMIN_ALERT_CHANNEL_ID:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
channel = bot.get_channel(config.FIENTA_ADMIN_ALERT_CHANNEL_ID)
|
||||||
|
if channel is None:
|
||||||
|
channel = await bot.fetch_channel(config.FIENTA_ADMIN_ALERT_CHANNEL_ID)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Could not fetch Fienta alert channel: %s", exc)
|
||||||
|
return
|
||||||
|
if not hasattr(channel, "send"):
|
||||||
|
return
|
||||||
|
|
||||||
|
header = "**Fienta LAN sync alerts**"
|
||||||
|
chunks: list[str] = []
|
||||||
|
current = header
|
||||||
|
for alert in alerts:
|
||||||
|
line = f"\n- {alert}"
|
||||||
|
if len(current) + len(line) > 1900:
|
||||||
|
chunks.append(current)
|
||||||
|
current = header + line
|
||||||
|
else:
|
||||||
|
current += line
|
||||||
|
chunks.append(current)
|
||||||
|
for chunk in chunks:
|
||||||
|
try:
|
||||||
|
await channel.send(chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Could not send Fienta alert: %s", exc)
|
||||||
|
break
|
||||||
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import calendar
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
@@ -15,20 +13,6 @@ from . import sheets
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
|
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
|
||||||
_TZ = ZoneInfo("Europe/Tallinn")
|
|
||||||
|
|
||||||
|
|
||||||
def today_local() -> date:
|
|
||||||
"""Today's date in Europe/Tallinn — the bot's operational timezone, independent of host TZ."""
|
|
||||||
return datetime.now(_TZ).date()
|
|
||||||
|
|
||||||
|
|
||||||
def _shift_year_safe(d: date, year: int) -> date:
|
|
||||||
"""Move `d` to `year`; Feb 29 falls back to Feb 28 when the target year is non-leap."""
|
|
||||||
try:
|
|
||||||
return d.replace(year=year)
|
|
||||||
except ValueError:
|
|
||||||
return d.replace(year=year, day=28)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_placeholder(val: str) -> bool:
|
def _is_placeholder(val: str) -> bool:
|
||||||
@@ -50,7 +34,6 @@ class SyncResult:
|
|||||||
roles_added: list[str] = field(default_factory=list)
|
roles_added: list[str] = field(default_factory=list)
|
||||||
roles_removed: list[str] = field(default_factory=list)
|
roles_removed: list[str] = field(default_factory=list)
|
||||||
birthday_soon: bool = False
|
birthday_soon: bool = False
|
||||||
birthday_today: bool = False
|
|
||||||
not_found: bool = False
|
not_found: bool = False
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
synced: bool = False # True when no errors; caller writes this to the sheet
|
synced: bool = False # True when no errors; caller writes this to the sheet
|
||||||
@@ -88,7 +71,7 @@ def _parse_birthday(raw: str) -> date | None:
|
|||||||
raw = str(raw).strip()
|
raw = str(raw).strip()
|
||||||
if _is_placeholder(raw):
|
if _is_placeholder(raw):
|
||||||
return None
|
return None
|
||||||
today = today_local()
|
today = date.today()
|
||||||
for fmt, has_year in [("%d/%m/%Y", True), ("%Y-%m-%d", True), ("%m-%d", False)]:
|
for fmt, has_year in [("%d/%m/%Y", True), ("%Y-%m-%d", True), ("%m-%d", False)]:
|
||||||
try:
|
try:
|
||||||
parsed = datetime.strptime(raw, fmt).date()
|
parsed = datetime.strptime(raw, fmt).date()
|
||||||
@@ -106,32 +89,21 @@ def _is_birthday_soon(birthday_str: str, window_days: int | None = None) -> bool
|
|||||||
if bday is None:
|
if bday is None:
|
||||||
return False
|
return False
|
||||||
window = window_days or config.BIRTHDAY_WINDOW_DAYS
|
window = window_days or config.BIRTHDAY_WINDOW_DAYS
|
||||||
today = today_local()
|
today = date.today()
|
||||||
this_year_bday = _shift_year_safe(bday, today.year)
|
this_year_bday = bday.replace(year=today.year)
|
||||||
if this_year_bday < today:
|
if this_year_bday < today:
|
||||||
this_year_bday = _shift_year_safe(bday, today.year + 1)
|
this_year_bday = bday.replace(year=today.year + 1)
|
||||||
delta = (this_year_bday - today).days
|
delta = (this_year_bday - today).days
|
||||||
return 0 <= delta <= window
|
return 0 <= delta <= window
|
||||||
|
|
||||||
|
|
||||||
def is_birthday_today(birthday_str: str) -> bool:
|
def is_birthday_today(birthday_str: str) -> bool:
|
||||||
"""Return True if today is the member's birthday (any supported date format).
|
"""Return True if today is the member's birthday (any supported date format)."""
|
||||||
|
|
||||||
Feb 29 babies are observed on Feb 28 in non-leap years.
|
|
||||||
"""
|
|
||||||
bday = _parse_birthday(birthday_str)
|
bday = _parse_birthday(birthday_str)
|
||||||
if bday is None:
|
if bday is None:
|
||||||
return False
|
return False
|
||||||
today = today_local()
|
today = date.today()
|
||||||
if bday.month == today.month and bday.day == today.day:
|
return bday.month == today.month and bday.day == today.day
|
||||||
return True
|
|
||||||
if (
|
|
||||||
bday.month == 2 and bday.day == 29
|
|
||||||
and today.month == 2 and today.day == 28
|
|
||||||
and not calendar.isleap(today.year)
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_member(
|
async def sync_member(
|
||||||
@@ -154,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":
|
||||||
await sheets.set_user_id(member.name, member.id)
|
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():
|
||||||
await sheets.update_username(member.id, member.name)
|
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()
|
||||||
@@ -214,9 +186,8 @@ async def sync_member(
|
|||||||
|
|
||||||
# --- Birthday check ---
|
# --- Birthday check ---
|
||||||
birthday_str = str(row.get("Sünnipäev", "")).strip()
|
birthday_str = str(row.get("Sünnipäev", "")).strip()
|
||||||
if not _is_placeholder(birthday_str):
|
if not _is_placeholder(birthday_str) and _is_birthday_soon(birthday_str):
|
||||||
result.birthday_today = is_birthday_today(birthday_str)
|
result.birthday_soon = True
|
||||||
result.birthday_soon = _is_birthday_soon(birthday_str)
|
|
||||||
|
|
||||||
# --- Mark synced (caller is responsible for writing to sheet) ---
|
# --- Mark synced (caller is responsible for writing to sheet) ---
|
||||||
result.synced = not bool(result.errors)
|
result.synced = not bool(result.errors)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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
|
PB_ECONOMY_COLLECTION_DEV / PB_ECONOMY_COLLECTION_ECONOMY / PB_ECONOMY_COLLECTION_LAN
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,11 +21,6 @@ import aiohttp
|
|||||||
|
|
||||||
import config
|
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 = config.PB_URL
|
PB_URL = config.PB_URL
|
||||||
@@ -62,20 +57,17 @@ 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()
|
||||||
try:
|
async with session.post(
|
||||||
async with session.post(
|
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
||||||
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
) as resp:
|
||||||
) as resp:
|
if resp.status != 200:
|
||||||
if resp.status != 200:
|
text = await resp.text()
|
||||||
text = await resp.text()
|
raise RuntimeError(f"PocketBase auth failed ({resp.status}): {text}")
|
||||||
raise DatabaseError(f"PocketBase auth failed ({resp.status}): {text}")
|
data = await resp.json()
|
||||||
data = await resp.json()
|
_token = data["token"]
|
||||||
_token = data["token"]
|
_token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry
|
||||||
_token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry
|
_log.debug("PocketBase admin token refreshed")
|
||||||
_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
|
||||||
|
|
||||||
|
|
||||||
@@ -83,35 +75,8 @@ async def _hdrs() -> dict[str, str]:
|
|||||||
return {"Authorization": await _ensure_auth()}
|
return {"Authorization": await _ensure_auth()}
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_token() -> None:
|
def _escape_filter_value(value: str) -> str:
|
||||||
global _token_expiry
|
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
_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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -120,61 +85,156 @@ async def _request(method: str, url: str, **kwargs: Any) -> Any:
|
|||||||
|
|
||||||
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."""
|
||||||
data = await _request(
|
return await get_first_record(
|
||||||
"GET",
|
ECONOMY_COLLECTION,
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
f'user_id="{_escape_filter_value(user_id)}"',
|
||||||
params={"filter": f'user_id="{user_id}"', "perPage": 1},
|
|
||||||
)
|
)
|
||||||
items = data.get("items", [])
|
|
||||||
return items[0] if items else None
|
|
||||||
|
async def get_first_record(collection: str, filter_expr: str) -> dict[str, Any] | None:
|
||||||
|
"""Fetch one record from any collection by a PocketBase filter expression."""
|
||||||
|
session = _get_session()
|
||||||
|
async with session.get(
|
||||||
|
f"{PB_URL}/api/collections/{collection}/records",
|
||||||
|
params={"filter": filter_expr, "perPage": 1},
|
||||||
|
headers=await _hdrs(),
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = await resp.json()
|
||||||
|
items = data.get("items", [])
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_record_by_field(collection: str, field: str, value: str) -> dict[str, Any] | None:
|
||||||
|
"""Fetch one record where `field` exactly equals `value`."""
|
||||||
|
escaped = _escape_filter_value(value)
|
||||||
|
return await get_first_record(collection, f'{field}="{escaped}"')
|
||||||
|
|
||||||
|
|
||||||
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)."""
|
||||||
return await _request(
|
return await create_record_in(ECONOMY_COLLECTION, record)
|
||||||
"POST",
|
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
|
||||||
|
async def create_record_in(collection: str, record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Create a new record in any collection. Returns the created record."""
|
||||||
|
session = _get_session()
|
||||||
|
async with session.post(
|
||||||
|
f"{PB_URL}/api/collections/{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."""
|
||||||
return await _request(
|
return await update_record_in(ECONOMY_COLLECTION, record_id, data)
|
||||||
"PATCH",
|
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}",
|
|
||||||
|
async def update_record_in(collection: str, record_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""PATCH an existing record in any collection by its PocketBase record id."""
|
||||||
|
session = _get_session()
|
||||||
|
async with session.patch(
|
||||||
|
f"{PB_URL}/api/collections/{collection}/records/{record_id}",
|
||||||
json=data,
|
json=data,
|
||||||
)
|
headers=await _hdrs(),
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
async def get_collection_fields() -> set[str]:
|
return await resp.json()
|
||||||
"""Return the field names defined on the economy collection's schema."""
|
|
||||||
data = await _request("GET", f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}")
|
|
||||||
return {f["name"] for f in data.get("fields", [])}
|
|
||||||
|
|
||||||
|
|
||||||
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)."""
|
||||||
data = await _request(
|
return await count_records_in(ECONOMY_COLLECTION)
|
||||||
"GET",
|
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
|
||||||
|
async def count_records_in(collection: str) -> int:
|
||||||
|
"""Return the total number of records in any collection."""
|
||||||
|
session = _get_session()
|
||||||
|
async with session.get(
|
||||||
|
f"{PB_URL}/api/collections/{collection}/records",
|
||||||
params={"perPage": 1, "page": 1},
|
params={"perPage": 1, "page": 1},
|
||||||
)
|
headers=await _hdrs(),
|
||||||
return int(data.get("totalItems", 0))
|
) as resp:
|
||||||
|
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."""
|
||||||
|
return await list_all_records_in(ECONOMY_COLLECTION, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all_records_in(collection: str, page_size: int = 500) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch every record in any 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:
|
||||||
data = await _request(
|
async with session.get(
|
||||||
"GET",
|
f"{PB_URL}/api/collections/{collection}/records",
|
||||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
|
||||||
params={"perPage": page_size, "page": page},
|
params={"perPage": page_size, "page": page},
|
||||||
)
|
headers=hdrs,
|
||||||
batch = data.get("items", [])
|
) as resp:
|
||||||
results.extend(batch)
|
resp.raise_for_status()
|
||||||
if len(batch) < page_size:
|
data = await resp.json()
|
||||||
return results
|
batch = data.get("items", [])
|
||||||
page += 1
|
results.extend(batch)
|
||||||
|
if len(batch) < page_size:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_record_by_field(
|
||||||
|
collection: str,
|
||||||
|
field: str,
|
||||||
|
value: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], bool]:
|
||||||
|
"""Create or update a record. Returns (record, created)."""
|
||||||
|
existing = await get_record_by_field(collection, field, value)
|
||||||
|
if existing:
|
||||||
|
return await update_record_in(collection, existing["id"], data), False
|
||||||
|
return await create_record_in(collection, data), True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_collection(collection: str) -> dict[str, Any] | None:
|
||||||
|
"""Fetch collection metadata, returning None if it doesn't exist."""
|
||||||
|
session = _get_session()
|
||||||
|
async with session.get(
|
||||||
|
f"{PB_URL}/api/collections/{collection}",
|
||||||
|
headers=await _hdrs(),
|
||||||
|
) as resp:
|
||||||
|
if resp.status == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
return await resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_collection(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Create a PocketBase collection from a full collection payload."""
|
||||||
|
session = _get_session()
|
||||||
|
async with session.post(
|
||||||
|
f"{PB_URL}/api/collections",
|
||||||
|
json=payload,
|
||||||
|
headers=await _hdrs(),
|
||||||
|
) as resp:
|
||||||
|
if resp.status not in (200, 201):
|
||||||
|
text = await resp.text()
|
||||||
|
raise RuntimeError(f"PocketBase collection create failed ({resp.status}): {text}")
|
||||||
|
return await resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_collection(collection: str, payload: dict[str, Any]) -> bool:
|
||||||
|
"""Create `collection` when missing. Returns True if created."""
|
||||||
|
if await get_collection(collection):
|
||||||
|
return False
|
||||||
|
await create_collection(payload)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
"""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
|
||||||
@@ -77,7 +69,11 @@ def _ensure_headers(ws: gspread.Worksheet) -> None:
|
|||||||
ws.update_cell(1, col_idx, header)
|
ws.update_cell(1, col_idx, header)
|
||||||
|
|
||||||
|
|
||||||
def _refresh_sync() -> list[dict]:
|
def refresh() -> 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)
|
||||||
@@ -87,11 +83,6 @@ def _refresh_sync() -> 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
|
||||||
@@ -131,12 +122,16 @@ def _row_index_for_member(discord_id: int | None = None, username: str | None =
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _update_cell_for_member_sync(
|
def update_cell_for_member(
|
||||||
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:
|
||||||
@@ -150,6 +145,7 @@ def _update_cell_for_member_sync(
|
|||||||
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
|
||||||
@@ -157,19 +153,8 @@ def _update_cell_for_member_sync(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def update_cell_for_member(
|
def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
|
||||||
discord_id: int | None,
|
"""Batch-write 'Discordis synced?' for multiple members in a single API call."""
|
||||||
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 = []
|
||||||
@@ -185,14 +170,9 @@ def _batch_set_synced_sync(updates: list[tuple[int, bool]]) -> None:
|
|||||||
ws.update_cells(cells, value_input_option="USER_ENTERED")
|
ws.update_cells(cells, value_input_option="USER_ENTERED")
|
||||||
|
|
||||||
|
|
||||||
async def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
|
def set_user_id(username: str, discord_id: int) -> bool:
|
||||||
"""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 await update_cell_for_member(
|
return update_cell_for_member(
|
||||||
discord_id=None,
|
discord_id=None,
|
||||||
username=username,
|
username=username,
|
||||||
column_name="User ID",
|
column_name="User ID",
|
||||||
@@ -200,9 +180,9 @@ async def set_user_id(username: str, discord_id: int) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def set_synced(discord_id: int, synced: bool) -> bool:
|
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 await update_cell_for_member(
|
return update_cell_for_member(
|
||||||
discord_id=discord_id,
|
discord_id=discord_id,
|
||||||
username=None,
|
username=None,
|
||||||
column_name="Discordis synced?",
|
column_name="Discordis synced?",
|
||||||
@@ -210,9 +190,9 @@ async def set_synced(discord_id: int, synced: bool) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def update_username(discord_id: int, new_username: str) -> bool:
|
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 await update_cell_for_member(
|
return update_cell_for_member(
|
||||||
discord_id=discord_id,
|
discord_id=discord_id,
|
||||||
username=None,
|
username=None,
|
||||||
column_name="Discord",
|
column_name="Discord",
|
||||||
@@ -220,17 +200,17 @@ async def update_username(discord_id: int, new_username: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _add_new_member_row_sync(username: str, discord_id: int) -> None:
|
def add_new_member_row(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)
|
|
||||||
|
|||||||
904
docs/CHANGELOG.md
Normal file
904
docs/CHANGELOG.md
Normal file
@@ -0,0 +1,904 @@
|
|||||||
|
## v0.36.7
|
||||||
|
|
||||||
|
- Fixed high memory usage with large file uploads ([#7572](https://github.com/pocketbase/pocketbase/discussions/7572)).
|
||||||
|
|
||||||
|
- Updated the rate limiter reset rules to follow a more traditional fixed window strategy _(aka. to be more close to how it is presented in the UI - allow max X user requests under Ys)_ since several users complained that the older algorithm was not intuitive and not suitable for large intervals.
|
||||||
|
_Approximated sliding window strategy was also suggested as a better compromise option to help minimize traffic spikes right after reset but the additional tracking could introduce some overhead and for now it is left aside until we have more tests._
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
|
||||||
|
_⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
|
||||||
|
|
||||||
|
- Updated other minor Go and npm deps.
|
||||||
|
_The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
|
||||||
|
|
||||||
|
|
||||||
|
## v0.36.6
|
||||||
|
|
||||||
|
- Set `NumberField.OnlyInt:true` for the generated View collection schema fields when a view column expression is known to return int-only values ([#7538](https://github.com/pocketbase/pocketbase/issues/7538)).
|
||||||
|
|
||||||
|
- Documented the `unmarshal` JSVM helper ([#7543](https://github.com/pocketbase/pocketbase/issues/7543)).
|
||||||
|
|
||||||
|
- Added extra read check after the `Store.GetOrSet` write lock to prevent races overwriting an already existing value.
|
||||||
|
|
||||||
|
- Added empty records check for the additional client-side filter's ListRule constraint that was introduced in v0.32.0 ([presentator#206](https://github.com/presentator/presentator/issues/206)).
|
||||||
|
|
||||||
|
- Set a fixed `routine.FireAndForget()` debug stack trace limit to 2KB.
|
||||||
|
|
||||||
|
- Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
|
||||||
|
|
||||||
|
- Typos and other minor doc fixes.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.36.5
|
||||||
|
|
||||||
|
- Disabled collection and fields name normalization while in IME mode ([#7532](https://github.com/pocketbase/pocketbase/pull/7532); thanks @miaopan607).
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to v1.46.1 _(resets connection state on Tx.Commit failure)_.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.36.4
|
||||||
|
|
||||||
|
- Made the optional `Bearer` token prefix case-insensitive ([#7525](https://github.com/pocketbase/pocketbase/pull/7525); thanks @benjamesfleming).
|
||||||
|
|
||||||
|
- Enabled `$filesystem.s3(...)` and `$filesystem.local(...)` JSVM bindings ([#7526](https://github.com/pocketbase/pocketbase/issues/7526)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.36.3
|
||||||
|
|
||||||
|
- Added `Accept-Encoding: identity` to the S3 requests per the suggestion in [#7523](https://github.com/pocketbase/pocketbase/issues/7523).
|
||||||
|
_This should help fixing the 0-bytes file response when S3 API compression is enabled._
|
||||||
|
|
||||||
|
- Bumped min Go GitHub action version to 1.26.0 _(it comes with minor [GC performance improvements](https://go.dev/doc/go1.26#runtime))_.
|
||||||
|
|
||||||
|
- Other minor fixes _(updated `modernc.org/sqlite` to v1.45.0, updated `goja_nodejs` adding `Buffer.concat`, updated the arguments of `app.DeleteTable(...)`, `app.DeleteView(...)` and other similar methods to make it more clear that they are dangerous and shouldn't be used with untrusted input, etc.)_.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.36.2
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to v1.44.3 _(race check fix)_, `goja` _(circular references fix)_ and other go deps.
|
||||||
|
|
||||||
|
- Other minor fixes _(updated tests to silence some of the race detector errors, updated `FindFirstRecordByData` with more clear error message when missing or invalid key is used, etc.)_.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.36.1
|
||||||
|
|
||||||
|
- Reverted the `DISTINCT` with `GROUP BY` replacement optimization from v0.36.0 as it was reported to negatively impact the indexes utilization for some queries
|
||||||
|
and the minor performance boost that you may get when used on large records is not enough to justify the more common use ([#7461](https://github.com/pocketbase/pocketbase/discussions/7461)).
|
||||||
|
_A better generic deduplication optimization for large records (aka. records with large `text`/`json` fields or many small ones) will be researched but there are no ETAs._
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to v1.44.2 _(SQLite 3.51.2)_.
|
||||||
|
|
||||||
|
- Fixed code comment typos.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.36.0
|
||||||
|
|
||||||
|
- List query and API rules optimizations:
|
||||||
|
- Removed unnecessary correlated subquery expression when using back-relations via single `relation` field.
|
||||||
|
- Replaced `DISTINCT` with `GROUP BY id` when rows deduplication is needed and when deemed safe.
|
||||||
|
_This should help with having a more stable and predictable performance even if the collection records are on the larger side._
|
||||||
|
|
||||||
|
For some queries and data sets the above 2 optimizations have shown significant improvements but if you notice a performance degradation after upgrading,
|
||||||
|
please open a Q&A discussion with export of your collections structure and the problematic request so that it can be analyzed.
|
||||||
|
|
||||||
|
- Added [`strftime(format, timevalue, modifiers...)`](https://pocketbase.io/docs/api-rules-and-filters/#strftimeformat-time-value-modifiers-) date formatting filter and API rules function.
|
||||||
|
It works similarly to the [SQLite `strftime` builtin function](https://sqlite.org/lang_datefunc.html)
|
||||||
|
with the main difference that NULL results will be normalized for consistency with the non-nullable PocketBase `text` and `date` fields.
|
||||||
|
Multi-match expressions are also supported and works the same as if the collection field is referenced, for example:
|
||||||
|
```js
|
||||||
|
// requires ANY/AT-LEAST-ONE-OF multiRel records to have "created" date matching the formatted string "2026-01"
|
||||||
|
strftime('%Y-%m', multiRel.created) ?= '2026-01'
|
||||||
|
|
||||||
|
// requires ALL multiRel records to have "created" date matching the formatted string "2026-01"
|
||||||
|
strftime('%Y-%m', multiRel.created) = '2026-01'
|
||||||
|
```
|
||||||
|
|
||||||
|
- ⚠️ Minor changes to the `search.ResolverResult` struct _(mostly used internally)_:
|
||||||
|
- Replaced `NoCoalesce` field with the more explicit `NullFallback` _(`NullFallbackDisabled` is the same as `NoCoalesce:true`)_.
|
||||||
|
- Replaced the expression interface of the `MultiMatchSubQuery` field with the concrete struct type `search.MultiMatchSubquery` to avoid excessive type assertions and allow direct mutations of the field.
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to v1.44.1 _(SQLite 3.51.1)_.
|
||||||
|
|
||||||
|
- Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.35.1
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to v1.43.0 _(query cancellation race fix)_.
|
||||||
|
|
||||||
|
- Other minor UI fixes (normalized relations picker selection and confirmation message when `maxSelect=0/1`, updated node deps).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.35.0
|
||||||
|
|
||||||
|
- Added `nullString()`, `nullInt()`, `nullFloat()`, `nullBool`, `nullArray()`, `nullObject()` JSVM helpers for scanning nullable columns ([#7396](https://github.com/pocketbase/pocketbase/issues/7396)).
|
||||||
|
|
||||||
|
- Store the correct `image/png` as attrs content type when generating a thumb fallback _(e.g. for `webp`)_.
|
||||||
|
|
||||||
|
- Trimmed custom uploaded file name and extension from leftover `.` characters after `filesystem.File` normalization.
|
||||||
|
_This was done to prevent issues with external files sync programs that may have special handling for "invisible" files._
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` _(v1.41.0 includes prepared statements optimization)_ and other minor Go deps.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.34.2
|
||||||
|
|
||||||
|
- Bumped JS SDK to v0.26.5 to fix Safari AbortError detection introduced with the previous release ([#7369](https://github.com/pocketbase/pocketbase/issues/7369)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.34.1
|
||||||
|
|
||||||
|
- Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353); thanks @ouvreboite).
|
||||||
|
|
||||||
|
- Added "Copy raw JSON" collection dropdown option ([#7357](https://github.com/pocketbase/pocketbase/issues/7357)).
|
||||||
|
|
||||||
|
- Updated Go deps and JS SDK.
|
||||||
|
|
||||||
|
- Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5).
|
||||||
|
_The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._
|
||||||
|
|
||||||
|
|
||||||
|
## v0.34.0
|
||||||
|
|
||||||
|
- Added `@request.body.someField:changed` modifier.
|
||||||
|
It could be used when you want to ensure that a body field either wasn't submitted or was submitted with the same value.
|
||||||
|
Or in other words, if you want to disallow a field change the below 2 expressions would be equivalent:
|
||||||
|
```js
|
||||||
|
// (old)
|
||||||
|
(@request.body.someField:isset = false || @request.body.someField = someField)
|
||||||
|
|
||||||
|
// (new)
|
||||||
|
@request.body.someField:changed = false
|
||||||
|
```
|
||||||
|
|
||||||
|
- Added `MailerRecordEvent.Meta["info"]` property for the `OnMailerRecordAuthAlertSend` hook.
|
||||||
|
|
||||||
|
- Updated the backup restore popup with a short info about the performed restore steps.
|
||||||
|
|
||||||
|
- Updated Go deps.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.33.0
|
||||||
|
|
||||||
|
- Added extra `id` characters validation in addition to the user specified regex pattern ([#7312](https://github.com/pocketbase/pocketbase/issues/7312)).
|
||||||
|
_The following special characters are always forbidden: `./\|"'``<>:?*%$\n\r\t\0 `. Common reserved Windows file names such as `aux`, `prn`, `con`, `nul`, `com1-9`, `lpt1-9` are also not allowed._
|
||||||
|
_The list is not exhaustive but it should help minimizing eventual filesystem compatibility issues in case of wildcards or other loose regex patterns._
|
||||||
|
|
||||||
|
- Added `{ALERT_INFO}` placeholder to the auth alert mail template ([#7314](https://github.com/pocketbase/pocketbase/issues/7314)).
|
||||||
|
_⚠️ `mails.SendRecordAuthAlert(app, authRecord, info)` also now accepts a 3rd `info` string argument._
|
||||||
|
|
||||||
|
- Updated Go deps.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.32.0
|
||||||
|
|
||||||
|
- ⚠️ Added extra List/Search API rules checks for the client-side `filter`/`sort` relations.
|
||||||
|
|
||||||
|
This is continuation of the effort to eliminate the risk of information disclosure _(and eventually the side-channel attacks that may originate from that)_.
|
||||||
|
|
||||||
|
So far this was accepted tradeoff between performance, usability and correctness since the solutions at the time weren't really practical _(especially with the back-relations as mentioned in ["Security and performance" section in #4417](https://github.com/pocketbase/pocketbase/discussions/4417))_, but with v0.23+ changes we can implement the extra checks without littering the code too much, with very little impact on the performance and at the same time ensuring better out of the box security _(especially for the cases where users operate with sensitive fields like "code", "token", "secret", etc.)_.
|
||||||
|
|
||||||
|
Similar to the previous release, probably for most users with already configured API rules this change won't be breaking, but if you have an _intermediate/junction collection_ that is "locked" (superusers-only) we no longer will allow the client-side relation filter to pass through it and you'll have to set its List/Search API rule to enable the current user to search in it.
|
||||||
|
|
||||||
|
For example, if you have a client-side filter that targets `rel1.rel2.token`, the client must have not only List/Search API rule access to the main collection BUT also to the collections referenced by "rel1" and "rel2" relation fields.
|
||||||
|
|
||||||
|
Note that this change is only for the **client-side** `filter`/`sort` and doesn't affect the execution of superuser requests, API rules and `expand` - they continue to work the same as it is.
|
||||||
|
|
||||||
|
An optional environment variable to toggle this behavior was considered but for now I think having 2 ways of resolving client-side filters would introduce maintenance burden and can even cause confusion (this change should actually make things more intuitive and clear because we can simply say something like _"you can search by a collection X field only if you have List/Search API rule access to it"_ no matter whether the targeted collection is the request's main collection, the first or last relation from the filter chain, etc.).
|
||||||
|
|
||||||
|
If you stumble on an error or extreme query performance degradation as a result of the extra checks, please open a Q&A discussion with the failing request and export of your collections configuration as JSON (_Settings > Export collections_) and I'll try to investigate it.
|
||||||
|
|
||||||
|
- Increased the default SQLite `PRAGMA cache_size` to ~32MB.
|
||||||
|
|
||||||
|
- Fixed deadlock when manually triggering the `OnTerminate` hook ([#7305](https://github.com/pocketbase/pocketbase/pull/7305); thanks @yerTools).
|
||||||
|
|
||||||
|
- Fixed some code comment typos, regenerated the JSVM types and updated npm dependencies.
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to 1.40.0.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.31.0
|
||||||
|
|
||||||
|
- Visualize presentable multiple `relation` fields ([#7260](https://github.com/pocketbase/pocketbase/issues/7260)).
|
||||||
|
|
||||||
|
- Support Ed25519 in the optional OIDC `id_token` signature validation ([#7252](https://github.com/pocketbase/pocketbase/issues/7252); thanks @shynome).
|
||||||
|
|
||||||
|
- Added `ApiScenario.DisableTestAppCleanup` optional field to skip the auto test app cleanup and leave it up to the developers to do the cleanup manually ([#7267](https://github.com/pocketbase/pocketbase/discussions/7267)).
|
||||||
|
|
||||||
|
- Added `FileDownloadRequestEvent.ThumbError` field that is populated in case of a thumb generation failure (e.g. unsupported format, timing out, etc.), allowing developers to reject the thumb fallback and/or supply their own custom thumb generation ([#7268](https://github.com/pocketbase/pocketbase/discussions/7268)).
|
||||||
|
|
||||||
|
- ⚠️ Disallow client-side filtering and sorting of relations where the collection of the last targeted relation field has superusers-only List/Search API rule to further minimize the risk of eventual side-channel attack.
|
||||||
|
_This should be a non-breaking change for most users, but if you want the old behavior, please open a new Q&A discussion with details about your use case to evaluate making it configurable._
|
||||||
|
_Note also that as mentioned in the "Security and performance" section of [#4417](https://github.com/pocketbase/pocketbase/discussions/4417) and [#5863](https://github.com/pocketbase/pocketbase/discussions/5863), the easiest and recommended solution to protect security sensitive fields (tokens, codes, passwords, etc.) is to mark them as "Hidden" (aka. make them non-API filterable)._
|
||||||
|
|
||||||
|
- Regenerated JSVM types and updated npm and Go deps.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.30.4
|
||||||
|
|
||||||
|
- Fixed `json` field CSS regression introduced with the overflow workaround in v0.30.3 ([#7259](https://github.com/pocketbase/pocketbase/issues/7259)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.30.3
|
||||||
|
|
||||||
|
- Fixed legacy identitity field priority check when a username is a valid email address ([#7256](https://github.com/pocketbase/pocketbase/issues/7256)).
|
||||||
|
|
||||||
|
- Workaround autocomplete overflow issue with Firefox 144 ([#7223](https://github.com/pocketbase/pocketbase/issues/7223)).
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to 1.39.1 (SQLite 3.50.4).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.30.2
|
||||||
|
|
||||||
|
- Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.30.1
|
||||||
|
|
||||||
|
- ⚠️ Excluded the `lost+found` directory from the backups ([#7208](https://github.com/pocketbase/pocketbase/pull/7208); thanks @lbndev).
|
||||||
|
_If for some reason you want to keep it, you can restore it by editing the `e.Exclude` list of the `OnBackupCreate` and `OnBackupRestore` hooks._
|
||||||
|
|
||||||
|
- Minor tests improvements (disabled initial superuser creation for the test app to avoid cluttering the std output, added more tests for the `s3.Uploader.MaxConcurrency`, etc.).
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` and other Go dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.30.0
|
||||||
|
|
||||||
|
- Eagerly escape the S3 request path following the same rules as in the S3 signing header ([#7153](https://github.com/pocketbase/pocketbase/issues/7153)).
|
||||||
|
|
||||||
|
- Added Lark OAuth2 provider ([#7130](https://github.com/pocketbase/pocketbase/pull/7130); thanks @mashizora).
|
||||||
|
|
||||||
|
- Increased test tokens `exp` claim to minimize eventual issues with reproducible builds ([#7123](https://github.com/pocketbase/pocketbase/issues/7123)).
|
||||||
|
|
||||||
|
- Added `os.Root` bindings to the JSVM ([`$os.openRoot`](https://pocketbase.io/jsvm/functions/_os.openRoot.html), [`$os.openInRoot`](https://pocketbase.io/jsvm/functions/_os.openInRoot.html)).
|
||||||
|
|
||||||
|
- Added `osutils.IsProbablyGoRun()` helper to loosely check if the program was started using `go run`.
|
||||||
|
|
||||||
|
- Various minor UI improvements (updated collections indexes UI, enabled seconds in the datepicker, updated helper texts, etc.).
|
||||||
|
|
||||||
|
- ⚠️ Updated the minimum package Go version to 1.24.0 and bumped Go dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.29.3
|
||||||
|
|
||||||
|
- Try to forward Apple OAuth2 POST redirect user's name so that it can be returned (and eventually assigned) with the success response of the all-in-one auth call ([#7090](https://github.com/pocketbase/pocketbase/issues/7090)).
|
||||||
|
|
||||||
|
- Fixed `RateLimitRule.Audience` code comment ([#7098](https://github.com/pocketbase/pocketbase/pull/7098); thanks @iustin05).
|
||||||
|
|
||||||
|
- Mocked `syscall.Exec` when building for WASM ([#7116](https://github.com/pocketbase/pocketbase/pull/7116); thanks @joas8211).
|
||||||
|
_Note that WASM is not officially supported PocketBase build target and many things may not work as expected._
|
||||||
|
|
||||||
|
- Registered missing `$filesystem`, `$mails`, `$template` and `__hooks` bindings in the JSVM migrations ([#7125](https://github.com/pocketbase/pocketbase/issues/7125)).
|
||||||
|
|
||||||
|
- Regenerated JSVM types to include methods from structs with single generic parameter.
|
||||||
|
|
||||||
|
- Updated Go dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.29.2
|
||||||
|
|
||||||
|
- Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.29.1
|
||||||
|
|
||||||
|
- Updated the X/Twitter provider to return the `confirmed_email` field and to use the `x.com` domain ([#7035](https://github.com/pocketbase/pocketbase/issues/7035)).
|
||||||
|
|
||||||
|
- Added Box.com OAuth2 provider ([#7056](https://github.com/pocketbase/pocketbase/pull/7056); thanks @blakepatteson).
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to 1.38.2 (SQLite 3.50.3).
|
||||||
|
|
||||||
|
- Fixed example List API response ([#7049](https://github.com/pocketbase/pocketbase/pull/7049); thanks @williamtguerra).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.29.0
|
||||||
|
|
||||||
|
- Enabled calling the `/auth-refresh` endpoint with nonrenewable tokens.
|
||||||
|
_When used with nonrenewable tokens (e.g. impersonate) the endpoint will simply return the same token with the up-to-date user data associated with it._
|
||||||
|
|
||||||
|
- Added the triggered rate rimit rule in the error log `details`.
|
||||||
|
|
||||||
|
- Added optional `ServeEvent.Listener` field to initialize a custom network listener (e.g. `unix`) instead of the default `tcp` ([#3233](https://github.com/pocketbase/pocketbase/discussions/3233)).
|
||||||
|
|
||||||
|
- Fixed request data unmarshalization for the `DynamicModel` array/object fields ([#7022](https://github.com/pocketbase/pocketbase/discussions/7022)).
|
||||||
|
|
||||||
|
- Fixed Dashboard page title `-` escaping ([#6982](https://github.com/pocketbase/pocketbase/issues/6982)).
|
||||||
|
|
||||||
|
- Other minor improvements (updated first superuser console text when running with `go run`, clarified trusted IP proxy header label, wrapped the backup restore in a transaction as an extra precaution, updated deps, etc.).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.28.4
|
||||||
|
|
||||||
|
- Added global JSVM `toBytes()` helper to return the bytes slice representation of a value such as io.Reader or string, _other types are first serialized to Go string_ ([#6935](https://github.com/pocketbase/pocketbase/issues/6935)).
|
||||||
|
|
||||||
|
- Fixed `security.RandomStringByRegex` random distribution ([#6947](https://github.com/pocketbase/pocketbase/pull/6947); thanks @yerTools).
|
||||||
|
|
||||||
|
- Minor docs and typos fixes.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.28.3
|
||||||
|
|
||||||
|
- Skip sending empty `Range` header when fetching blobs from S3 ([#6914](https://github.com/pocketbase/pocketbase/pull/6914)).
|
||||||
|
|
||||||
|
- Updated Go deps and particularly `modernc.org/sqlite` to 1.38.0 (SQLite 3.50.1).
|
||||||
|
|
||||||
|
- Bumped GitHub action min Go version to 1.23.10 as it comes with some [minor security `net/http` fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.10+label%3ACherryPickApproved).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.28.2
|
||||||
|
|
||||||
|
- Loaded latin-ext charset for the default text fonts ([#6869](https://github.com/pocketbase/pocketbase/issues/6869)).
|
||||||
|
|
||||||
|
- Updated view query CAST regex to properly recognize multiline expressions ([#6860](https://github.com/pocketbase/pocketbase/pull/6860); thanks @azat-ismagilov).
|
||||||
|
|
||||||
|
- Updated Go and npm dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.28.1
|
||||||
|
|
||||||
|
- Fixed `json_each`/`json_array_length` normalizations to properly check for array values ([#6835](https://github.com/pocketbase/pocketbase/issues/6835)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.28.0
|
||||||
|
|
||||||
|
- Write the default response body of `*Request` hooks that are wrapped in a transaction after the related transaction completes to allow propagating the transaction error ([#6462](https://github.com/pocketbase/pocketbase/discussions/6462#discussioncomment-12207818)).
|
||||||
|
|
||||||
|
- Updated `app.DB()` to automatically routes raw write SQL statements to the nonconcurrent db pool ([#6689](https://github.com/pocketbase/pocketbase/discussions/6689)).
|
||||||
|
_For the rare cases when it is needed users still have the option to explicitly target the specific pool they want using `app.ConcurrentDB()`/`app.NonconcurrentDB()`._
|
||||||
|
|
||||||
|
- ⚠️ Changed the default `json` field max size to 1MB.
|
||||||
|
_Users still have the option to adjust the default limit from the collection field options but keep in mind that storing large strings/blobs in the database is known to cause performance issues and should be avoided when possible._
|
||||||
|
|
||||||
|
- ⚠️ Soft-deprecated and replaced `filesystem.System.GetFile(fileKey)` with `filesystem.System.GetReader(fileKey)` to avoid the confusion with `filesystem.File`.
|
||||||
|
_The old method will still continue to work for at least until v0.29.0 but you'll get a console warning to replace it with `GetReader`._
|
||||||
|
|
||||||
|
- Added new `filesystem.System.GetReuploadableFile(fileKey, preserveName)` method to return an existing blob as a `*filesystem.File` value ([#6792](https://github.com/pocketbase/pocketbase/discussions/6792)).
|
||||||
|
_This method could be useful in case you want to clone an existing Record file and assign it to a new Record (e.g. in a Record duplicate action)._
|
||||||
|
|
||||||
|
- Other minor improvements (updated the GitHub release min Go version to 1.23.9, updated npm and Go deps, etc.)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.27.2
|
||||||
|
|
||||||
|
- Added workers pool when cascade deleting record files to minimize _"thread exhaustion"_ errors ([#6780](https://github.com/pocketbase/pocketbase/discussions/6780)).
|
||||||
|
|
||||||
|
- Updated the `:excerpt` fields modifier to properly account for multibyte characters ([#6778](https://github.com/pocketbase/pocketbase/issues/6778)).
|
||||||
|
|
||||||
|
- Use `rowid` as count column for non-view collections to minimize the need of having the id field in a covering index ([#6739](https://github.com/pocketbase/pocketbase/discussions/6739))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.27.1
|
||||||
|
|
||||||
|
- Updated example `geoPoint` API preview body data.
|
||||||
|
|
||||||
|
- Added JSVM `new GeoPointField({ ... })` constructor.
|
||||||
|
|
||||||
|
- Added _partial_ WebP thumbs generation (_the thumbs will be stored as PNG_; [#6744](https://github.com/pocketbase/pocketbase/pull/6744)).
|
||||||
|
|
||||||
|
- Updated npm dev dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.27.0
|
||||||
|
|
||||||
|
- ⚠️ Moved the Create and Manage API rule checks out of the `OnRecordCreateRequest` hook finalizer, **aka. now all CRUD API rules are checked BEFORE triggering their corresponding `*Request` hook**.
|
||||||
|
This was done to minimize the confusion regarding the firing order of the request operations, making it more predictable and consistent with the other record List/View/Update/Delete request actions.
|
||||||
|
It could be a minor breaking change if you are relying on the old behavior and have a Go `tests.ApiScenario` that is testing a Create API rule failure and expect `OnRecordCreateRequest` to be fired. In that case for example you may have to update your test scenario like:
|
||||||
|
```go
|
||||||
|
tests.ApiScenario{
|
||||||
|
Name: "Example test that checks a Create API rule failure"
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/collections/example/records",
|
||||||
|
...
|
||||||
|
// old:
|
||||||
|
ExpectedEvents: map[string]int{
|
||||||
|
"*": 0,
|
||||||
|
"OnRecordCreateRequest": 1,
|
||||||
|
},
|
||||||
|
// new:
|
||||||
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If you are having difficulties adjusting your code, feel free to open a [Q&A discussion](https://github.com/pocketbase/pocketbase/discussions) with the failing/problematic code sample.
|
||||||
|
|
||||||
|
- Added [new `geoPoint` field](https://pocketbase.io/docs/collections/#geopoint) for storing `{"lon":x,"lat":y}` geographic coordinates.
|
||||||
|
In addition, a new [`geoDistance(lonA, lotA, lonB, lotB)` function](https://pocketbase.io/docs/api-rules-and-filters/#geodistancelona-lata-lonb-latb) was also implemented that could be used to apply an API rule or filter constraint based on the distance (in km) between 2 geo points.
|
||||||
|
|
||||||
|
- Updated the `select` field UI to accommodate better larger lists and RTL languages ([#4674](https://github.com/pocketbase/pocketbase/issues/4674)).
|
||||||
|
|
||||||
|
- Updated the mail attachments auto MIME type detection to use `gabriel-vasile/mimetype` for consistency and broader sniffing signatures support.
|
||||||
|
|
||||||
|
- Forced `text/javascript` Content-Type when serving `.js`/`.mjs` collection uploaded files with the `/api/files/...` endpoint ([#6597](https://github.com/pocketbase/pocketbase/issues/6597)).
|
||||||
|
|
||||||
|
- Added second optional JSVM `DateTime` constructor argument for specifying a default timezone as TZ identifier when parsing the date string as alternative to a fixed offset in order to better handle daylight saving time nuances ([#6688](https://github.com/pocketbase/pocketbase/discussions/6688)):
|
||||||
|
```js
|
||||||
|
// the same as with CET offset: new DateTime("2025-10-26 03:00:00 +01:00")
|
||||||
|
new DateTime("2025-10-26 03:00:00", "Europe/Amsterdam") // 2025-10-26 02:00:00.000Z
|
||||||
|
|
||||||
|
// the same as with CEST offset: new DateTime("2025-10-26 01:00:00 +02:00")
|
||||||
|
new DateTime("2025-10-26 01:00:00", "Europe/Amsterdam") // 2025-10-25 23:00:00.000Z
|
||||||
|
```
|
||||||
|
|
||||||
|
- Soft-deprecated the `$http.send`'s `result.raw` field in favor of `result.body` that contains the response body as plain bytes slice to avoid the discrepancies between Go and the JSVM when casting binary data to string.
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to 1.37.0.
|
||||||
|
|
||||||
|
- Other minor improvements (_removed the superuser fields from the auth record create/update body examples, allowed programmatically updating the auth record password from the create/update hooks, fixed collections import error response, etc._).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.26.6
|
||||||
|
|
||||||
|
- Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to 1.36.3.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.26.5
|
||||||
|
|
||||||
|
- Fixed canonical URI parts escaping when generating the S3 request signature ([#6654](https://github.com/pocketbase/pocketbase/issues/6654)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.26.4
|
||||||
|
|
||||||
|
- Fixed `RecordErrorEvent.Error` and `CollectionErrorEvent.Error` sync with `ModelErrorEvent.Error` ([#6639](https://github.com/pocketbase/pocketbase/issues/6639)).
|
||||||
|
|
||||||
|
- Fixed logs details copy to clipboard action.
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to 1.36.2.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.26.3
|
||||||
|
|
||||||
|
- Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.26.2
|
||||||
|
|
||||||
|
- Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.26.1
|
||||||
|
|
||||||
|
- Removed the wrapping of `io.EOF` error when reading files since currently `io.ReadAll` doesn't check for wrapped errors ([#6600](https://github.com/pocketbase/pocketbase/issues/6600)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.26.0
|
||||||
|
|
||||||
|
- ⚠️ Replaced `aws-sdk-go-v2` and `gocloud.dev/blob` with custom lighter implementation ([#6562](https://github.com/pocketbase/pocketbase/discussions/6562)).
|
||||||
|
As a side-effect of the dependency removal, the binary size has been reduced with ~10MB and builds ~30% faster.
|
||||||
|
_Although the change is expected to be backward-compatible, I'd recommend to test first locally the new version with your S3 provider (if you use S3 for files storage and backups)._
|
||||||
|
|
||||||
|
- ⚠️ Prioritized the user submitted non-empty `createData.email` (_it will be unverified_) when creating the PocketBase user during the first OAuth2 auth.
|
||||||
|
|
||||||
|
- Load the request info context during password/OAuth2/OTP authentication ([#6402](https://github.com/pocketbase/pocketbase/issues/6402)).
|
||||||
|
This could be useful in case you want to target the auth method as part of the MFA and Auth API rules.
|
||||||
|
For example, to disable MFA for the OAuth2 auth could be expressed as `@request.context != "oauth2"` MFA rule.
|
||||||
|
|
||||||
|
- Added `store.Store.SetFunc(key, func(old T) new T)` to set/update a store value with the return result of the callback in a concurrent safe manner.
|
||||||
|
|
||||||
|
- Added `subscription.Message.WriteSSE(w, id)` for writing an SSE formatted message into the provided writer interface (_used mostly to assist with the unit testing_).
|
||||||
|
|
||||||
|
- Added `$os.stat(file)` JSVM helper ([#6407](https://github.com/pocketbase/pocketbase/discussions/6407)).
|
||||||
|
|
||||||
|
- Added log warning for `async` marked JSVM handlers and resolve when possible the returned `Promise` as fallback ([#6476](https://github.com/pocketbase/pocketbase/issues/6476)).
|
||||||
|
|
||||||
|
- Allowed calling `cronAdd`, `cronRemove` from inside other JSVM handlers ([#6481](https://github.com/pocketbase/pocketbase/discussions/6481)).
|
||||||
|
|
||||||
|
- Bumped the default request read and write timeouts to 5mins (_old 3mins_) to accommodate slower internet connections and larger file uploads/downloads.
|
||||||
|
_If you want to change them you can modify the `OnServe` hook's `ServeEvent.ReadTimeout/WriteTimeout` fields as shown in [#6550](https://github.com/pocketbase/pocketbase/discussions/6550#discussioncomment-12364515)._
|
||||||
|
|
||||||
|
- Normalized the `@request.auth.*` and `@request.body.*` back relations resolver to always return `null` when the relation field is pointing to a different collection ([#6590](https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581)).
|
||||||
|
|
||||||
|
- Other minor improvements (_fixed query dev log nested parameters output, reintroduced `DynamicModel` object/array props reflect types caching, updated Go and npm deps, etc._)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.9
|
||||||
|
|
||||||
|
- Fixed `DynamicModel` object/array props reflect type caching ([#6563](https://github.com/pocketbase/pocketbase/discussions/6563)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.8
|
||||||
|
|
||||||
|
- Added a default leeway of 5 minutes for the Apple/OIDC `id_token` timestamp claims check to account for clock-skew ([#6529](https://github.com/pocketbase/pocketbase/issues/6529)).
|
||||||
|
It can be further customized if needed with the `PB_ID_TOKEN_LEEWAY` env variable (_the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute_).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.7
|
||||||
|
|
||||||
|
- Fixed `@request.body.jsonObjOrArr.*` values extraction ([#6493](https://github.com/pocketbase/pocketbase/discussions/6493)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.6
|
||||||
|
|
||||||
|
- Restore the missing `meta.isNew` field of the OAuth2 success response ([#6490](https://github.com/pocketbase/pocketbase/issues/6490)).
|
||||||
|
|
||||||
|
- Updated npm dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.5
|
||||||
|
|
||||||
|
- Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.4
|
||||||
|
|
||||||
|
- Downgraded `aws-sdk-go-v2` to the version before the default data integrity checks because there have been reports for non-AWS S3 providers in addition to Backblaze (IDrive, R2) that no longer or partially work with the latest AWS SDK changes.
|
||||||
|
|
||||||
|
While we try to enforce `when_required` by default, it is not enough to disable the new AWS SDK integrity checks entirely and some providers will require additional manual adjustments to make them compatible with the latest AWS SDK (e.g. removing the `x-aws-checksum-*` headers, unsetting the checksums calculation or reinstantiating the old MD5 checksums for some of the required operations, etc.) which as a result leads to a configuration mess that I'm not sure it would be a good idea to introduce.
|
||||||
|
|
||||||
|
This unfornuatelly is not a PocketBase or Go specific issue and the official AWS SDKs for other languages are in the same situation (even the latest aws-cli).
|
||||||
|
|
||||||
|
For those of you that extend PocketBase with Go: if your S3 vendor doesn't support the [AWS Data integrity checks](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html) and you are updating with `go get -u`, then make sure that the `aws-sdk-go-v2` dependencies in your `go.mod` are the same as in the repo:
|
||||||
|
```
|
||||||
|
// go.mod
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.36.1
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||||
|
|
||||||
|
// after that run
|
||||||
|
go clean -modcache && go mod tidy
|
||||||
|
```
|
||||||
|
_The versions pinning is temporary until the non-AWS S3 vendors patch their implementation or until I manage to find time to remove/replace the `aws-sdk-go-v2` dependency (I'll consider prioritizing it for the v0.26 or v0.27 release)._
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.3
|
||||||
|
|
||||||
|
- Added a temporary exception for Backblaze S3 endpoints to exclude the new `aws-sdk-go-v2` checksum headers ([#6440](https://github.com/pocketbase/pocketbase/discussions/6440)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.2
|
||||||
|
|
||||||
|
- Fixed realtime delete event not being fired for `RecordProxy`-ies and added basic realtime record resolve automated tests ([#6433](https://github.com/pocketbase/pocketbase/issues/6433)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.1
|
||||||
|
|
||||||
|
- Fixed the batch API Preview success sample response.
|
||||||
|
|
||||||
|
- Bumped GitHub action min Go version to 1.23.6 as it comes with a [minor security fix](https://github.com/golang/go/issues?q=milestone%3AGo1.23.6+label%3ACherryPickApproved) for the ppc64le build.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.0
|
||||||
|
|
||||||
|
- ⚠️ Upgraded Google OAuth2 auth, token and userinfo endpoints to their latest versions.
|
||||||
|
_For users that don't do anything custom with the Google OAuth2 data or the OAuth2 auth URL, this should be a non-breaking change. The exceptions that I could find are:_
|
||||||
|
- `/v3/userinfo` auth response changes:
|
||||||
|
```
|
||||||
|
meta.rawUser.id => meta.rawUser.sub
|
||||||
|
meta.rawUser.verified_email => meta.rawUser.email_verified
|
||||||
|
```
|
||||||
|
- `/v2/auth` query parameters changes:
|
||||||
|
If you are specifying custom `approval_prompt=force` query parameter for the OAuth2 auth URL, you'll have to replace it with **`prompt=consent`**.
|
||||||
|
|
||||||
|
- Added Trakt OAuth2 provider ([#6338](https://github.com/pocketbase/pocketbase/pull/6338); thanks @aidan-)
|
||||||
|
|
||||||
|
- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)).
|
||||||
|
|
||||||
|
- Enforced `when_required` for the new AWS SDK request and response checksum validations to allow other non-AWS vendors to catch up with new AWS SDK changes (see [#6313](https://github.com/pocketbase/pocketbase/discussions/6313) and [aws/aws-sdk-go-v2#2960](https://github.com/aws/aws-sdk-go-v2/discussions/2960)).
|
||||||
|
_You can set the environment variables `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_supported` if your S3 vendor supports the [new default integrity protections](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)._
|
||||||
|
|
||||||
|
- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)).
|
||||||
|
|
||||||
|
- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler `osutils.YesNoPrompt(message, fallback)` helper.
|
||||||
|
|
||||||
|
- Upgraded to `golang-jwt/jwt/v5`.
|
||||||
|
|
||||||
|
- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
|
||||||
|
|
||||||
|
- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods.
|
||||||
|
|
||||||
|
- Use the non-transactional app instance during the realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved.
|
||||||
|
|
||||||
|
- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, updated API Preview sample error responses, updated UI dependencies, etc._)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.4
|
||||||
|
|
||||||
|
- Fixed fields extraction for view query with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
|
||||||
|
|
||||||
|
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.3
|
||||||
|
|
||||||
|
- Fixed incorrectly reported unique validator error for fields starting with name of another field ([#6281](https://github.com/pocketbase/pocketbase/pull/6281); thanks @svobol13).
|
||||||
|
|
||||||
|
- Reload the created/edited records data in the RecordsPicker UI.
|
||||||
|
|
||||||
|
- Updated Go dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.2
|
||||||
|
|
||||||
|
- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.1
|
||||||
|
|
||||||
|
- Added missing time macros in the UI autocomplete.
|
||||||
|
|
||||||
|
- Fixed JSVM types for structs and functions with multiple generic parameters.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.0
|
||||||
|
|
||||||
|
- ⚠️ Removed the "dry submit" when executing the collections Create API rule
|
||||||
|
(you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073).
|
||||||
|
For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually.
|
||||||
|
With this change the "multi-match" operators are also normalized in case the targeted collection doesn't have any records
|
||||||
|
(_or in other words, `@collection.example.someField != "test"` will result to `true` if `example` collection has no records because it satisfies the condition that all available "example" records mustn't have `someField` equal to "test"_).
|
||||||
|
As a side-effect of all of the above minor changes, the record create API performance has been also improved ~4x times in high concurrent scenarios (500 concurrent clients inserting total of 50k records - [old (58.409064001s)](https://github.com/pocketbase/benchmarks/blob/54140be5fb0102f90034e1370c7f168fbcf0ddf0/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestdatapublicisset--true) vs [new (13.580098262s)](https://github.com/pocketbase/benchmarks/blob/7df0466ac9bd62fe0a1056270d20ef82012f0234/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestbodypublicisset--true)).
|
||||||
|
|
||||||
|
- ⚠️ Changed the type definition of `store.Store[T any]` to `store.Store[K comparable, T any]` to allow support for custom store key types.
|
||||||
|
For most users it should be non-breaking change, BUT if you are calling `store.New[any](nil)` instances you'll have to specify the store key type, aka. `store.New[string, any](nil)`.
|
||||||
|
|
||||||
|
- Added `@yesterday` and `@tomorrow` datetime filter macros.
|
||||||
|
|
||||||
|
- Added `:lower` filter modifier (e.g. `title:lower = "lorem"`).
|
||||||
|
|
||||||
|
- Added `mailer.Message.InlineAttachments` field for attaching inline files to an email (_aka. `cid` links_).
|
||||||
|
|
||||||
|
- Added cache for the JSVM `arrayOf(m)`, `DynamicModel`, etc. dynamic `reflect` created types.
|
||||||
|
|
||||||
|
- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
|
||||||
|
|
||||||
|
- Added `record.SetRandomPassword()` to simplify random password generation usually used in the OAuth2 or OTP record creation flows.
|
||||||
|
_The generated ~30 chars random password is assigned directly as bcrypt hash and ignores the `password` field plain value validators like min/max length or regex pattern._
|
||||||
|
|
||||||
|
- Added option to list and trigger the registered app level cron jobs via the Web API and UI.
|
||||||
|
|
||||||
|
- Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1).
|
||||||
|
|
||||||
|
- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`.
|
||||||
|
|
||||||
|
- Added `app.FindCachedCollectionReferences(collection, excludeIds)` to speedup records cascade delete almost twice for projects with many collections.
|
||||||
|
|
||||||
|
- Added `tests.NewTestAppWithConfig(config)` helper if you need more control over the test configurations like `IsDev`, the number of allowed connections, etc.
|
||||||
|
|
||||||
|
- Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)).
|
||||||
|
|
||||||
|
- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
|
||||||
|
|
||||||
|
- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule.
|
||||||
|
|
||||||
|
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.12
|
||||||
|
|
||||||
|
- Added warning logs in case of mismatched `modernc.org/sqlite` and `modernc.org/libc` versions ([#6136](https://github.com/pocketbase/pocketbase/issues/6136#issuecomment-2556336962)).
|
||||||
|
|
||||||
|
- Skipped the default body size limit middleware for the backup upload endpoint ([#6152](https://github.com/pocketbase/pocketbase/issues/6152)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.11
|
||||||
|
|
||||||
|
- Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
|
||||||
|
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.10
|
||||||
|
|
||||||
|
- Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
|
||||||
|
|
||||||
|
- Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
|
||||||
|
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.9
|
||||||
|
|
||||||
|
- Replaced `strconv.Itoa` with `strconv.FormatInt` to avoid the int64->int conversion overflow on 32-bit platforms ([#6132](https://github.com/pocketbase/pocketbase/discussions/6132)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.8
|
||||||
|
|
||||||
|
- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)).
|
||||||
|
|
||||||
|
- Other minor improvements (updated Go and npm deps, added extra escaping for the default mail record params in case the emails are stored as html files, fixed code comment typos, etc.).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.7
|
||||||
|
|
||||||
|
- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.6
|
||||||
|
|
||||||
|
- Fixed `$filesystem.fileFromURL` documentation and generated type ([#6058](https://github.com/pocketbase/pocketbase/issues/6058)).
|
||||||
|
|
||||||
|
- Fixed `X-Forwarded-For` header typo in the suggested UI "Common trusted proxy" headers ([#6063](https://github.com/pocketbase/pocketbase/pull/6063)).
|
||||||
|
|
||||||
|
- Updated the `text` field max length validator error message to make it more clear ([#6066](https://github.com/pocketbase/pocketbase/issues/6066)).
|
||||||
|
|
||||||
|
- Other minor fixes (updated Go deps, skipped unnecessary validator check when the default primary key pattern is used, updated JSVM types, etc.).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.5
|
||||||
|
|
||||||
|
- Fixed UI logs search not properly accounting for the "Include requests by superusers" toggle when multiple search expressions are used.
|
||||||
|
|
||||||
|
- Fixed `text` field max validation error message ([#6053](https://github.com/pocketbase/pocketbase/issues/6053)).
|
||||||
|
|
||||||
|
- Other minor fixes (comment typos, JSVM types update).
|
||||||
|
|
||||||
|
- Updated Go deps and the min Go release GitHub action version to 1.23.4.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.4
|
||||||
|
|
||||||
|
- Fixed `autodate` fields not refreshing when calling `Save` multiple times on the same `Record` instance ([#6000](https://github.com/pocketbase/pocketbase/issues/6000)).
|
||||||
|
|
||||||
|
- Added more descriptive test OTP id and failure log message ([#5982](https://github.com/pocketbase/pocketbase/discussions/5982)).
|
||||||
|
|
||||||
|
- Moved the default UI CSP from meta tag to response header ([#5995](https://github.com/pocketbase/pocketbase/discussions/5995)).
|
||||||
|
|
||||||
|
- Updated Go and npm dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.3
|
||||||
|
|
||||||
|
- Fixed Gzip middleware not applying when serving static files.
|
||||||
|
|
||||||
|
- Fixed `Record.Fresh()`/`Record.Clone()` methods not properly cloning `autodate` fields ([#5973](https://github.com/pocketbase/pocketbase/discussions/5973)).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.2
|
||||||
|
|
||||||
|
- Fixed `RecordQuery()` custom struct scanning ([#5958](https://github.com/pocketbase/pocketbase/discussions/5958)).
|
||||||
|
|
||||||
|
- Fixed `--dev` log query print formatting.
|
||||||
|
|
||||||
|
- Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router.
|
||||||
|
|
||||||
|
- Added collection rules change list in the confirmation popup
|
||||||
|
(_to avoid getting anoying during development, the rules confirmation currently is enabled only when using https_).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.1
|
||||||
|
|
||||||
|
- Added `RequestEvent.Blob(status, contentType, bytes)` response write helper ([#5940](https://github.com/pocketbase/pocketbase/discussions/5940)).
|
||||||
|
|
||||||
|
- Added more descriptive error messages.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.23.0
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> You don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing
|
||||||
|
> your existing app and/or are satisfied with the v0.22.x features set. There are no identified critical issues
|
||||||
|
> with PocketBase v0.22.x yet and in the case of critical bugs and security vulnerabilities, the fixes
|
||||||
|
> will be backported for at least until Q1 of 2025 (_if not longer_).
|
||||||
|
>
|
||||||
|
> **If you don't plan upgrading make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:**
|
||||||
|
> - JS SDK: `<0.22.0`
|
||||||
|
> - Dart SDK: `<0.19.0`
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> This release introduces many Go/JSVM and Web APIs breaking changes!
|
||||||
|
>
|
||||||
|
> Existing `pb_data` will be automatically upgraded with the start of the new executable,
|
||||||
|
> but custom Go or JSVM (`pb_hooks`, `pb_migrations`) and JS/Dart SDK code will have to be migrated manually.
|
||||||
|
> Please refer to the below upgrade guides:
|
||||||
|
> - Go: https://pocketbase.io/v023upgrade/go/.
|
||||||
|
> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
|
||||||
|
>
|
||||||
|
> If you had already switched to some of the earlier `<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,75 +2,37 @@
|
|||||||
|
|
||||||
## 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` | 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 |
|
| `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. |
|
| `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. **`core/economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging
|
1. **`economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging
|
||||||
2. **`core/economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one
|
2. **`economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one
|
||||||
3. **`core/economy.py`** - add the EXP reward to `EXP_REWARDS` dict
|
3. **`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. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases
|
10. **`bot.py`** - implement the `cmd_<name>` function, handle all `res["reason"]` cases
|
||||||
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)
|
11. **`bot.py`** - call `_maybe_remind` if the command has a cooldown and reminders make sense
|
||||||
12. **`commands/economy_<group>_commands.py`** - call `await award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` on success
|
12. **`bot.py`** - call `_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 (this helper still lives in `bot.py` and is shared across all command modules)
|
14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,21 +40,21 @@ Checklist - do all of these, in order:
|
|||||||
|
|
||||||
Checklist:
|
Checklist:
|
||||||
|
|
||||||
1. **`core/economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
|
1. **`economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
|
||||||
2. **`core/economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3)
|
2. **`economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3)
|
||||||
3. **`core/economy.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≥10) or T3 (≥20)
|
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)
|
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:
|
||||||
- **`core/economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_<cmd>` function
|
- **`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
|
||||||
- **`commands/economy_profile_commands.py` `cmd_cooldowns`** - add the item annotation to the relevant status line
|
- **`bot.py` `cmd_cooldowns`** - add the item annotation to the relevant status line
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding a New Level Role
|
## Adding a New Level Role
|
||||||
|
|
||||||
1. **`core/economy.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first)
|
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)
|
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
|
||||||
|
|
||||||
@@ -102,7 +64,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. **`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()`
|
3. **`bot.py`** - add `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,7 +72,7 @@ Checklist:
|
|||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
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.
|
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
|
### Currency & Income Sources
|
||||||
|
|
||||||
@@ -141,10 +103,8 @@ 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 `core/economy.py`)
|
### EXP Rewards (from `EXP_REWARDS` in economy.py)
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -179,30 +139,27 @@ 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 `core/economy.py` controls per-item lock thresholds.
|
The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## strings.py Organisation
|
## strings.py Organisation
|
||||||
|
|
||||||
Imported as `import strings as S` everywhere. Dicts are read from `bot.py` and from every `commands/*.py` module.
|
| Section | Dict | Usage in bot.py |
|
||||||
|
|
||||||
| 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` (in `bot.py`) |
|
| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` |
|
||||||
| 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` in `commands/ops_admin_commands.py`) |
|
| Maintenance mode | `MSG_MAINTENANCE` | Shown when `_PAUSED=True` in bot.py (toggled by `/pause`) |
|
||||||
| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown |
|
| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown |
|
||||||
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` (in `commands/economy_games_commands.py`) |
|
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` |
|
||||||
| 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 (`cd_ts` helper passed in by `bot.py`) |
|
| Cooldown messages | `CD_MSG["cmd"].format(ts=_cd_ts(...))` | Cooldown responses |
|
||||||
| Shop UI | `SHOP_UI["key"]` | `_shop_embed` (in `commands/economy_support_commands.py`) |
|
| Shop UI | `SHOP_UI["key"]` | `_shop_embed` |
|
||||||
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `core/economy.py` `SHOP[key]["description"]` |
|
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.SHOP[key]["description"]` |
|
||||||
| Patch notes UI | `PATCHNOTES_UI["key"]` | `commands/info_commands.py` (`/patchnotes`) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -210,18 +167,17 @@ Imported as `import strings as S` everywhere. Dicts are read from `bot.py` and f
|
|||||||
|
|
||||||
| Constant | File | Description |
|
| Constant | File | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SHOP` | `core/economy.py` | All shop items (name, emoji, cost, description) |
|
| `SHOP` | `economy.py` | All shop items (name, emoji, cost, description) |
|
||||||
| `SHOP_TIERS` | `core/economy.py` | Which items are in T1/T2/T3 |
|
| `SHOP_TIERS` | `economy.py` | Which items are in T1/T2/T3 |
|
||||||
| `SHOP_LEVEL_REQ` | `core/economy.py` | Min level per item |
|
| `SHOP_LEVEL_REQ` | `economy.py` | Min level per item |
|
||||||
| `COOLDOWNS` | `core/economy.py` | Base cooldown per command |
|
| `COOLDOWNS` | `economy.py` | Base cooldown per command |
|
||||||
| `JAIL_DURATION` | `core/economy.py` | How long jail lasts |
|
| `JAIL_DURATION` | `economy.py` | How long jail lasts |
|
||||||
| `LEVEL_ROLES` | `core/economy.py` | `[(min_level, "RoleName"), ...]` highest first |
|
| `LEVEL_ROLES` | `economy.py` | `[(min_level, "RoleName"), ...]` highest first |
|
||||||
| `ECONOMY_ROLE` | `core/economy.py` | Name of the base economy participation role |
|
| `ECONOMY_ROLE` | `economy.py` | Name of the base economy participation role |
|
||||||
| `EXP_REWARDS` | `core/economy.py` | EXP per command |
|
| `EXP_REWARDS` | `economy.py` | EXP per command |
|
||||||
| `FISH` | `core/economy.py` | Fish species table (rarity, weight, coins, exp) |
|
| `HOUSE_ID` | `economy.py` | Bot's user ID (house account for /rob) |
|
||||||
| `HOUSE_ID` | `core/economy.py` | Bot's user ID (house account for /rob) |
|
| `MIN_BAIL` | `economy.py` | Minimum bail payment (350⬡) |
|
||||||
| `MIN_BAIL` | `core/economy.py` | Minimum bail payment (350⬡) |
|
| `COIN` | `economy.py` | The coin emoji string |
|
||||||
| `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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
79
docs/LAN_FIENTA_SETUP.md
Normal file
79
docs/LAN_FIENTA_SETUP.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# LAN Fienta Setup
|
||||||
|
|
||||||
|
This profile runs the same codebase as a separate Discord bot process:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:BOT_PROFILE="lan"
|
||||||
|
python bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Required `.env` values:
|
||||||
|
|
||||||
|
```env
|
||||||
|
BOT_PROFILE=lan
|
||||||
|
DISCORD_BOT_LAN=...
|
||||||
|
GUILD_ID_LAN=1301145356750426192
|
||||||
|
SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw
|
||||||
|
PB_ECONOMY_COLLECTION_LAN=economy_users_lan
|
||||||
|
PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan
|
||||||
|
FIENTA_WEBHOOK_SECRET=<optional-long-random-secret>
|
||||||
|
FIENTA_WEBHOOK_PORT=8090
|
||||||
|
FIENTA_ADMIN_ALERT_CHANNEL_ID=1478302279894302812
|
||||||
|
```
|
||||||
|
|
||||||
|
The LAN bot must be invited to the LAN server and to the dev server that owns
|
||||||
|
the alert channel.
|
||||||
|
|
||||||
|
## Fienta Webhooks
|
||||||
|
|
||||||
|
Use these URLs in Fienta:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ostu sooritamisel:
|
||||||
|
https://veebikonks.tipilan.ee/fienta/purchase
|
||||||
|
|
||||||
|
Registreerimisvormi täitmisel peale ostu:
|
||||||
|
https://veebikonks.tipilan.ee/fienta/registration
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `Pileti valideerimisel` empty for now.
|
||||||
|
|
||||||
|
The old secret-token endpoint is still supported for testing:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://veebikonks.tipilan.ee/fienta/webhook/<FIENTA_WEBHOOK_SECRET>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caddy
|
||||||
|
|
||||||
|
If Caddy is on the same Docker network as the bot service:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
veebikonks.tipilan.ee {
|
||||||
|
reverse_proxy /fienta/* bot:8090
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If Caddy runs on the host, make sure `localhost:8090` points to the bot webhook,
|
||||||
|
not PocketBase. The current compose file publishes PocketBase on host port
|
||||||
|
`8090`, so host-level Caddy cannot also proxy that same host port to the bot
|
||||||
|
unless PocketBase is moved or the bot is exposed through a different host route.
|
||||||
|
|
||||||
|
## Ticket Mapping
|
||||||
|
|
||||||
|
- `595507` - CS2 participant, public sheet row, CS2/team/language roles
|
||||||
|
- `595509` - CS2 reserve, roles only
|
||||||
|
- `595510` - CS2 manager, CS2/team/language/Manager roles
|
||||||
|
- `595912` - LoL participant, public sheet row, LoL/team/language roles
|
||||||
|
|
||||||
|
Public sheet rows:
|
||||||
|
|
||||||
|
- `CS2`: rows `6` through `37`
|
||||||
|
- `LoL`: rows `6` through `17`
|
||||||
|
|
||||||
|
## Admin Command
|
||||||
|
|
||||||
|
Use `/fientasync` in the LAN server to re-apply stored Fienta registrations to
|
||||||
|
Discord roles and the public sheet.
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 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.2.0 — 2026-07-22
|
|
||||||
|
|
||||||
- Added `/quests` — daily and weekly quests with a "claim rewards" button. Three daily quests refresh every day and two weekly quests refresh every week; every player gets their own personal set that rotates over time. Complete objectives like working, fishing, wagering, or pulling off crimes to earn TipiCOIN and EXP.
|
|
||||||
- Fixed concurrent commands being able to overwrite each other's balance changes — every economy action now runs under a per-user lock, and house payouts use atomic database increments
|
|
||||||
- Fixed the maintenance pause and channel restrictions never actually being enforced — the global command check was registered incorrectly and silently did nothing (command logging was also affected)
|
|
||||||
|
|
||||||
## 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
|
|
||||||
@@ -44,17 +44,6 @@ Add the following fields:
|
|||||||
|
|
||||||
> **Tip:** Set `user_id` as a unique index under **Indexes** tab.
|
> **Tip:** Set `user_id` as a unique index under **Indexes** tab.
|
||||||
|
|
||||||
The table above is only the base schema. After creating the collections, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/sync_pb_schema.py
|
|
||||||
```
|
|
||||||
|
|
||||||
It derives the complete field list from the bot's user model and adds whatever
|
|
||||||
is missing to **both** collections (stats, fishing, prestige, quests, ...).
|
|
||||||
Re-run it after every update - PocketBase silently drops writes to fields that
|
|
||||||
are not in the collection schema, which breaks features without any error.
|
|
||||||
|
|
||||||
Set **API rules** (all four: list, view, create, update) to admin-only (leave blank / locked).
|
Set **API rules** (all four: list, view, create, update) to admin-only (leave blank / locked).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
-r requirements.txt
|
|
||||||
pytest>=8.0
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"""Add the quest-system JSON fields to the economy_users PocketBase collection.
|
|
||||||
|
|
||||||
Run once after pulling the quest changes:
|
|
||||||
python scripts/add_quest_fields.py
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- PocketBase running and reachable at PB_URL
|
|
||||||
- PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD set in .env
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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
|
|
||||||
# Patch both profiles' collections so the result doesn't depend on which
|
|
||||||
# BOT_PROFILE happened to be set when the script was run.
|
|
||||||
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# New fields to add
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_NEW_JSON_FIELDS = [
|
|
||||||
"quest_daily",
|
|
||||||
"quest_weekly",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _json_field(name: str) -> dict:
|
|
||||||
return {"name": name, "type": "json", "required": False}
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
timeout = aiohttp.ClientTimeout(total=15)
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
# ── Authenticate ────────────────────────────────────────────────────
|
|
||||||
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:
|
|
||||||
print(f"Auth failed ({resp.status}): {await resp.text()}")
|
|
||||||
return
|
|
||||||
token = (await resp.json())["token"]
|
|
||||||
|
|
||||||
hdrs = {"Authorization": token}
|
|
||||||
|
|
||||||
for collection in COLLECTIONS:
|
|
||||||
print(f"── {collection} ──")
|
|
||||||
|
|
||||||
# ── Fetch current collection ─────────────────────────────────────
|
|
||||||
async with session.get(
|
|
||||||
f"{PB_URL}/api/collections/{collection}", headers=hdrs
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
print(f"Could not fetch collection ({resp.status}): {await resp.text()}\n")
|
|
||||||
continue
|
|
||||||
col = await resp.json()
|
|
||||||
|
|
||||||
existing = {f["name"] for f in col.get("fields", [])}
|
|
||||||
print(f"Existing fields ({len(existing)}): {sorted(existing)}\n")
|
|
||||||
|
|
||||||
new_fields = []
|
|
||||||
for name in _NEW_JSON_FIELDS:
|
|
||||||
if name not in existing:
|
|
||||||
new_fields.append(_json_field(name))
|
|
||||||
print(f" + {name} (json)")
|
|
||||||
else:
|
|
||||||
print(f" = {name} (already exists)")
|
|
||||||
|
|
||||||
if not new_fields:
|
|
||||||
print("Nothing to add - schema already up to date.\n")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ── Patch collection schema ──────────────────────────────────────
|
|
||||||
updated_fields = col.get("fields", []) + new_fields
|
|
||||||
async with session.patch(
|
|
||||||
f"{PB_URL}/api/collections/{collection}",
|
|
||||||
json={"fields": updated_fields},
|
|
||||||
headers=hdrs,
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
print(f"Schema update failed ({resp.status}): {await resp.text()}\n")
|
|
||||||
continue
|
|
||||||
print(f"✅ Added {len(new_fields)} field(s) successfully.\n")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -42,32 +42,26 @@ 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 = updated = errors = 0
|
created = skipped = 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:
|
||||||
# Merge JSON fields *onto* the existing record so values that have
|
await pb_client.update_record(existing["id"], record)
|
||||||
# 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}")
|
||||||
updated += 1
|
skipped += 1 # reuse skipped counter as "updated"
|
||||||
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
|
||||||
@@ -75,9 +69,7 @@ async def main() -> None:
|
|||||||
print(f" [ERROR] {uid}: {exc}")
|
print(f" [ERROR] {uid}: {exc}")
|
||||||
errors += 1
|
errors += 1
|
||||||
|
|
||||||
print(f"\nDone. Created: {created} Updated: {updated} Errors: {errors}")
|
print(f"\nDone. Created: {created} Skipped: {skipped} Errors: {errors}")
|
||||||
if errors:
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Destructively recreate economy PocketBase collections for dev + economy profiles.
|
"""Destructively recreate TipiBOT PocketBase collections.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python scripts/reset_pb_collections.py --confirm
|
python scripts/reset_pb_collections.py --confirm
|
||||||
@@ -6,6 +6,8 @@ Usage:
|
|||||||
This will DELETE and recreate the collections configured by:
|
This will DELETE and recreate the collections configured by:
|
||||||
- PB_ECONOMY_COLLECTION_DEV
|
- PB_ECONOMY_COLLECTION_DEV
|
||||||
- PB_ECONOMY_COLLECTION_ECONOMY
|
- PB_ECONOMY_COLLECTION_ECONOMY
|
||||||
|
- PB_ECONOMY_COLLECTION_LAN
|
||||||
|
- PB_FIENTA_COLLECTION_LAN
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -114,6 +116,51 @@ def _collection_payload(name: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fienta_collection_payload(name: str) -> dict:
|
||||||
|
fields = [
|
||||||
|
_text_field("registration_key", required=True),
|
||||||
|
_text_field("order_id"),
|
||||||
|
_text_field("ticket_code"),
|
||||||
|
_text_field("order_status"),
|
||||||
|
_text_field("order_url"),
|
||||||
|
_text_field("payment_time"),
|
||||||
|
_text_field("game"),
|
||||||
|
_text_field("kind"),
|
||||||
|
_text_field("ticket_type_id"),
|
||||||
|
_text_field("ticket_title"),
|
||||||
|
_text_field("ticket_group_title"),
|
||||||
|
_text_field("team_name"),
|
||||||
|
_text_field("discord_username"),
|
||||||
|
_text_field("nickname"),
|
||||||
|
_text_field("country"),
|
||||||
|
_text_field("country_code"),
|
||||||
|
_text_field("riot_id"),
|
||||||
|
_text_field("steam64_id"),
|
||||||
|
_text_field("vrs_ranking"),
|
||||||
|
_bool_field("is_main"),
|
||||||
|
_bool_field("is_reserve"),
|
||||||
|
_bool_field("is_manager"),
|
||||||
|
_bool_field("is_captain"),
|
||||||
|
_bool_field("sheet_public"),
|
||||||
|
_bool_field("blocked_country"),
|
||||||
|
_bool_field("active"),
|
||||||
|
_bool_field("roles_synced"),
|
||||||
|
_text_field("last_sync_error"),
|
||||||
|
_text_field("updated_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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 def _auth_token(session: aiohttp.ClientSession) -> str:
|
||||||
async with session.post(
|
async with session.post(
|
||||||
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
||||||
@@ -138,8 +185,12 @@ async def _delete_if_exists(session: aiohttp.ClientSession, headers: dict[str, s
|
|||||||
print(f"[DELETE] {name}")
|
print(f"[DELETE] {name}")
|
||||||
|
|
||||||
|
|
||||||
async def _create_collection(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None:
|
async def _create_collection(
|
||||||
payload = _collection_payload(name)
|
session: aiohttp.ClientSession,
|
||||||
|
headers: dict[str, str],
|
||||||
|
name: str,
|
||||||
|
payload: dict,
|
||||||
|
) -> None:
|
||||||
async with session.post(f"{PB_URL}/api/collections", json=payload, headers=headers) as resp:
|
async with session.post(f"{PB_URL}/api/collections", json=payload, headers=headers) as resp:
|
||||||
if resp.status not in (200, 201):
|
if resp.status not in (200, 201):
|
||||||
raise RuntimeError(f"Create failed for {name} ({resp.status}): {await resp.text()}")
|
raise RuntimeError(f"Create failed for {name} ({resp.status}): {await resp.text()}")
|
||||||
@@ -154,10 +205,17 @@ async def main() -> None:
|
|||||||
if not args.confirm:
|
if not args.confirm:
|
||||||
raise SystemExit("Refusing to run without --confirm (this operation deletes collections).")
|
raise SystemExit("Refusing to run without --confirm (this operation deletes collections).")
|
||||||
|
|
||||||
targets = []
|
targets: list[tuple[str, dict]] = []
|
||||||
for name in [config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY]:
|
for name in [
|
||||||
if name and name not in targets:
|
config.PB_ECONOMY_COLLECTION_DEV,
|
||||||
targets.append(name)
|
config.PB_ECONOMY_COLLECTION_ECONOMY,
|
||||||
|
config.PB_ECONOMY_COLLECTION_LAN,
|
||||||
|
]:
|
||||||
|
if name and all(existing != name for existing, _ in targets):
|
||||||
|
targets.append((name, _collection_payload(name)))
|
||||||
|
fienta_name = config.PB_FIENTA_COLLECTION_LAN
|
||||||
|
if fienta_name and all(name != fienta_name for name, _ in targets):
|
||||||
|
targets.append((fienta_name, _fienta_collection_payload(fienta_name)))
|
||||||
|
|
||||||
if not targets:
|
if not targets:
|
||||||
raise SystemExit("No target collections configured.")
|
raise SystemExit("No target collections configured.")
|
||||||
@@ -167,12 +225,12 @@ async def main() -> None:
|
|||||||
token = await _auth_token(session)
|
token = await _auth_token(session)
|
||||||
headers = {"Authorization": token}
|
headers = {"Authorization": token}
|
||||||
|
|
||||||
for name in targets:
|
for name, payload in targets:
|
||||||
await _delete_if_exists(session, headers, name)
|
await _delete_if_exists(session, headers, name)
|
||||||
await _create_collection(session, headers, name)
|
await _create_collection(session, headers, name, payload)
|
||||||
|
|
||||||
print("\nDone. Collections recreated:")
|
print("\nDone. Collections recreated:")
|
||||||
for name in targets:
|
for name, _ in targets:
|
||||||
print(f" - {name}")
|
print(f" - {name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Reconcile the PocketBase economy collections with the bot's user schema.
|
|
||||||
|
|
||||||
The expected field list is derived from core.economy._default_user() - the
|
|
||||||
single source of truth for what the bot persists - so this supersedes
|
|
||||||
add_stats_fields.py / add_quest_fields.py and also covers the fields those
|
|
||||||
scripts never added (fishing, prestige, quests, last_heist, ...).
|
|
||||||
|
|
||||||
PocketBase silently drops record fields that are missing from the collection
|
|
||||||
schema, so any drift here breaks features without a single error message.
|
|
||||||
|
|
||||||
Patches BOTH profile collections regardless of BOT_PROFILE. Only ever adds
|
|
||||||
fields; existing fields are never removed or retyped (type mismatches are
|
|
||||||
reported for manual review).
|
|
||||||
|
|
||||||
python scripts/sync_pb_schema.py # add whatever is missing
|
|
||||||
python scripts/sync_pb_schema.py --check # report only; exit 1 on drift
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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
|
|
||||||
from core import economy # noqa: E402
|
|
||||||
|
|
||||||
PB_URL = config.PB_URL
|
|
||||||
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
|
|
||||||
|
|
||||||
# _default_user() keys whose default is None, all ISO date/datetime strings
|
|
||||||
_TEXT_FIELDS = {
|
|
||||||
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
|
||||||
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _expected_fields() -> dict[str, str]:
|
|
||||||
"""Map every persisted field name to its PocketBase field type."""
|
|
||||||
expected = {"user_id": "text"}
|
|
||||||
for key, default in economy._default_user().items():
|
|
||||||
if key in _TEXT_FIELDS:
|
|
||||||
expected[key] = "text"
|
|
||||||
elif isinstance(default, bool):
|
|
||||||
expected[key] = "bool"
|
|
||||||
elif isinstance(default, (int, float)):
|
|
||||||
expected[key] = "number"
|
|
||||||
elif isinstance(default, (list, dict)):
|
|
||||||
expected[key] = "json"
|
|
||||||
else:
|
|
||||||
raise SystemExit(
|
|
||||||
f"Cannot infer a PocketBase type for {key!r} (default {default!r}). "
|
|
||||||
"Add it to _TEXT_FIELDS or extend the mapping."
|
|
||||||
)
|
|
||||||
return expected
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> int:
|
|
||||||
check_only = "--check" in sys.argv
|
|
||||||
expected = _expected_fields()
|
|
||||||
drift = False
|
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=15)
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.post(
|
|
||||||
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
|
||||||
json={"identity": config.PB_ADMIN_EMAIL, "password": config.PB_ADMIN_PASSWORD},
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
print(f"Auth failed ({resp.status}): {await resp.text()}")
|
|
||||||
return 1
|
|
||||||
hdrs = {"Authorization": (await resp.json())["token"]}
|
|
||||||
|
|
||||||
for collection in COLLECTIONS:
|
|
||||||
print(f"── {collection} ──")
|
|
||||||
async with session.get(
|
|
||||||
f"{PB_URL}/api/collections/{collection}", headers=hdrs
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
print(f"Could not fetch collection ({resp.status}): {await resp.text()}\n")
|
|
||||||
drift = True
|
|
||||||
continue
|
|
||||||
col = await resp.json()
|
|
||||||
|
|
||||||
existing = {f["name"]: f.get("type", "?") for f in col.get("fields", [])}
|
|
||||||
|
|
||||||
missing = [name for name in expected if name not in existing]
|
|
||||||
mismatched = [
|
|
||||||
(name, existing[name], ftype)
|
|
||||||
for name, ftype in expected.items()
|
|
||||||
if name in existing and existing[name] != ftype
|
|
||||||
]
|
|
||||||
|
|
||||||
for name, have, want in mismatched:
|
|
||||||
drift = True
|
|
||||||
print(f" ! {name}: schema has type '{have}', bot expects '{want}' - fix manually")
|
|
||||||
|
|
||||||
if not missing:
|
|
||||||
print(" ✓ no missing fields\n" if not mismatched else "")
|
|
||||||
continue
|
|
||||||
|
|
||||||
drift = True
|
|
||||||
for name in missing:
|
|
||||||
print(f" + {name} ({expected[name]})")
|
|
||||||
|
|
||||||
if check_only:
|
|
||||||
print()
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_fields = [{"name": name, "type": expected[name]} for name in missing]
|
|
||||||
async with session.patch(
|
|
||||||
f"{PB_URL}/api/collections/{collection}",
|
|
||||||
json={"fields": col.get("fields", []) + new_fields},
|
|
||||||
headers=hdrs,
|
|
||||||
) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
print(f" Schema update failed ({resp.status}): {await resp.text()}\n")
|
|
||||||
continue
|
|
||||||
print(f" ✅ added {len(new_fields)} field(s)\n")
|
|
||||||
|
|
||||||
return 1 if (check_only and drift) else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(asyncio.run(main()))
|
|
||||||
43
ssssecret.txt
Normal file
43
ssssecret.txt
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Runtime profile
|
||||||
|
BOT_PROFILE=lan
|
||||||
|
|
||||||
|
# Discord bot tokens
|
||||||
|
DISCORD_TOKEN_DEV=MTQ4MjM2NDcxNzI5MTkzMzc2Ng.G8SmBo._5u6z-Tr13DFpd7n1gI2GfjqorYsvV3S-sOnFA
|
||||||
|
DISCORD_TOKEN_ECONOMY=MTQ5MDAzNDM5OTU4Mjg4Mzg3MA.GmN2OX.AFxiZcSPAtoO00ARcT8eXV8JvH8vRysvOM9KPU
|
||||||
|
DISCORD_BOT_LAN=MTQ5MDAzNDM5OTU4Mjg4Mzg3MA.GL25hE.7Fd59Jw52MxxHnfRZtyW33-xSeUsER3teOvHqE
|
||||||
|
DISCORD_TOKEN=
|
||||||
|
|
||||||
|
# Google Sheets
|
||||||
|
SHEET_ID=1TyW075sOxefQYbeowNV7AWO8lHv2pe4nT6CcsdvFd_E
|
||||||
|
SHEET_ID_DEV=1TyW075sOxefQYbeowNV7AWO8lHv2pe4nT6CcsdvFd_E
|
||||||
|
SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw
|
||||||
|
GOOGLE_CREDS_PATH=credentials.json
|
||||||
|
|
||||||
|
# Discord guilds
|
||||||
|
GUILD_ID_DEV=1478302278086819946
|
||||||
|
GUILD_ID_ECONOMY=1301145356750426192
|
||||||
|
GUILD_ID_LAN=1301145356750426192
|
||||||
|
GUILD_ID=
|
||||||
|
|
||||||
|
# Birthday system
|
||||||
|
BIRTHDAY_CHANNEL_ID_DEV=1482398641699291357
|
||||||
|
BIRTHDAY_CHANNEL_ID_ECONOMY=
|
||||||
|
BIRTHDAY_CHANNEL_ID=
|
||||||
|
BIRTHDAY_WINDOW_DAYS=7
|
||||||
|
|
||||||
|
# PocketBase
|
||||||
|
PB_URL=http://127.0.0.1:8090
|
||||||
|
PB_ADMIN_EMAIL=tipilaninfo@gmail.com
|
||||||
|
PB_ADMIN_PASSWORD=salakala
|
||||||
|
|
||||||
|
# PocketBase collections
|
||||||
|
PB_ECONOMY_COLLECTION_DEV=economy_users_dev
|
||||||
|
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod
|
||||||
|
PB_ECONOMY_COLLECTION_LAN=economy_users_lan
|
||||||
|
PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan
|
||||||
|
PB_ECONOMY_COLLECTION=
|
||||||
|
|
||||||
|
# Fienta LAN registration sync
|
||||||
|
FIENTA_WEBHOOK_SECRET=NC6A4BsaPkPmsT3dayph_p7lpP3-ExWpFFkZSKbljdk
|
||||||
|
FIENTA_WEBHOOK_PORT=8090
|
||||||
|
FIENTA_ADMIN_ALERT_CHANNEL_ID=1478302279894302812
|
||||||
179
strings.py
179
strings.py
@@ -4,8 +4,6 @@ Edit this file to change any message, description, or flavour text
|
|||||||
without touching any logic code.
|
without touching any logic code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from core.emoji import EMOJI as E
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Flavour text
|
# Flavour text
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -173,8 +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",
|
"fientasync": "[Admin] Sünkroniseeri LAN Fienta registreeringud uuesti",
|
||||||
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -247,7 +244,6 @@ HELP_CATEGORIES: dict[str, dict] = {
|
|||||||
("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mängijat, max 8. 5 min ühinemisaeg. Õnnestumisel jagatakse saak võrdselt - ebaõnnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."),
|
("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mängijat, max 8. 5 min ühinemisaeg. Õnnestumisel jagatakse saak võrdselt - ebaõnnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."),
|
||||||
("/jailbreak", "Proovi vanglas olles täringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. Ebaõnnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⬡) või jää vanglasse kuni aja lõpuni."),
|
("/jailbreak", "Proovi vanglas olles täringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. Ebaõnnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⬡) või jää vanglasse kuni aja lõpuni."),
|
||||||
("/give @user <amount>", "Anna TipiCOINe teisele mängijale"),
|
("/give @user <amount>", "Anna TipiCOINe teisele mängijale"),
|
||||||
("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."),
|
|
||||||
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
|
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
|
||||||
("/shop", "Sirvi TipiBOTi poodi"),
|
("/shop", "Sirvi TipiBOTi poodi"),
|
||||||
("/buy <item>", "Osta ese TipiBOTi poodist"),
|
("/buy <item>", "Osta ese TipiBOTi poodist"),
|
||||||
@@ -260,22 +256,22 @@ HELP_CATEGORIES: dict[str, dict] = {
|
|||||||
"description": "TipiBOTi poe esemed ja nende efektid",
|
"description": "TipiBOTi poe esemed ja nende efektid",
|
||||||
"color": 0xF4C430,
|
"color": 0xF4C430,
|
||||||
"fields": [
|
"fields": [
|
||||||
(f"{E['TipiHIIR']} Mängurihiir - 500 ⬡", "Teeni töötades 50% rohkem TipiCOINe."),
|
("<:TipiHIIR:1483004306012504128> Mängurihiir - 500 ⬡", "Teeni töötades 50% rohkem TipiCOINe."),
|
||||||
(f"{E['TipiMATT']} XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."),
|
("<:TipiMATT:1483387697132208128> XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."),
|
||||||
(f"{E['TipiKLAPID']} Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."),
|
("<:TipiKLAPID:1483387694083084349> Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."),
|
||||||
(f"{E['TipiPILET']} LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."),
|
("<:TipiPILET:1483004308353060904> LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."),
|
||||||
(f"{E['TipiVAC']} Anticheat - 750 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."),
|
("<:TipiVAC:1483004309510819860> Anticheat - 750 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."),
|
||||||
(f"{E['TipiBULL']} Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."),
|
("<:TipiBULL:1483004310924300409> Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."),
|
||||||
(f"{E['TipiLAP']} Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."),
|
("<:TipiLAP:1483004307161874566> Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."),
|
||||||
(f"{E['TipiLAUD']} Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."),
|
("<:TipiLAUD:1483387695576125440> Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."),
|
||||||
(f"{E['TipiSERVER']} Jellyfin server - 4000 ⬡ *(T2)*", "Röövimise edu tõenäosus 45% → 60%."),
|
("<:TipiSERVER:1483387701032910969> Jellyfin server - 4000 ⬡ *(T2)*", "Röövimise edu tõenäosus 45% → 60%."),
|
||||||
(f"{E['TipiMIC']} Mikrofon - 2800 ⬡ *(T2)*", "Teeni 30% rohkem eduka /crime puhul."),
|
("<:TipiMIC:1483387698499551313> Mikrofon - 2800 ⬡ *(T2)*", "Teeni 30% rohkem eduka /crime puhul."),
|
||||||
(f"{E['TipiKLAVA']} Mehhaaniline klaviatuur - 1800 ⬡ *(T2)*", "/beg teenib 2x rohkem."),
|
("<:TipiKLAVA:1483014339228078140> Mehhaaniline klaviatuur - 1800 ⬡ *(T2)*", "/beg teenib 2x rohkem."),
|
||||||
(f"{E['TipiMONITOR']} Ultralai monitor - 2500 ⬡ *(T2)*", "/work ooteaeg: 1h → 40min."),
|
("<:TipiMONITOR:1483014340327243908> Ultralai monitor - 2500 ⬡ *(T2)*", "/work ooteaeg: 1h → 40min."),
|
||||||
(f"{E['TipiCAT']} CAT6 netikaabel - 3500 ⬡ *(T2)*", "/crime edu tõenäosus tõuseb 60% → 75%."),
|
("<:TipiCAT:1483014337663602718> CAT6 netikaabel - 3500 ⬡ *(T2)*", "/crime edu tõenäosus tõuseb 60% → 75%."),
|
||||||
(f"{E['TipiMONITOR2']} 360hz monitor - 7500 ⬡ *(T3)*", "Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x."),
|
("<:TipiMONITOR2:1483387699514839162> 360hz monitor - 7500 ⬡ *(T3)*", "Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x."),
|
||||||
(f"{E['TipiKARIKAS']} TipiLANi trofee - 6000 ⬡ *(T3)*", "Streak ei nulli, kui sa mõne päeva vahele jätad."),
|
("<:TipiKARIKAS:1483014841148112977> TipiLANi trofee - 6000 ⬡ *(T3)*", "Streak ei nulli, kui sa mõne päeva vahele jätad."),
|
||||||
(f"{E['TipiTOOL']} Mänguritool - 9000 ⬡ *(T3)*", "/crime ebaõnnestumine ei saada sind vanglasse."),
|
("<:TipiTOOL:1483014341648187613> Mänguritool - 9000 ⬡ *(T3)*", "/crime ebaõnnestumine ei saada sind vanglasse."),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"games": {
|
"games": {
|
||||||
@@ -311,6 +307,7 @@ HELP_CATEGORIES: dict[str, dict] = {
|
|||||||
("/channels", "Näita lubatud kanalite nimekirja"),
|
("/channels", "Näita lubatud kanalite nimekirja"),
|
||||||
("/adminseason [top_n]", "Lõpeta võistlus, teavita võitjaid ja lähtesta EXP"),
|
("/adminseason [top_n]", "Lõpeta võistlus, teavita võitjaid ja lähtesta EXP"),
|
||||||
("/economysetup", "Loo ja sea korda majandussüsteemi rollid (ECONOMY + taseme rollid) boti rolli alla"),
|
("/economysetup", "Loo ja sea korda majandussüsteemi rollid (ECONOMY + taseme rollid) boti rolli alla"),
|
||||||
|
("/fientasync", "Sünkroniseeri LAN Fienta registreeringute rollid ja avalik tabel uuesti"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -351,10 +348,10 @@ REMINDER_OPTS: list[tuple[str, str, str]] = [
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
SLOTS_TIERS: dict[str, tuple[str, int]] = {
|
SLOTS_TIERS: dict[str, tuple[str, int]] = {
|
||||||
"jackpot": (f"{E['TipiFIRE']} JACKPOT!!!", 0xF4C430),
|
"jackpot": ("<:TipiFIRE:1483431381668335687> JACKPOT!!!", 0xF4C430),
|
||||||
"triple": ("🎰 Kolmik!", 0x57F287),
|
"triple": ("🎰 Kolmik!", 0x57F287),
|
||||||
"pair": ("🎰 Paar", 0x99AAB5),
|
"pair": ("🎰 Paar", 0x99AAB5),
|
||||||
"miss": (f"{E['TipICRY']} Ei õnnestunud", 0xED4245),
|
"miss": ("<:TipICRY:1483431288852709387> Ei õnnestunud", 0xED4245),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -538,22 +535,22 @@ TITLE: dict[str, str] = {
|
|||||||
"daily": "📅 Päevane boonus",
|
"daily": "📅 Päevane boonus",
|
||||||
"work": "💼 Töö",
|
"work": "💼 Töö",
|
||||||
"beg": "🙏 Kerjamine",
|
"beg": "🙏 Kerjamine",
|
||||||
"crime_win": f"{E['TipiFIRE']} Kuritegu õnnestus!",
|
"crime_win": "<:TipiFIRE:1483431381668335687> Kuritegu õnnestus!",
|
||||||
"crime_fail": f"{E['TipiTROLL']} Vahele jäid!",
|
"crime_fail": "<:TipiTROLL:1483431380166774895> Vahele jäid!",
|
||||||
"rob_win": f"{E['TipiFIRE']} Rööv õnnestus!",
|
"rob_win": "<:TipiFIRE:1483431381668335687> Rööv õnnestus!",
|
||||||
"rob_fail": f"{E['TipiTROLL']} Rööv ebaõnnestus!",
|
"rob_fail": "<:TipiTROLL:1483431380166774895> Rööv ebaõnnestus!",
|
||||||
"rob_anticheat": f"{E['TipiVAC']} Anticheat peatas sind!",
|
"rob_anticheat": "<:TipiVAC:1483004309510819860> Anticheat peatas sind!",
|
||||||
"jailbreak": "🎲 Vanglast põgenemine",
|
"jailbreak": "🎲 Vanglast põgenemine",
|
||||||
"jailbreak_free": f"🎲 {E['TipiFIRE']} DUUBEL! Oled vaba!",
|
"jailbreak_free": "🎲 <:TipiFIRE:1483431381668335687> DUUBEL! Oled vaba!",
|
||||||
"jailbreak_fail": f"{E['TipICRY']} Kolm katset läbi!",
|
"jailbreak_fail": "<:TipICRY:1483431288852709387> Kolm katset läbi!",
|
||||||
"jailbreak_miss": "🎲 " + E["TipICRY"] + " Ei saanud duublit ({tries}/{max})",
|
"jailbreak_miss": "🎲 <:TipICRY:1483431288852709387> Ei saanud duublit ({tries}/{max})",
|
||||||
"jailbreak_bail": "💸 Kautsjon",
|
"jailbreak_bail": "💸 Kautsjon",
|
||||||
"give": f"{E['TipiHEART']} TipiCOINi ülekanne",
|
"give": "<:TipiHEART:1483431377561976853> TipiCOINi ülekanne",
|
||||||
"stats": "📊 Mängustatistika",
|
"stats": "📊 Mängustatistika",
|
||||||
"leaderboard_coins": "🪙 TipiBOTi edetabel - Mündid",
|
"leaderboard_coins": "🪙 TipiBOTi edetabel - Mündid",
|
||||||
"leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase",
|
"leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase",
|
||||||
"leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP",
|
"leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP",
|
||||||
"leaderboard_prestige": f"{E['TipiFIRE']} TipiBOTi edetabel - Prestiiž",
|
"leaderboard_prestige": "<:TipiFIRE:1483431381668335687> TipiBOTi edetabel - Prestiiž",
|
||||||
"leaderboard_wagered": "🎲 TipiBOTi edetabel - Hasartmängud",
|
"leaderboard_wagered": "🎲 TipiBOTi edetabel - Hasartmängud",
|
||||||
"leaderboard_fish": "🎣 TipiBOTi edetabel - Kalapüük",
|
"leaderboard_fish": "🎣 TipiBOTi edetabel - Kalapüük",
|
||||||
"rps": "⚔️ Kivi, Paber, Käärid",
|
"rps": "⚔️ Kivi, Paber, Käärid",
|
||||||
@@ -564,70 +561,32 @@ TITLE: dict[str, str] = {
|
|||||||
"rps_duel_expire": "⚔️ KPK duell - aegus",
|
"rps_duel_expire": "⚔️ KPK duell - aegus",
|
||||||
"rps_duel_decline": "⚔️ KPK duell - keelduti",
|
"rps_duel_decline": "⚔️ KPK duell - keelduti",
|
||||||
"heist_lobby": "🔫 Grupirööv - kogunemine",
|
"heist_lobby": "🔫 Grupirööv - kogunemine",
|
||||||
"heist_win": f"{E['TipiFIRE']} Grupirööv õnnestus!",
|
"heist_win": "<:TipiFIRE:1483431381668335687> Grupirööv õnnestus!",
|
||||||
"heist_fail": f"{E['TipiSKULL']} Grupirööv ebaõnnestus!",
|
"heist_fail": "<:TipiSKULL:1483431378929451028> Grupirööv ebaõnnestus!",
|
||||||
"heist_cancel": "🔫 Grupirööv tühistatud",
|
"heist_cancel": "🔫 Grupirööv tühistatud",
|
||||||
"request": f"{E['TipiHEART']} Rahataotlus",
|
"request": "<:TipiHEART:1483431377561976853> Rahataotlus",
|
||||||
"reminders": "⏰ Meeldetuletused",
|
"reminders": "⏰ Meeldetuletused",
|
||||||
"cooldowns": "⏱️ Sinu ooteajad",
|
"cooldowns": "⏱️ Sinu ooteajad",
|
||||||
"adminseason": "🏆 Hooaeg lõppes!",
|
"adminseason": "🏆 Hooaeg lõppes!",
|
||||||
"economysetup": "⚙️ Majanduse seadistamine",
|
"economysetup": "⚙️ Majanduse seadistamine",
|
||||||
"blackjack": "🃏 Blackjack",
|
"blackjack": "🃏 Blackjack",
|
||||||
"blackjack_bj": f"🃏 {E['TipiFIRE']} BLACKJACK!",
|
"blackjack_bj": "🃏 <:TipiFIRE:1483431381668335687> BLACKJACK!",
|
||||||
"blackjack_win": f"{E['TipiFIRE']} Võitsid!",
|
"blackjack_win": "<:TipiFIRE:1483431381668335687> Võitsid!",
|
||||||
"blackjack_lose": f"{E['TipiSKULL']} Kaotasid!",
|
"blackjack_lose": "<:TipiSKULL:1483431378929451028> Kaotasid!",
|
||||||
"blackjack_bust": f"{E['TipiSKULL']} Üle 21 - kaotasid!",
|
"blackjack_bust": "<:TipiSKULL:1483431378929451028> Üle 21 - kaotasid!",
|
||||||
"blackjack_push": "🤝 Viik!",
|
"blackjack_push": "🤝 Viik!",
|
||||||
"blackjack_dbust": f"{E['TipiSKULL']} Üle 21 - mõlemad kaotasid!",
|
"blackjack_dbust": "<:TipiSKULL:1483431378929451028> Üle 21 - mõlemad kaotasid!",
|
||||||
"blackjack_dwin": f"{E['TipiFIRE']} Topeltpanus võitis!",
|
"blackjack_dwin": "<:TipiFIRE:1483431381668335687> Topeltpanus võitis!",
|
||||||
"prestige_confirm": "🔥 Prestiiž - kinnita",
|
"prestige_confirm": "🔥 Prestiiž - kinnita",
|
||||||
"prestige_success": E["TipiFIRE"] + " Prestiiž {level} saavutatud!",
|
"prestige_success": "<:TipiFIRE:1483431381668335687> Prestiiž {level} saavutatud!",
|
||||||
"prestige_too_low": "❌ Prestiiž pole saadaval",
|
"prestige_too_low": "❌ Prestiiž pole saadaval",
|
||||||
"prestige_shop": f"{E['TipiFIRE']} Prestiižipood",
|
"prestige_shop": "<:TipiFIRE:1483431381668335687> Prestiižipood",
|
||||||
"prestige_buy_ok": "✅ Uuendus ostetud!",
|
"prestige_buy_ok": "✅ Uuendus ostetud!",
|
||||||
"fish_cast": "🎣 Otsid kala...",
|
"fish_cast": "🎣 Otsid kala...",
|
||||||
"fish_bite": "🐟 KALA NÄKKAB!",
|
"fish_bite": "🐟 KALA NÄKKAB!",
|
||||||
"fish_escape": "🎣 Kala pääses!",
|
"fish_escape": "🎣 Kala pääses!",
|
||||||
"fish_junk": "🗑️ Ai ai ai...",
|
"fish_junk": "🗑️ Ai ai ai...",
|
||||||
"fishbook": "📖 Kalakogu",
|
"fishbook": "📖 Kalakogu",
|
||||||
"quests": "🎯 Ülesanded",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Quest system (/quests)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
QUEST_UI: dict[str, str] = {
|
|
||||||
"daily_header": "📅 Päevaülesanded",
|
|
||||||
"weekly_header": "📆 Nädalaülesanded",
|
|
||||||
"progress": "⏳ {progress}/{max}",
|
|
||||||
"ready": "✅ Valmis - nõua auhind!",
|
|
||||||
"completed": "🏆 Nõutud",
|
|
||||||
"reward": "🎁 {coins} ⬡ · {exp} EXP",
|
|
||||||
"empty": "Ühtegi ülesannet pole. Proovi hiljem uuesti!",
|
|
||||||
"claim_btn": "🎁 Nõua auhinnad",
|
|
||||||
"claimed_msg": "🏆 Nõudsid {count} ülesande auhinnad: +{coins} · +{exp} EXP!",
|
|
||||||
"nothing": "Sul pole ühtegi valmis ülesannet, mida nõuda.",
|
|
||||||
"error": "❌ Ülesannete laadimine ebaõnnestus. Proovi hiljem uuesti.",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Estonian one-line description per quest id (keys match economy.QUESTS_*).
|
|
||||||
QUEST_DESCRIPTIONS: dict[str, str] = {
|
|
||||||
# Daily
|
|
||||||
"work3": "Tööta 3 korda (/work)",
|
|
||||||
"beg5": "Kerja 5 korda (/beg)",
|
|
||||||
"wager500": "Panusta kokku 500 ⬡ hasartmängudes",
|
|
||||||
"fish2": "Püüa 2 kala (/fish)",
|
|
||||||
"crime1": "Soorita edukalt 1 kuritegu (/crime)",
|
|
||||||
"earn1000": "Teeni kokku 1 000 ⬡",
|
|
||||||
"give200": "Kingi teistele kokku 200 ⬡ (/give)",
|
|
||||||
# Weekly
|
|
||||||
"work20": "Tööta 20 korda (/work)",
|
|
||||||
"fish15": "Püüa 15 kala (/fish)",
|
|
||||||
"wager5000": "Panusta kokku 5 000 ⬡ hasartmängudes",
|
|
||||||
"crime5": "Soorita edukalt 5 kuritegu (/crime)",
|
|
||||||
"heist1": "Osale 1 grupiröövis (/heist)",
|
|
||||||
"earn10000": "Teeni kokku 10 000 ⬡",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -651,8 +610,8 @@ ERR: dict[str, str] = {
|
|||||||
"heist_active": "❌ Serveris on juba aktiivne grupirööv käimas! Oota, kuni see lõpeb.",
|
"heist_active": "❌ Serveris on juba aktiivne grupirööv käimas! Oota, kuni see lõpeb.",
|
||||||
"heist_full": "❌ Grupirööv on täis!",
|
"heist_full": "❌ Grupirööv on täis!",
|
||||||
"heist_min_players": "❌ Grupiröövi alustamiseks on vaja vähemalt **{min}** osalejat.",
|
"heist_min_players": "❌ Grupiröövi alustamiseks on vaja vähemalt **{min}** osalejat.",
|
||||||
"broke": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Saldo: {bal}",
|
"broke": "<:TipICRY:1483431288852709387> Sul pole piisavalt TipiCOINe. Saldo: {bal}",
|
||||||
"broke_need": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Vajad veel {need}.",
|
"broke_need": "<:TipICRY:1483431288852709387> Sul pole piisavalt TipiCOINe. Vajad veel {need}.",
|
||||||
"item_owned": "❌ Sul on see ese juba olemas.",
|
"item_owned": "❌ Sul on see ese juba olemas.",
|
||||||
"item_not_found": "❌ Eset ei leitud.",
|
"item_not_found": "❌ Eset ei leitud.",
|
||||||
"item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kõiki käske kasutades.",
|
"item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kõiki käske kasutades.",
|
||||||
@@ -695,7 +654,7 @@ CD_MSG: dict[str, str] = {
|
|||||||
"rob": "⏳ Saad uuesti röövida {ts}.",
|
"rob": "⏳ Saad uuesti röövida {ts}.",
|
||||||
"heist": "⏳ Saad uuesti heisti teha {ts}.",
|
"heist": "⏳ Saad uuesti heisti teha {ts}.",
|
||||||
"heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. Järgmine heist võimalik {ts}.",
|
"heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. Järgmine heist võimalik {ts}.",
|
||||||
"jailed": E["TipiTROLL"] + " Oled vangis! Pääsed välja {ts}. Kasuta `/jailbreak`, et varem välja pääseda.",
|
"jailed": "<:TipiTROLL:1483431380166774895> Oled vangis! Pääsed välja {ts}. Kasuta `/jailbreak`, et varem välja pääseda.",
|
||||||
"fish": "🎣 Saad uuesti kalastada {ts}.",
|
"fish": "🎣 Saad uuesti kalastada {ts}.",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,20 +754,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -879,8 +824,8 @@ CHANNEL_UI: dict[str, str] = {
|
|||||||
|
|
||||||
DAILY_UI: dict[str, str] = {
|
DAILY_UI: dict[str, str] = {
|
||||||
"earned": "✅ Said {earned}!",
|
"earned": "✅ Said {earned}!",
|
||||||
"interest": E["TipiLAP"] + " Bot Farm tootis: +{interest}",
|
"interest": "<:TipiLAP:1483004307161874566> Bot Farm tootis: +{interest}",
|
||||||
"vip": f"{E['TipiPILET']} LAN pileti boonus rakendus!",
|
"vip": "<:TipiPILET:1483004308353060904> LAN pileti boonus rakendus!",
|
||||||
"footer": "Streak: {streak_str} · Saldo: {balance}",
|
"footer": "Streak: {streak_str} · Saldo: {balance}",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1092,9 +1037,9 @@ RANK_UI: dict[str, str] = {
|
|||||||
|
|
||||||
WORK_UI: dict[str, str] = {
|
WORK_UI: dict[str, str] = {
|
||||||
"desc": "Sa {job} ja teenisid {earned}!",
|
"desc": "Sa {job} ja teenisid {earned}!",
|
||||||
"redbull": f"\n{E['TipiBULL']} Red Bull aktiveerus - 3x boonus!",
|
"redbull": "\n<:TipiBULL:1483004310924300409> Red Bull aktiveerus - 3x boonus!",
|
||||||
"hiir": f"\n{E['TipiHIIR']} Mängurihiir: +50% palk",
|
"hiir": "\n<:TipiHIIR:1483004306012504128> Mängurihiir: +50% palk",
|
||||||
"laud": f"\n{E['TipiLAUD']} Reguleeritav laud: +25% palk",
|
"laud": "\n<:TipiLAUD:1483387695576125440> Reguleeritav laud: +25% palk",
|
||||||
"balance": "\nSaldo: {balance}",
|
"balance": "\nSaldo: {balance}",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,7 +1049,7 @@ WORK_UI: dict[str, str] = {
|
|||||||
|
|
||||||
BEG_UI: dict[str, str] = {
|
BEG_UI: dict[str, str] = {
|
||||||
"desc": "Sa {text} ja said {earned}.",
|
"desc": "Sa {text} ja said {earned}.",
|
||||||
"klaviatuur": f"{E['TipiKLAVA']} Mehhaaniline klaviatuur: 2x tulu",
|
"klaviatuur": "<:TipiKLAVA:1483014339228078140> Mehhaaniline klaviatuur: 2x tulu",
|
||||||
"balance": "Saldo: {balance}",
|
"balance": "Saldo: {balance}",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1117,8 +1062,8 @@ CRIME_UI: dict[str, str] = {
|
|||||||
"fail_base": "Sa {text} ja said trahvi {fine}.",
|
"fail_base": "Sa {text} ja said trahvi {fine}.",
|
||||||
"fail_jailed": "\n\ud83d\udd12 Oled vangis! P\u00e4\u00e4sed {ts}.",
|
"fail_jailed": "\n\ud83d\udd12 Oled vangis! P\u00e4\u00e4sed {ts}.",
|
||||||
"fail_shield": "\n\ud83d\udee1\ufe0f Gaming Tool hoidis sind vanglast!",
|
"fail_shield": "\n\ud83d\udee1\ufe0f Gaming Tool hoidis sind vanglast!",
|
||||||
"mikrofon": f"\n{E['TipiMIC']} Mikrofon: +30% saak",
|
"mikrofon": "\n<:TipiMIC:1483387698499551313> Mikrofon: +30% saak",
|
||||||
"cat6": f"\n{E['TipiCAT']} CAT6: 75% edu t\u00f5en\u00e4osus",
|
"cat6": "\n<:TipiCAT:1483014337663602718> CAT6: 75% edu t\u00f5en\u00e4osus",
|
||||||
"balance": "\nSaldo: {balance}",
|
"balance": "\nSaldo: {balance}",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,7 +1103,7 @@ BUY_UI: dict[str, str] = {
|
|||||||
|
|
||||||
JAILBREAK_UI: dict[str, str] = {
|
JAILBREAK_UI: dict[str, str] = {
|
||||||
"btn_roll": "🎲 Viska täringud ({try_}/{max})",
|
"btn_roll": "🎲 Viska täringud ({try_}/{max})",
|
||||||
"rolling_desc": f"{E['TipiDICE']} *Täringud lendavad...*",
|
"rolling_desc": "<:TipiDICE:1485923107108556950> *Täringud lendavad...*",
|
||||||
"free_desc": "{d1} {d2}\n\n✅ Viskasid duubli - pääsesid vanglast!",
|
"free_desc": "{d1} {d2}\n\n✅ Viskasid duubli - pääsesid vanglast!",
|
||||||
"miss_desc": "{d1} {d2}\n\n{left} katset jäänud. Proovi uuesti!",
|
"miss_desc": "{d1} {d2}\n\n{left} katset jäänud. Proovi uuesti!",
|
||||||
"intro_desc": "Oled vangis kuni {ts}.\n\nViska täringuid ja proovi **duublit** saada - siis pääsed tasuta vabaks!\nSul on **{tries} katset**. Ebaõnnestumisel saad valida: maksa kautsjon **(20–30% saldost, min 350 ⬡)** või jää vanglasse kuni aja lõpuni.",
|
"intro_desc": "Oled vangis kuni {ts}.\n\nViska täringuid ja proovi **duublit** saada - siis pääsed tasuta vabaks!\nSul on **{tries} katset**. Ebaõnnestumisel saad valida: maksa kautsjon **(20–30% saldost, min 350 ⬡)** või jää vanglasse kuni aja lõpuni.",
|
||||||
@@ -1202,7 +1147,7 @@ LEADERBOARD_UI: dict[str, str] = {
|
|||||||
|
|
||||||
SLOTS_UI: dict[str, str] = {
|
SLOTS_UI: dict[str, str] = {
|
||||||
"playing": "🎰 Mängimas...",
|
"playing": "🎰 Mängimas...",
|
||||||
"jackpot_footer": E["TipiKARIKAS"] + " Kolm karikat! +{change}",
|
"jackpot_footer": "<:TipiKARIKAS:1483014841148112977> Kolm karikat! +{change}",
|
||||||
"triple_footer": "✅ Kolm ühesugust! +{change}",
|
"triple_footer": "✅ Kolm ühesugust! +{change}",
|
||||||
"pair_footer": "Kaks ühesugust! +{change}",
|
"pair_footer": "Kaks ühesugust! +{change}",
|
||||||
"miss_footer": "-{amount}",
|
"miss_footer": "-{amount}",
|
||||||
@@ -1306,7 +1251,7 @@ PRESTIGE_SHOP_DESCRIPTIONS: dict[str, str] = {
|
|||||||
PRESTIGE_UI: dict[str, str] = {
|
PRESTIGE_UI: dict[str, str] = {
|
||||||
"confirm_desc": (
|
"confirm_desc": (
|
||||||
"Oled tasemel **{level}** ({exp} EXP).\n\n"
|
"Oled tasemel **{level}** ({exp} EXP).\n\n"
|
||||||
"Prestiiži korral saad **{pp}** " + E["TipiFIRE"] + " ja kõik lähtestub:\n"
|
"Prestiiži korral saad **{pp}** <:TipiFIRE:1483431381668335687> ja kõik lähtestub:\n"
|
||||||
"• Saldo, EXP, esemed, ooteajad\n\n"
|
"• Saldo, EXP, esemed, ooteajad\n\n"
|
||||||
"**Kalakogu jääb alles!**\n\nKas oled kindel?"
|
"**Kalakogu jääb alles!**\n\nKas oled kindel?"
|
||||||
),
|
),
|
||||||
@@ -1315,21 +1260,21 @@ PRESTIGE_UI: dict[str, str] = {
|
|||||||
"btn_tab_status": "⭐ Prestiiz",
|
"btn_tab_status": "⭐ Prestiiz",
|
||||||
"btn_tab_shop": "🛍️ Uuendused",
|
"btn_tab_shop": "🛍️ Uuendused",
|
||||||
"success_desc": (
|
"success_desc": (
|
||||||
"Said **{pp}** " + E["TipiFIRE"] + "\n"
|
"Said **{pp}** <:TipiFIRE:1483431381668335687>\n"
|
||||||
"Prestiiži tase: **{level}**\n"
|
"Prestiiži tase: **{level}**\n"
|
||||||
"Kogutud PP: **{total_pp}** " + E["TipiFIRE"] + "\n\n"
|
"Kogutud PP: **{total_pp}** <:TipiFIRE:1483431381668335687>\n\n"
|
||||||
"*Kõik lähtestati. Alusta otsast!*"
|
"*Kõik lähtestati. Alusta otsast!*"
|
||||||
),
|
),
|
||||||
"too_low_desc": "Prestiiži jaoks vajad taset **{required}** (sul on tase {level}).",
|
"too_low_desc": "Prestiiži jaoks vajad taset **{required}** (sul on tase {level}).",
|
||||||
"shop_desc": "Sul on **{pp}** " + E["TipiFIRE"] + " · Vajuta nuppu uuenduse ostmiseks",
|
"shop_desc": "Sul on **{pp}** <:TipiFIRE:1483431381668335687> · Vajuta nuppu uuenduse ostmiseks",
|
||||||
"shop_maxed": "✅ Max",
|
"shop_maxed": "✅ Max",
|
||||||
"shop_level_fmt": "Tase {cur}/{max}",
|
"shop_level_fmt": "Tase {cur}/{max}",
|
||||||
"shop_cost_fmt": "{cost} " + E["TipiFIRE"],
|
"shop_cost_fmt": "{cost} <:TipiFIRE:1483431381668335687>",
|
||||||
"buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** " + E["TipiFIRE"],
|
"buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** <:TipiFIRE:1483431381668335687>",
|
||||||
"buy_no_pp": E["TipICRY"] + " Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** " + E["TipiFIRE"] + ".",
|
"buy_no_pp": "<:TipICRY:1483431288852709387> Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** <:TipiFIRE:1483431381668335687>.",
|
||||||
"buy_maxed": "❌ See uuendus on juba maksimumtasemel.",
|
"buy_maxed": "❌ See uuendus on juba maksimumtasemel.",
|
||||||
"buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.",
|
"buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.",
|
||||||
"rank_line": E["TipiFIRE"] + " Prestiiž **{level}** · {pp} PP",
|
"rank_line": "<:TipiFIRE:1483431381668335687> Prestiiž **{level}** · {pp} PP",
|
||||||
"rank_season": "🏆 Hooaja EXP: **{exp}**",
|
"rank_season": "🏆 Hooaja EXP: **{exp}**",
|
||||||
"btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)",
|
"btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)",
|
||||||
"status_footer": "⭐ Prestiiž {level} · {pp} PP",
|
"status_footer": "⭐ Prestiiž {level} · {pp} PP",
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
"""Shared fixtures: an in-memory PocketBase stand-in wired into core.economy."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import copy
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from core import economy, pb_client # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
class FakePocketBase:
|
|
||||||
"""In-memory stand-in for core.pb_client.
|
|
||||||
|
|
||||||
Mimics the behaviours the economy layer depends on:
|
|
||||||
- records are returned as deep copies (like JSON over REST)
|
|
||||||
- "field+" / "field-" body keys are atomic number modifiers
|
|
||||||
- fields not in `schema_fields` are silently dropped, like PocketBase
|
|
||||||
does for fields missing from the collection schema (schema_fields=None
|
|
||||||
keeps everything)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, schema_fields: set[str] | None = None):
|
|
||||||
self.records: dict[str, dict] = {}
|
|
||||||
self.schema_fields = schema_fields
|
|
||||||
self._next_id = 0
|
|
||||||
|
|
||||||
def _filter(self, data: dict) -> dict:
|
|
||||||
if self.schema_fields is None:
|
|
||||||
return dict(data)
|
|
||||||
return {k: v for k, v in data.items() if k.rstrip("+-") in self.schema_fields}
|
|
||||||
|
|
||||||
def _apply(self, record: dict, data: dict) -> None:
|
|
||||||
for key, value in self._filter(data).items():
|
|
||||||
if key.endswith("+"):
|
|
||||||
record[key[:-1]] = record.get(key[:-1], 0) + value
|
|
||||||
elif key.endswith("-"):
|
|
||||||
record[key[:-1]] = record.get(key[:-1], 0) - value
|
|
||||||
else:
|
|
||||||
record[key] = value
|
|
||||||
|
|
||||||
async def get_record(self, user_id: str) -> dict | None:
|
|
||||||
await asyncio.sleep(0) # yield, so unserialized tasks would interleave
|
|
||||||
for record in self.records.values():
|
|
||||||
if record.get("user_id") == user_id:
|
|
||||||
return copy.deepcopy(record)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def create_record(self, record: dict) -> dict:
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
self._next_id += 1
|
|
||||||
stored = self._filter(record)
|
|
||||||
stored["id"] = f"rec{self._next_id}"
|
|
||||||
stored["user_id"] = record.get("user_id", "")
|
|
||||||
self.records[stored["id"]] = stored
|
|
||||||
return copy.deepcopy(stored)
|
|
||||||
|
|
||||||
async def update_record(self, record_id: str, data: dict) -> dict:
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
record = self.records[record_id]
|
|
||||||
self._apply(record, data)
|
|
||||||
return copy.deepcopy(record)
|
|
||||||
|
|
||||||
async def list_all_records(self, page_size: int = 500) -> list[dict]:
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
return copy.deepcopy(list(self.records.values()))
|
|
||||||
|
|
||||||
async def count_records(self) -> int:
|
|
||||||
return len(self.records)
|
|
||||||
|
|
||||||
async def get_collection_fields(self) -> set[str]:
|
|
||||||
if self.schema_fields is None:
|
|
||||||
return set(economy._default_user()) | {"user_id"}
|
|
||||||
return set(self.schema_fields)
|
|
||||||
|
|
||||||
# -- test helpers -------------------------------------------------------
|
|
||||||
def record_for(self, user_id: int) -> dict:
|
|
||||||
for record in self.records.values():
|
|
||||||
if record.get("user_id") == str(user_id):
|
|
||||||
return record
|
|
||||||
raise KeyError(user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _install(monkeypatch, fake: FakePocketBase) -> FakePocketBase:
|
|
||||||
for name in ("get_record", "create_record", "update_record",
|
|
||||||
"list_all_records", "count_records", "get_collection_fields"):
|
|
||||||
monkeypatch.setattr(pb_client, name, getattr(fake, name))
|
|
||||||
# live house state is owned by economy.house (the package re-export is a snapshot)
|
|
||||||
monkeypatch.setattr(economy.house, "HOUSE_ID", None)
|
|
||||||
monkeypatch.setattr(economy.house, "_house_pb_id", None)
|
|
||||||
economy._user_locks.clear()
|
|
||||||
return fake
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_pb(monkeypatch) -> FakePocketBase:
|
|
||||||
return _install(monkeypatch, FakePocketBase())
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_pb_without_quest_fields(monkeypatch) -> FakePocketBase:
|
|
||||||
"""A fake whose collection schema predates the quest migration."""
|
|
||||||
schema = set(economy._default_user().keys()) | {"user_id"}
|
|
||||||
schema -= {"quest_daily", "quest_weekly"}
|
|
||||||
return _install(monkeypatch, FakePocketBase(schema_fields=schema))
|
|
||||||
|
|
||||||
|
|
||||||
def run(coro):
|
|
||||||
return asyncio.run(coro)
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
"""Tests for the async economy flows against the in-memory PocketBase fake."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import random
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from core import economy
|
|
||||||
|
|
||||||
from conftest import run
|
|
||||||
|
|
||||||
UID = 111
|
|
||||||
OTHER = 222
|
|
||||||
HOUSE = 999
|
|
||||||
|
|
||||||
|
|
||||||
def _fixed_now(monkeypatch, dt: datetime):
|
|
||||||
# _clock is the time seam shared by every economy submodule
|
|
||||||
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetUser:
|
|
||||||
def test_creates_default_record(self, fake_pb):
|
|
||||||
user = run(economy.get_user(UID))
|
|
||||||
assert user["balance"] == 0
|
|
||||||
assert fake_pb.record_for(UID)["user_id"] == str(UID)
|
|
||||||
|
|
||||||
def test_roundtrips_existing_data(self, fake_pb):
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
fake_pb.record_for(UID)["balance"] = 1234
|
|
||||||
assert run(economy.get_user(UID))["balance"] == 1234
|
|
||||||
|
|
||||||
|
|
||||||
class TestDaily:
|
|
||||||
def test_first_claim(self, fake_pb):
|
|
||||||
res = run(economy.do_daily(UID))
|
|
||||||
assert res["ok"] and res["streak"] == 1 and res["earned"] == 150
|
|
||||||
|
|
||||||
def test_cooldown_blocks_second_claim(self, fake_pb):
|
|
||||||
run(economy.do_daily(UID))
|
|
||||||
res = run(economy.do_daily(UID))
|
|
||||||
assert not res["ok"] and res["reason"] == "cooldown"
|
|
||||||
|
|
||||||
def test_streak_increments_next_day(self, fake_pb, monkeypatch):
|
|
||||||
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
|
|
||||||
run(economy.do_daily(UID))
|
|
||||||
_fixed_now(monkeypatch, t0 + timedelta(days=1))
|
|
||||||
res = run(economy.do_daily(UID))
|
|
||||||
assert res["ok"] and res["streak"] == 2
|
|
||||||
|
|
||||||
def test_streak_resets_after_missed_day(self, fake_pb, monkeypatch):
|
|
||||||
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
|
|
||||||
run(economy.do_daily(UID))
|
|
||||||
_fixed_now(monkeypatch, t0 + timedelta(days=3))
|
|
||||||
res = run(economy.do_daily(UID))
|
|
||||||
assert res["ok"] and res["streak"] == 1
|
|
||||||
|
|
||||||
def test_karikas_preserves_streak(self, fake_pb, monkeypatch):
|
|
||||||
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
|
|
||||||
run(economy.do_daily(UID))
|
|
||||||
fake_pb.record_for(UID)["items"] = ["karikas"]
|
|
||||||
fake_pb.record_for(UID)["daily_streak"] = 5
|
|
||||||
_fixed_now(monkeypatch, t0 + timedelta(days=3))
|
|
||||||
res = run(economy.do_daily(UID))
|
|
||||||
assert res["ok"] and res["streak"] == 5
|
|
||||||
|
|
||||||
def test_streak_multiplier_tiers(self, fake_pb, monkeypatch):
|
|
||||||
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc))
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
rec = fake_pb.record_for(UID)
|
|
||||||
rec["daily_streak"] = 13
|
|
||||||
rec["last_streak_date"] = (t0.date() - timedelta(days=1)).isoformat()
|
|
||||||
res = run(economy.do_daily(UID))
|
|
||||||
assert res["streak"] == 14 and res["streak_mult"] == 3.0 and res["earned"] == 450
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuy:
|
|
||||||
def test_insufficient_funds(self, fake_pb):
|
|
||||||
res = run(economy.do_buy(UID, "gaming_hiir"))
|
|
||||||
assert not res["ok"] and res["reason"] == "insufficient"
|
|
||||||
|
|
||||||
def test_purchase_and_rebuy_blocked(self, fake_pb):
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
fake_pb.record_for(UID)["balance"] = 1000
|
|
||||||
res = run(economy.do_buy(UID, "gaming_hiir"))
|
|
||||||
assert res["ok"] and res["balance"] == 500
|
|
||||||
assert "gaming_hiir" in fake_pb.record_for(UID)["items"]
|
|
||||||
res = run(economy.do_buy(UID, "gaming_hiir"))
|
|
||||||
assert not res["ok"] and res["reason"] == "owned"
|
|
||||||
|
|
||||||
def test_tier2_requires_level(self, fake_pb):
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
fake_pb.record_for(UID)["balance"] = 100_000
|
|
||||||
res = run(economy.do_buy(UID, "jellyfin"))
|
|
||||||
assert not res["ok"] and res["reason"] == "level_required"
|
|
||||||
fake_pb.record_for(UID)["exp"] = economy.exp_for_level(10)
|
|
||||||
assert run(economy.do_buy(UID, "jellyfin"))["ok"]
|
|
||||||
|
|
||||||
def test_anticheat_repurchase_after_depletion(self, fake_pb):
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
rec = fake_pb.record_for(UID)
|
|
||||||
rec["balance"] = 10_000
|
|
||||||
assert run(economy.do_buy(UID, "anticheat"))["ok"]
|
|
||||||
assert fake_pb.record_for(UID)["item_uses"]["anticheat"] == 2
|
|
||||||
fake_pb.record_for(UID)["item_uses"]["anticheat"] = 0
|
|
||||||
assert run(economy.do_buy(UID, "anticheat"))["ok"]
|
|
||||||
assert fake_pb.record_for(UID)["item_uses"]["anticheat"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestGive:
|
|
||||||
def test_transfer(self, fake_pb):
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
fake_pb.record_for(UID)["balance"] = 500
|
|
||||||
res = run(economy.do_give(UID, OTHER, 200))
|
|
||||||
assert res["ok"]
|
|
||||||
assert fake_pb.record_for(UID)["balance"] == 300
|
|
||||||
assert fake_pb.record_for(OTHER)["balance"] == 200
|
|
||||||
|
|
||||||
def test_insufficient(self, fake_pb):
|
|
||||||
res = run(economy.do_give(UID, OTHER, 50))
|
|
||||||
assert not res["ok"] and res["reason"] == "insufficient"
|
|
||||||
|
|
||||||
def test_opposite_transfers_do_not_deadlock(self, fake_pb):
|
|
||||||
async def both():
|
|
||||||
for uid in (UID, OTHER):
|
|
||||||
await economy.get_user(uid)
|
|
||||||
fake_pb.record_for(uid)["balance"] = 100
|
|
||||||
await asyncio.wait_for(
|
|
||||||
asyncio.gather(
|
|
||||||
economy.do_give(UID, OTHER, 10),
|
|
||||||
economy.do_give(OTHER, UID, 25),
|
|
||||||
),
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
run(both())
|
|
||||||
total = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"]
|
|
||||||
assert total == 200
|
|
||||||
|
|
||||||
|
|
||||||
class TestRob:
|
|
||||||
def test_anticheat_blocks_and_depletes(self, fake_pb):
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
run(economy.get_user(OTHER))
|
|
||||||
economy.set_house(HOUSE)
|
|
||||||
fake_pb.record_for(UID)["balance"] = 1000
|
|
||||||
target = fake_pb.record_for(OTHER)
|
|
||||||
target["balance"] = 1000
|
|
||||||
target["items"] = ["anticheat"]
|
|
||||||
target["item_uses"] = {"anticheat": 1}
|
|
||||||
res = run(economy.do_rob(UID, OTHER))
|
|
||||||
assert res["ok"] and not res["success"] and res["reason"] == "valvur"
|
|
||||||
assert fake_pb.record_for(UID)["balance"] == 1000 - res["fine"]
|
|
||||||
assert "anticheat" not in fake_pb.record_for(OTHER)["items"]
|
|
||||||
# the fine flows to the house
|
|
||||||
assert fake_pb.record_for(HOUSE)["balance"] == res["fine"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestGambling:
|
|
||||||
def test_roulette_conserves_money_with_house(self, fake_pb):
|
|
||||||
economy.set_house(HOUSE)
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
fake_pb.record_for(UID)["balance"] = 1000
|
|
||||||
random.seed(3)
|
|
||||||
res = run(economy.do_roulette(UID, 100, "punane"))
|
|
||||||
assert res["ok"]
|
|
||||||
user_bal = fake_pb.record_for(UID)["balance"]
|
|
||||||
if res["won"]:
|
|
||||||
assert user_bal == 1000 + res["change"]
|
|
||||||
else:
|
|
||||||
assert user_bal == 900
|
|
||||||
assert fake_pb.record_for(HOUSE)["balance"] == 100
|
|
||||||
|
|
||||||
def test_bet_larger_than_balance_rejected(self, fake_pb):
|
|
||||||
res = run(economy.do_slots(UID, 50))
|
|
||||||
assert not res["ok"] and res["reason"] == "insufficient"
|
|
||||||
|
|
||||||
def test_blackjack_bet_and_payout(self, fake_pb):
|
|
||||||
economy.set_house(HOUSE)
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
fake_pb.record_for(UID)["balance"] = 500
|
|
||||||
assert run(economy.do_blackjack_bet(UID, 100))["ok"]
|
|
||||||
assert fake_pb.record_for(UID)["balance"] == 400
|
|
||||||
# player loses: payout 0 of 100 invested -> house gains the bet
|
|
||||||
run(economy.do_blackjack_payout(UID, 0, total_invested=100))
|
|
||||||
assert fake_pb.record_for(UID)["balance"] == 400
|
|
||||||
assert fake_pb.record_for(HOUSE)["balance"] == 100
|
|
||||||
|
|
||||||
|
|
||||||
class TestConcurrency:
|
|
||||||
"""The per-user locks must serialize read-modify-write cycles."""
|
|
||||||
|
|
||||||
def test_concurrent_exp_awards_are_not_lost(self, fake_pb):
|
|
||||||
async def hammer():
|
|
||||||
await asyncio.gather(*(economy.award_exp(UID, 10) for _ in range(25)))
|
|
||||||
run(hammer())
|
|
||||||
assert fake_pb.record_for(UID)["exp"] == 250
|
|
||||||
|
|
||||||
def test_concurrent_credit_house_is_atomic(self, fake_pb):
|
|
||||||
economy.set_house(HOUSE)
|
|
||||||
|
|
||||||
async def hammer():
|
|
||||||
await economy.get_user(HOUSE)
|
|
||||||
await asyncio.gather(*(economy._credit_house(7) for _ in range(30)))
|
|
||||||
run(hammer())
|
|
||||||
assert fake_pb.record_for(HOUSE)["balance"] == 210
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Tests for the pure (no-database) economy math."""
|
|
||||||
|
|
||||||
import random
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from core import economy
|
|
||||||
|
|
||||||
|
|
||||||
class TestLevels:
|
|
||||||
def test_milestones(self):
|
|
||||||
assert economy.get_level(0) == 1
|
|
||||||
assert economy.get_level(249) == 4
|
|
||||||
assert economy.get_level(250) == 5
|
|
||||||
assert economy.get_level(1000) == 10
|
|
||||||
assert economy.get_level(4000) == 20
|
|
||||||
assert economy.get_level(9000) == 30
|
|
||||||
|
|
||||||
def test_exp_for_level_is_inverse(self):
|
|
||||||
for level in range(1, 41):
|
|
||||||
exp = economy.exp_for_level(level)
|
|
||||||
assert economy.get_level(exp) == level
|
|
||||||
if level > 1:
|
|
||||||
assert economy.get_level(exp - 1) == level - 1
|
|
||||||
|
|
||||||
def test_negative_exp_clamps_to_level_1(self):
|
|
||||||
assert economy.get_level(-500) == 1
|
|
||||||
|
|
||||||
def test_role_names(self):
|
|
||||||
assert economy.level_role_name(1) == "TipiNOOB"
|
|
||||||
assert economy.level_role_name(4) == "TipiNOOB"
|
|
||||||
assert economy.level_role_name(5) == "TipiGRINDER"
|
|
||||||
assert economy.level_role_name(10) == "TipiHUSTLER"
|
|
||||||
assert economy.level_role_name(20) == "TipiCHAD"
|
|
||||||
assert economy.level_role_name(30) == "TipiLEGEND"
|
|
||||||
assert economy.level_role_name(99) == "TipiLEGEND"
|
|
||||||
|
|
||||||
|
|
||||||
class TestGambleExp:
|
|
||||||
def test_tiers(self):
|
|
||||||
assert economy.gamble_exp(0) == 0
|
|
||||||
assert economy.gamble_exp(9) == 0
|
|
||||||
assert economy.gamble_exp(10) == 5
|
|
||||||
assert economy.gamble_exp(99) == 5
|
|
||||||
assert economy.gamble_exp(100) == 10
|
|
||||||
assert economy.gamble_exp(999) == 10
|
|
||||||
assert economy.gamble_exp(1_000) == 15
|
|
||||||
assert economy.gamble_exp(9_999) == 15
|
|
||||||
assert economy.gamble_exp(10_000) == 20
|
|
||||||
assert economy.gamble_exp(99_999) == 20
|
|
||||||
assert economy.gamble_exp(100_000) == 25
|
|
||||||
|
|
||||||
def test_cap(self):
|
|
||||||
assert economy.gamble_exp(10_000_000) == 25
|
|
||||||
|
|
||||||
|
|
||||||
class TestFormatTd:
|
|
||||||
def test_hours(self):
|
|
||||||
assert economy.format_td(timedelta(hours=1, minutes=23, seconds=45)) == "1t 23m"
|
|
||||||
|
|
||||||
def test_minutes(self):
|
|
||||||
assert economy.format_td(timedelta(minutes=45, seconds=12)) == "45m 12s"
|
|
||||||
|
|
||||||
def test_seconds(self):
|
|
||||||
assert economy.format_td(timedelta(seconds=8)) == "8s"
|
|
||||||
|
|
||||||
|
|
||||||
class TestRollFish:
|
|
||||||
def test_rolls_are_valid(self):
|
|
||||||
random.seed(42)
|
|
||||||
for _ in range(500):
|
|
||||||
fish_id, weight = economy.roll_fish()
|
|
||||||
if fish_id == "junk":
|
|
||||||
assert weight == 0
|
|
||||||
else:
|
|
||||||
fish = economy.FISH_CATALOGUE[fish_id]
|
|
||||||
assert fish["weight"][0] <= weight <= fish["weight"][1]
|
|
||||||
|
|
||||||
def test_rarity_bump_shifts_every_catch_up_a_tier(self):
|
|
||||||
random.seed(7)
|
|
||||||
rarities = {
|
|
||||||
economy.FISH_CATALOGUE[fid]["rarity"]
|
|
||||||
for fid, _ in (economy.roll_fish(rarity_bump=True) for _ in range(1000))
|
|
||||||
if fid != "junk"
|
|
||||||
}
|
|
||||||
assert "common" not in rarities
|
|
||||||
assert "legendary" in rarities
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"""Tests for the quest system: rotation, progress, claiming, schema detection."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from core import economy
|
|
||||||
|
|
||||||
from conftest import run
|
|
||||||
|
|
||||||
UID = 111
|
|
||||||
|
|
||||||
|
|
||||||
def _fixed_now(monkeypatch, dt: datetime):
|
|
||||||
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
def _complete_all_active_quests(fake_pb, user_id: int) -> tuple[int, int]:
|
|
||||||
"""Push every active quest's tracked counter past its goal directly in the
|
|
||||||
store. Returns (expected_coins, expected_exp)."""
|
|
||||||
rec = fake_pb.record_for(user_id)
|
|
||||||
coins = exp = 0
|
|
||||||
for pool, block in (
|
|
||||||
(economy.QUESTS_DAILY, rec["quest_daily"]),
|
|
||||||
(economy.QUESTS_WEEKLY, rec["quest_weekly"]),
|
|
||||||
):
|
|
||||||
for qid, state in block["quests"].items():
|
|
||||||
stat = pool[qid]["stat"]
|
|
||||||
rec[stat] = state["snap"] + pool[qid]["goal"]
|
|
||||||
coins += pool[qid]["coins"]
|
|
||||||
exp += pool[qid]["exp"]
|
|
||||||
return coins, exp
|
|
||||||
|
|
||||||
|
|
||||||
class TestRotation:
|
|
||||||
def test_deterministic_per_seed(self):
|
|
||||||
a = economy._pick_quests(economy.QUESTS_DAILY, 3, "1:date:2026-07-26")
|
|
||||||
b = economy._pick_quests(economy.QUESTS_DAILY, 3, "1:date:2026-07-26")
|
|
||||||
assert a == b
|
|
||||||
|
|
||||||
def test_users_get_different_sets(self):
|
|
||||||
sets = {
|
|
||||||
tuple(economy._pick_quests(economy.QUESTS_DAILY, 3, f"{uid}:date:2026-07-26"))
|
|
||||||
for uid in range(50)
|
|
||||||
}
|
|
||||||
assert len(sets) > 1
|
|
||||||
|
|
||||||
def test_counts(self, fake_pb):
|
|
||||||
data = run(economy.get_quests(UID))
|
|
||||||
assert len(data["daily"]) == economy.DAILY_QUEST_COUNT
|
|
||||||
assert len(data["weekly"]) == economy.WEEKLY_QUEST_COUNT
|
|
||||||
|
|
||||||
def test_daily_rolls_over_weekly_stays(self, fake_pb, monkeypatch):
|
|
||||||
# a Tuesday, so day+1 stays inside the same ISO week
|
|
||||||
t0 = _fixed_now(monkeypatch, datetime(2026, 7, 21, 12, tzinfo=timezone.utc))
|
|
||||||
run(economy.get_quests(UID))
|
|
||||||
rec = fake_pb.record_for(UID)
|
|
||||||
daily_before, weekly_before = dict(rec["quest_daily"]), dict(rec["quest_weekly"])
|
|
||||||
_fixed_now(monkeypatch, t0 + timedelta(days=1))
|
|
||||||
run(economy.get_quests(UID))
|
|
||||||
rec = fake_pb.record_for(UID)
|
|
||||||
assert rec["quest_daily"]["date"] != daily_before["date"]
|
|
||||||
assert rec["quest_weekly"] == weekly_before
|
|
||||||
|
|
||||||
|
|
||||||
class TestProgress:
|
|
||||||
def test_progress_tracks_counter_delta(self, fake_pb):
|
|
||||||
run(economy.get_quests(UID))
|
|
||||||
rec = fake_pb.record_for(UID)
|
|
||||||
qid, state = next(iter(rec["quest_daily"]["quests"].items()))
|
|
||||||
stat = economy.QUESTS_DAILY[qid]["stat"]
|
|
||||||
rec[stat] = state["snap"] + 1
|
|
||||||
data = run(economy.get_quests(UID))
|
|
||||||
quest = next(q for q in data["daily"] if q["id"] == qid)
|
|
||||||
assert quest["progress"] == 1
|
|
||||||
|
|
||||||
def test_pre_roll_stats_do_not_count(self, fake_pb):
|
|
||||||
run(economy.get_user(UID))
|
|
||||||
fake_pb.record_for(UID)["work_count"] = 500
|
|
||||||
data = run(economy.get_quests(UID))
|
|
||||||
assert all(q["progress"] == 0 for q in data["daily"] + data["weekly"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestClaim:
|
|
||||||
def test_claim_pays_and_is_idempotent(self, fake_pb):
|
|
||||||
run(economy.get_quests(UID))
|
|
||||||
coins, exp = _complete_all_active_quests(fake_pb, UID)
|
|
||||||
res = run(economy.claim_quests(UID))
|
|
||||||
assert res["ok"]
|
|
||||||
assert res["claimed"] == economy.DAILY_QUEST_COUNT + economy.WEEKLY_QUEST_COUNT
|
|
||||||
assert res["coins"] == coins and res["exp"] == exp
|
|
||||||
assert fake_pb.record_for(UID)["balance"] == coins
|
|
||||||
res = run(economy.claim_quests(UID))
|
|
||||||
assert not res["ok"] and res["reason"] == "nothing"
|
|
||||||
|
|
||||||
def test_claim_with_nothing_done(self, fake_pb):
|
|
||||||
run(economy.get_quests(UID))
|
|
||||||
res = run(economy.claim_quests(UID))
|
|
||||||
assert not res["ok"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaDetection:
|
|
||||||
def test_missing_fields_reported(self, fake_pb_without_quest_fields):
|
|
||||||
assert run(economy.missing_schema_fields()) == ["quest_daily", "quest_weekly"]
|
|
||||||
|
|
||||||
def test_healthy_schema_reports_nothing(self, fake_pb):
|
|
||||||
assert run(economy.missing_schema_fields()) == []
|
|
||||||
|
|
||||||
def test_missing_quest_fields_warn_and_zero_progress(
|
|
||||||
self, fake_pb_without_quest_fields, caplog
|
|
||||||
):
|
|
||||||
"""Reproduces the live 'quests never progress' symptom: when the
|
|
||||||
collection schema lacks quest_daily/quest_weekly, PocketBase drops the
|
|
||||||
rolled quest block, so every call re-rolls with a fresh snapshot."""
|
|
||||||
fake = fake_pb_without_quest_fields
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
run(economy.get_quests(UID))
|
|
||||||
assert any("quest fields" in r.message for r in caplog.records)
|
|
||||||
# counters advance, but progress stays 0 because the snapshot re-rolls
|
|
||||||
fake.record_for(UID)["work_count"] = 500
|
|
||||||
data = run(economy.get_quests(UID))
|
|
||||||
assert all(q["progress"] == 0 for q in data["daily"] + data["weekly"])
|
|
||||||
Reference in New Issue
Block a user