Compare commits
15 Commits
rewrite-v2
...
691f160a09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
691f160a09 | ||
|
|
3c2b4342a2 | ||
|
|
93f4d471dc | ||
|
|
de7cfce833 | ||
|
|
a4a447867f | ||
|
|
9ae26049c5 | ||
|
|
b998418c14 | ||
|
|
94df54dde2 | ||
|
|
77a3badd41 | ||
|
|
17102ae202 | ||
|
|
cd41bc2a48 | ||
| 802a6a2e8d | |||
|
|
64d9b304a9 | ||
|
|
07360d3f11 | ||
| 8f28832432 |
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
|
||||
16
.env.example
16
.env.example
@@ -1,15 +1,16 @@
|
||||
# Bot runtime profile: dev (economy + member tools) or economy (economy-only)
|
||||
BOT_PROFILE=dev
|
||||
|
||||
# Profile-specific Discord bot tokens (from https://discord.com/developers/applications)
|
||||
DISCORD_TOKEN_DEV=your-dev-bot-token-here
|
||||
DISCORD_TOKEN_ECONOMY=your-economy-bot-token-here
|
||||
DISCORD_BOT_LAN=your-lan-bot-token-here
|
||||
|
||||
# Legacy fallback token (optional, backward compatibility)
|
||||
DISCORD_TOKEN=
|
||||
|
||||
# Google Sheets spreadsheet ID (the long string in the sheet URL)
|
||||
SHEET_ID=your-google-sheet-id-here
|
||||
SHEET_ID_DEV=
|
||||
SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw
|
||||
|
||||
# Path to Google service account credentials JSON
|
||||
GOOGLE_CREDS_PATH=credentials.json
|
||||
@@ -17,6 +18,7 @@ GOOGLE_CREDS_PATH=credentials.json
|
||||
# Profile-specific guild (server) IDs - right-click your server with dev mode on
|
||||
GUILD_ID_DEV=your-dev-guild-id-here
|
||||
GUILD_ID_ECONOMY=your-economy-guild-id-here
|
||||
GUILD_ID_LAN=1301145356750426192
|
||||
|
||||
# Legacy fallback guild ID (optional, backward compatibility)
|
||||
GUILD_ID=
|
||||
@@ -41,6 +43,16 @@ PB_ADMIN_PASSWORD=your-pb-admin-password
|
||||
# Profile-specific PocketBase collections
|
||||
PB_ECONOMY_COLLECTION_DEV=economy_users_dev
|
||||
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod
|
||||
PB_ECONOMY_COLLECTION_LAN=economy_users_lan
|
||||
PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan
|
||||
|
||||
# Legacy fallback collection name (optional, backward compatibility)
|
||||
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
|
||||
|
||||
17
.gitea/workflows/deploy.yml
Normal file
17
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
cd ~/tipibot
|
||||
git pull
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
systemctl restart tipibot
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,10 +4,9 @@ __pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
data/restart_channel.json
|
||||
data/economy.json
|
||||
data/
|
||||
pocketbase.exe
|
||||
pocketbase
|
||||
pb_data/
|
||||
pb_migrations/
|
||||
logs/
|
||||
logs/
|
||||
257
DEV_NOTES.md
257
DEV_NOTES.md
@@ -1,257 +0,0 @@
|
||||
# TipiLAN Bot - Developer Reference
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `bot.py` | All Discord commands, views (UI components), event handlers, reminder system |
|
||||
| `economy.py` | All economy logic, data model, constants (SHOP, COOLDOWNS, LEVEL_ROLES, etc.) |
|
||||
| `pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
|
||||
| `strings.py` | **Single source of truth for all user-facing text.** Edit here to change any message. |
|
||||
| `sheets.py` | Google Sheets integration (member sync) |
|
||||
| `member_sync.py` | Birthday/member sync background task |
|
||||
| `config.py` | Environment variables (TOKEN, GUILD_ID, PB_URL, etc.) |
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Economy Command
|
||||
|
||||
Checklist - do all of these, in order:
|
||||
|
||||
1. **`economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging
|
||||
2. **`economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one
|
||||
3. **`economy.py`** - add the EXP reward to `EXP_REWARDS` dict
|
||||
4. **PocketBase** - if the function stores new fields, add them manually in the PB admin UI at `http://127.0.0.1:8090/_/`. Fields not in the PB schema are silently dropped on PATCH.
|
||||
5. **`strings.py` `CMD`** - add the slash command description
|
||||
6. **`strings.py` `OPT`** - add any parameter descriptions
|
||||
7. **`strings.py` `TITLE`** - add embed title(s) for success/fail states
|
||||
8. **`strings.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`)
|
||||
9. **`strings.py` `CD_MSG`** - add cooldown message if command has a cooldown
|
||||
10. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed
|
||||
11. **`bot.py`** - implement the `cmd_<name>` function, handle all `res["reason"]` cases
|
||||
12. **`bot.py`** - call `_maybe_remind` if the command has a cooldown and reminders make sense
|
||||
13. **`bot.py`** - call `_award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` on success
|
||||
14. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one
|
||||
15. **`bot.py` `_maybe_remind`** and **`_restore_reminders`** - if item-modified cooldown, add `elif` branch
|
||||
16. **`bot.py` `_REMINDER_COOLDOWN_KEYS`** - add `"cmd": "last_cmd"` mapping if reminder-capable
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Shop Item
|
||||
|
||||
Checklist:
|
||||
|
||||
1. **`economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
|
||||
2. **`economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3)
|
||||
3. **`economy.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≥10) or T3 (≥20)
|
||||
4. **`strings.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect)
|
||||
5. **`strings.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost)
|
||||
6. If the item modifies a cooldown:
|
||||
- **`economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_<cmd>` function
|
||||
- **`bot.py` `_maybe_remind`** - add `elif cmd == "<cmd>" and "<item>" in items:` branch with the new delay
|
||||
- **`bot.py` `_restore_reminders`** - add the same `elif` branch
|
||||
- **`bot.py` `cmd_cooldowns`** - add the item annotation to the relevant status line
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Level Role
|
||||
|
||||
1. **`economy.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first)
|
||||
2. **`bot.py` `_ensure_level_role`** - no changes needed (uses `LEVEL_ROLES` dynamically)
|
||||
3. Run **`/economysetup`** in the server to create the role and set its position
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Admin Command
|
||||
|
||||
1. **`strings.py` `CMD`** - add `"[Admin] ..."` description
|
||||
2. **`strings.py` `OPT`** - add parameter descriptions
|
||||
3. **`strings.py` `ADMIN`** - add response and DM strings
|
||||
4. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry
|
||||
5. **`economy.py`** - add `do_admin_<name>` function
|
||||
6. **`bot.py`** - add command with `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()`
|
||||
|
||||
---
|
||||
|
||||
## Economy System Design
|
||||
|
||||
### Storage
|
||||
|
||||
All economy state is stored in **PocketBase** (`economy_users` collection). `pb_client.py` owns all reads/writes. Each `do_*` function in `economy.py` calls `get_user()` → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase.
|
||||
|
||||
### Currency & Income Sources
|
||||
|
||||
| Command | Cooldown | Base Earn | Notes |
|
||||
|---|---|---|---|
|
||||
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop; prestige daily_plus adds +20% per level |
|
||||
| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook; prestige work_plus +20%/level |
|
||||
| `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur |
|
||||
| `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail |
|
||||
| `/rob` | 2h | 10–25% of target | 45% success (60% w/ jellyfin); fail = fine; cannot rob TipiBOT directly |
|
||||
| `/heist` | 4h personal + 1h global | 20–55% of house / n players | solo allowed, max 8; 35%+5%/player success (cap 65%); fail = 3h jail + ~15% balance fine |
|
||||
| `/fish` | 2min (90s w/ ussipurk) | varies by fish rarity | Interactive minigame; catches go to inventory; sell with `/fishsell` |
|
||||
| `/slots` | - | varies | pair=+0.5× bet; triple tiered; karikas jackpot ×25; ×1.5 w/ monitor_360; miss=lose bet |
|
||||
| `/roulette` | - | 2× red/black, 14× green | 1/37 green chance |
|
||||
| `/blackjack` | - | 1:1 win, 3:2 BJ, 2:1 double | Dealer stands on 17+; double down on first action only |
|
||||
|
||||
### Fishing System
|
||||
|
||||
- `/fish` - interactive minigame: cast → wait 5–15s for bite → press button within 2s (3s w/ echolood) → keep or sell
|
||||
- Fish stored in `fish_inventory` (list of `{fish_id, weight, value}` objects)
|
||||
- `/fishbook` - paginated fish collection showing caught species and inventory counts
|
||||
- `/fishsell` - sell all fish from inventory at once
|
||||
- `fish_inventory` and `fish_book` **survive prestige resets**
|
||||
- `kalavork` (T3, 5000⬡): bumps all caught fish up one rarity tier
|
||||
- `ussipurk` (T2, 3500⬡): cooldown 2min → 90s
|
||||
- `echolood` (T3, 8000⬡): bite window 2s → 3s
|
||||
|
||||
### Prestige System
|
||||
|
||||
- Requires level 30 (9000 EXP)
|
||||
- Resets: balance, EXP, items, cooldowns, jail
|
||||
- Preserves: fish_book, fish_inventory, lifetime stats, prestige_points, season_total_exp, prestige_upgrades
|
||||
- Awards prestige_points = max(1, exp ÷ 1000) at time of prestige
|
||||
- Each prestige increments `prestige_level` counter
|
||||
- Prestige coin/exp multipliers apply to all earned values
|
||||
|
||||
**Prestige Shop** (`PRESTIGE_SHOP` in economy.py):
|
||||
|
||||
| Upgrade | Max Level | Cost/level | Effect |
|
||||
|---|---|---|---|
|
||||
| `coin_mult` | 5 | 5 PP | +8% coin multiplier per level |
|
||||
| `exp_mult` | 5 | 5 PP | +8% EXP multiplier per level |
|
||||
| `daily_plus` | 3 | 7 PP | +20% daily base reward per level |
|
||||
| `work_plus` | 3 | 7 PP | +20% work earnings per level |
|
||||
|
||||
### "all" Keyword
|
||||
Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` to mean the user's full current balance. Parsed by `_parse_amount(value, balance)` in `bot.py`.
|
||||
|
||||
### Daily Streak Multipliers
|
||||
- 1-2 days: ×1.0 (150⬡)
|
||||
- 3-6 days: ×1.5 (225⬡)
|
||||
- 7-13 days: ×2.0 (300⬡)
|
||||
- 14+ days: ×3.0 (450⬡)
|
||||
- `karikas` item: streak survives missed days
|
||||
|
||||
### Jail
|
||||
- Normal duration: 30 minutes (`JAIL_DURATION`)
|
||||
- Heist fail duration: 3 hours (`HEIST_JAIL`) + fine 15% of balance (min 150⬡, max 1000⬡)
|
||||
- `gaming_tool`: prevents jail on crime fail
|
||||
- `/jailbreak`: 3 single-button dice rolls (both dice at once), need doubles to escape free. Animated reveal with TipiDICE emoji. On fail after 3 tries - bail = 20-30% of balance, min 350⬡. If balance < 350⬡, stay jailed until timer.
|
||||
|
||||
### EXP Rewards (from `EXP_REWARDS` in economy.py)
|
||||
EXP is awarded on every successful command use. Level formula: `floor(sqrt(exp / 10))`, so Level 5 = 250 EXP, Level 10 = 1000, Level 20 = 4000, Level 30 = 9000.
|
||||
|
||||
Fish EXP is awarded per catch (varies by rarity, defined in `FISH_CATALOGUE`). Prestige `exp_mult` upgrade applies to fish EXP.
|
||||
|
||||
---
|
||||
|
||||
## Admin Commands Reference
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `/pause` | Toggle maintenance mode - blocks all non-admin commands |
|
||||
| `/admincoins @user <amount> <reason>` | Give/take coins (positive/negative). DMs user. |
|
||||
| `/adminexp @user <amount> <reason>` | Give/take EXP (positive/negative). Auto-applies level roles on change. DMs user. |
|
||||
| `/adminitem @user <item_id> <anna\|eemalda>` | Give or remove any shop item for free. DMs user. |
|
||||
| `/adminjail @user <minutes> <reason>` | Manually jail a user. DMs user. |
|
||||
| `/adminunjail @user` | Remove jail from a user. |
|
||||
| `/adminban @user <reason>` | Ban from all economy commands. DMs user. |
|
||||
| `/adminunban @user` | Lift economy ban. |
|
||||
| `/adminreset @user <reason>` | Wipe balance, EXP, items, streak. DMs user. |
|
||||
| `/adminview @user` | Full profile: balance, EXP/level, streak, prestige, fish stats, items, timestamps. |
|
||||
| `/adminseason <top_n>` | End season: DM top N players, reset all EXP. |
|
||||
|
||||
All admin commands require **Manage Guild** permission and work in any channel (bypass pause and channel restrictions).
|
||||
|
||||
---
|
||||
|
||||
## Role Hierarchy (Discord)
|
||||
|
||||
Order top to bottom in server roles:
|
||||
|
||||
```
|
||||
[Bot managed role] ← bot's own role, always at top of our stack
|
||||
ECONOMY ← given to everyone who uses any economy command
|
||||
TipiLEGEND ← level 30+
|
||||
TipiCHAD ← level 20+
|
||||
TipiHUSTLER ← level 10+
|
||||
TipiGRINDER ← level 5+
|
||||
TipiNOOB ← level 1+
|
||||
```
|
||||
|
||||
Run `/economysetup` to auto-create all roles and set their positions. The command is idempotent - safe to run multiple times.
|
||||
|
||||
Role assignment:
|
||||
- **ECONOMY** role: given automatically on first EXP award (i.e. first successful economy command)
|
||||
- **Level roles**: given/swapped automatically on level-up; synced on `/rank`
|
||||
- **`/adminexp`**: automatically re-applies level roles if level changes
|
||||
|
||||
---
|
||||
|
||||
## Shop Tiers & Level Requirements
|
||||
|
||||
| Tier | Level Required | Items |
|
||||
|---|---|---|
|
||||
| T1 | 0 (any) | gaming_hiir, hiirematt, korvaklapid, lan_pass, anticheat, energiajook, gaming_laptop |
|
||||
| T2 | 10 | reguleeritav_laud, jellyfin, mikrofon, klaviatuur, monitor, cat6, **ussipurk** |
|
||||
| T3 | 20 | monitor_360, karikas, gaming_tool, **kalavork**, **echolood** |
|
||||
|
||||
Shop display is sorted by cost (ascending) within each tier.
|
||||
The `SHOP_LEVEL_REQ` dict in `economy.py` controls per-item lock thresholds.
|
||||
|
||||
---
|
||||
|
||||
## strings.py Organisation
|
||||
|
||||
| Section | Dict | Usage in bot.py |
|
||||
|---|---|---|
|
||||
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions |
|
||||
| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` |
|
||||
| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` |
|
||||
| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` |
|
||||
| Banned message | `MSG_BANNED` | All banned checks |
|
||||
| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown |
|
||||
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` |
|
||||
| Embed titles | `TITLE["key"]` | `discord.Embed(title=S.TITLE["key"])` |
|
||||
| Error messages | `ERR["key"]` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts |
|
||||
| Cooldown messages | `CD_MSG["cmd"].format(ts=_cd_ts(...))` | Cooldown responses |
|
||||
| Shop UI | `SHOP_UI["key"]` | `_shop_embed` |
|
||||
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.SHOP[key]["description"]` |
|
||||
| Fish UI | `FISH_UI["key"]` | `/fish`, `/fishbook`, `/fishsell` |
|
||||
| Fish names | `FISH_NAMES["fish_id"]` | Fish display name |
|
||||
| Admin responses | `ADMIN["key"]` | Admin command success/DM messages |
|
||||
|
||||
---
|
||||
|
||||
## Constants Location Quick-Reference
|
||||
|
||||
| Constant | File | Description |
|
||||
|---|---|---|
|
||||
| `SHOP` | `economy.py` | All shop items (name, emoji, cost, description) |
|
||||
| `SHOP_TIERS` | `economy.py` | Which items are in T1/T2/T3 |
|
||||
| `SHOP_LEVEL_REQ` | `economy.py` | Min level per item |
|
||||
| `COOLDOWNS` | `economy.py` | Base cooldown per command |
|
||||
| `JAIL_DURATION` | `economy.py` | How long jail lasts |
|
||||
| `LEVEL_ROLES` | `economy.py` | `[(min_level, "RoleName"), ...]` highest first |
|
||||
| `ECONOMY_ROLE` | `economy.py` | Name of the base economy participation role |
|
||||
| `EXP_REWARDS` | `economy.py` | EXP per command |
|
||||
| `FISH_CATALOGUE` | `economy.py` | All fish species (rarity, weight, coins, exp) |
|
||||
| `PRESTIGE_SHOP` | `economy.py` | Prestige upgrade definitions |
|
||||
| `HOUSE_ID` | `economy.py` | Bot's user ID (house account for /rob) |
|
||||
| `MIN_BAIL` | `economy.py` | Minimum bail payment (350⬡) |
|
||||
| `COIN` | `economy.py` | The coin emoji string |
|
||||
|
||||
---
|
||||
|
||||
## Balance Notes (as of current version)
|
||||
|
||||
- **Beg** is most efficient for active players (3min cooldown + 2× multiplier w/ `klaviatuur` = high ⬡/hr)
|
||||
- **Work** is best for passive players (1h cooldown, fire and forget)
|
||||
- **Crime** is high risk/reward - best with `cat6` + `mikrofon`
|
||||
- **`lan_pass`** (1200⬡) doubles daily - good long-term investment
|
||||
- **`gaming_laptop`** (1500⬡) 5% interest, capped 500⬡/day - snowballs with large balance
|
||||
- `anticheat` is consumable (2 uses) - only item that can be re-bought
|
||||
- `karikas` (T3) is the only item that preserves a daily streak across missed days
|
||||
- `reguleeritav_laud` (T2) stacks with `gaming_hiir`: combined ×1.5 × ×1.25 = ×1.875
|
||||
- **Fishing** is a steady passive income; `kalavork` (T3) dramatically increases fish value by bumping rarity
|
||||
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"]
|
||||
11
README.md
11
README.md
@@ -88,14 +88,18 @@ cp .env.example .env
|
||||
|
||||
| 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_ECONOMY` | Economy bot token from Discord Developer Portal |
|
||||
| `DISCORD_BOT_LAN` | LAN bot token from Discord Developer Portal |
|
||||
| `DISCORD_TOKEN` | Legacy fallback token (optional) |
|
||||
| `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`) |
|
||||
| `GUILD_ID_DEV` | Dev bot guild ID |
|
||||
| `GUILD_ID_ECONOMY` | Economy bot guild ID |
|
||||
| `GUILD_ID_LAN` | LAN bot guild ID |
|
||||
| `GUILD_ID` | Legacy fallback guild ID (optional) |
|
||||
| `BIRTHDAY_CHANNEL_ID_DEV` | Channel for birthday `@here` pings in dev profile |
|
||||
| `BIRTHDAY_CHANNEL_ID_ECONOMY` | Optional birthday channel in economy profile |
|
||||
@@ -106,7 +110,12 @@ cp .env.example .env
|
||||
| `PB_ADMIN_PASSWORD` | PocketBase superuser password |
|
||||
| `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` |
|
||||
| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
|
||||
| `PB_ECONOMY_COLLECTION_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) |
|
||||
| `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
|
||||
|
||||
|
||||
121
bot.py
121
bot.py
@@ -18,25 +18,25 @@ from discord.ext import tasks
|
||||
|
||||
import colorlog
|
||||
import psutil
|
||||
from aiohttp import web
|
||||
|
||||
import config
|
||||
import strings as S
|
||||
import economy
|
||||
import pb_client
|
||||
import sheets
|
||||
from dev_member_commands import register_dev_member_commands
|
||||
from dev_member_runtime import handle_member_join, run_birthday_daily
|
||||
from economy_admin_commands import register_economy_admin_commands
|
||||
from economy_extra_commands import register_economy_extra_commands
|
||||
from economy_fish_commands import register_economy_fish_commands
|
||||
from economy_games_commands import register_economy_games_commands
|
||||
from economy_income_commands import register_economy_income_commands
|
||||
from economy_prestige_commands import register_prestige_commands
|
||||
from economy_profile_commands import register_economy_profile_commands
|
||||
from economy_support_commands import register_economy_support_commands
|
||||
from ops_channel_commands import register_ops_channel_commands
|
||||
from ops_admin_commands import register_ops_admin_commands
|
||||
from member_sync import SyncResult
|
||||
from core import economy, lan_fienta, pb_client, sheets
|
||||
from core.member_sync import SyncResult
|
||||
from commands.dev_member_commands import register_dev_member_commands
|
||||
from commands.dev_member_runtime import handle_member_join, run_birthday_daily
|
||||
from commands.economy_admin_commands import register_economy_admin_commands
|
||||
from commands.economy_extra_commands import register_economy_extra_commands
|
||||
from commands.economy_fish_commands import register_economy_fish_commands
|
||||
from commands.economy_games_commands import register_economy_games_commands
|
||||
from commands.economy_income_commands import register_economy_income_commands
|
||||
from commands.economy_prestige_commands import register_prestige_commands
|
||||
from commands.economy_profile_commands import register_economy_profile_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_admin_commands import register_ops_admin_commands
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -96,6 +96,7 @@ tree = app_commands.CommandTree(bot)
|
||||
|
||||
GUILD_OBJ = discord.Object(id=config.GUILD_ID)
|
||||
IS_DEV_PROFILE = config.BOT_PROFILE == "dev"
|
||||
IS_LAN_PROFILE = config.BOT_PROFILE == "lan"
|
||||
TALLINN_TZ = ZoneInfo("Europe/Tallinn")
|
||||
_start_time = datetime.datetime.now()
|
||||
_process = psutil.Process()
|
||||
@@ -114,6 +115,8 @@ _RESTART_FILE = _DATA_DIR / "restart_channel.json"
|
||||
_BOT_CONFIG = _DATA_DIR / "bot_config.json"
|
||||
_PAUSED = False # maintenance mode: blocks non-admin commands when True
|
||||
_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:
|
||||
@@ -163,6 +166,64 @@ def _member_cache_size() -> int:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -391,6 +452,13 @@ async def on_ready():
|
||||
log.info("Loaded %d member rows from Google Sheets", len(data))
|
||||
except Exception as e:
|
||||
log.error("Failed to load sheet on startup: %s", e)
|
||||
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
|
||||
tree.copy_global_to(guild=GUILD_OBJ)
|
||||
@@ -409,6 +477,10 @@ async def on_ready():
|
||||
_rotate_presence.start()
|
||||
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
|
||||
await _restore_reminders()
|
||||
|
||||
@@ -438,6 +510,11 @@ async def on_resumed():
|
||||
@bot.event
|
||||
async def on_member_join(member: discord.Member):
|
||||
"""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:
|
||||
return
|
||||
await handle_member_join(
|
||||
@@ -462,6 +539,9 @@ if IS_DEV_PROFILE:
|
||||
mark_announced_today=_mark_announced_today,
|
||||
)
|
||||
|
||||
if IS_LAN_PROFILE:
|
||||
register_lan_fienta_commands(tree, bot, log)
|
||||
|
||||
register_ops_admin_commands(
|
||||
tree,
|
||||
bot,
|
||||
@@ -496,6 +576,7 @@ async def cmd_ping(interaction: discord.Interaction):
|
||||
# ---------------------------------------------------------------------------
|
||||
_HELP_PAGE_SIZE = 10
|
||||
_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]]:
|
||||
@@ -508,6 +589,8 @@ def _visible_help_fields(category_key: str) -> list[tuple[str, str]]:
|
||||
blob = f"{name}\n{value}".lower()
|
||||
if any(tok in blob for tok in _DEV_ONLY_HELP_TOKENS):
|
||||
continue
|
||||
if not IS_LAN_PROFILE and any(tok in blob for tok in _LAN_ONLY_HELP_TOKENS):
|
||||
continue
|
||||
visible.append((name, value))
|
||||
return visible
|
||||
|
||||
@@ -883,7 +966,11 @@ def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -
|
||||
|
||||
if __name__ == "__main__":
|
||||
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(
|
||||
f"{profile_key} pole seadistatud profiilile '{config.BOT_PROFILE}'. "
|
||||
"Kopeeri .env.example failiks .env ja täida see."
|
||||
|
||||
@@ -7,9 +7,9 @@ from typing import Callable
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import sheets
|
||||
from core import sheets
|
||||
import strings as S
|
||||
from member_sync import announce_birthday, sync_member
|
||||
from core.member_sync import announce_birthday, sync_member
|
||||
|
||||
|
||||
class BirthdayPages(discord.ui.View):
|
||||
@@ -6,8 +6,8 @@ from collections.abc import Callable
|
||||
import discord
|
||||
|
||||
import config
|
||||
import sheets
|
||||
from member_sync import announce_birthday, is_birthday_today, sync_member
|
||||
from core import sheets
|
||||
from core.member_sync import announce_birthday, is_birthday_today, sync_member
|
||||
|
||||
|
||||
async def run_birthday_daily(
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import economy
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from collections.abc import Awaitable, Callable, MutableSet
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import economy
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable, MutableSet
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import economy
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable, MutableSet
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import economy
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import economy
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import economy
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import economy
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import economy
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
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)
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
import economy
|
||||
from core import economy
|
||||
import strings as S
|
||||
|
||||
|
||||
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:
|
||||
47
config.py
47
config.py
@@ -4,8 +4,8 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev"
|
||||
if BOT_PROFILE not in {"dev", "economy"}:
|
||||
raise SystemExit("BOT_PROFILE must be either 'dev' or 'economy'.")
|
||||
if BOT_PROFILE not in {"dev", "economy", "lan"}:
|
||||
raise SystemExit("BOT_PROFILE must be either 'dev', 'economy', or 'lan'.")
|
||||
|
||||
|
||||
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", "")
|
||||
DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "")
|
||||
DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "")
|
||||
DISCORD_TOKEN = (
|
||||
DISCORD_TOKEN_ECONOMY if BOT_PROFILE == "economy" else DISCORD_TOKEN_DEV
|
||||
) or _LEGACY_DISCORD_TOKEN
|
||||
DISCORD_BOT_LAN = os.getenv("DISCORD_BOT_LAN", "")
|
||||
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")
|
||||
|
||||
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
|
||||
GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID)
|
||||
GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID)
|
||||
GUILD_ID = GUILD_ID_ECONOMY if BOT_PROFILE == "economy" else GUILD_ID_DEV
|
||||
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)
|
||||
BIRTHDAY_CHANNEL_ID_DEV = _env_int("BIRTHDAY_CHANNEL_ID_DEV", _LEGACY_BIRTHDAY_CHANNEL_ID)
|
||||
@@ -55,6 +69,21 @@ PB_ECONOMY_COLLECTION_ECONOMY = (
|
||||
os.getenv("PB_ECONOMY_COLLECTION_ECONOMY", "").strip()
|
||||
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod")
|
||||
)
|
||||
PB_ECONOMY_COLLECTION = (
|
||||
PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV
|
||||
PB_ECONOMY_COLLECTION_LAN = (
|
||||
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)
|
||||
|
||||
@@ -12,13 +12,20 @@ import random
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import TypedDict
|
||||
|
||||
import pb_client
|
||||
import aiohttp
|
||||
|
||||
from . import pb_client
|
||||
|
||||
import strings
|
||||
|
||||
_txn_log = logging.getLogger("tipiCOIN.txn")
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""Raised when PocketBase is unreachable or returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
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())
|
||||
@@ -583,11 +590,15 @@ def format_td(td: timedelta) -> str:
|
||||
async def get_user(user_id: int) -> UserData:
|
||||
"""Fetch user data from PocketBase, creating a default record if first seen."""
|
||||
uid = str(user_id)
|
||||
record = await pb_client.get_record(uid)
|
||||
if record is None:
|
||||
default = _default_user()
|
||||
default["user_id"] = uid # type: ignore[typeddict-unknown-key]
|
||||
record = await pb_client.create_record(default)
|
||||
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:
|
||||
@@ -696,7 +707,10 @@ async def _commit(user_id: int, user: UserData) -> None:
|
||||
# /daily
|
||||
# ---------------------------------------------------------------------------
|
||||
async def do_daily(user_id: int) -> dict:
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
@@ -772,7 +786,10 @@ _WORK_JOBS = strings.WORK_JOBS
|
||||
|
||||
|
||||
async def do_work(user_id: int) -> dict:
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
@@ -822,7 +839,10 @@ _BEG_JAIL_LINES = strings.BEG_JAIL_LINES
|
||||
|
||||
|
||||
async def do_beg(user_id: int) -> dict:
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
@@ -857,7 +877,10 @@ async def do_beg(user_id: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
async def do_fish_start(user_id: int) -> dict:
|
||||
"""Check cooldown + jail, set cooldown. Call before starting the fishing minigame."""
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
if jail := _is_jailed(user):
|
||||
@@ -928,7 +951,8 @@ async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict:
|
||||
to_sell = inv
|
||||
remaining = []
|
||||
else:
|
||||
to_sell = [inv[i] for i in sorted(set(indices)) if 0 <= i < len(inv)]
|
||||
valid_indices = [i if i >= 0 else len(inv) + i for i in indices]
|
||||
to_sell = [inv[i] for i in sorted(set(valid_indices)) if 0 <= i < len(inv)]
|
||||
keep_idx = set(range(len(inv))) - set(indices)
|
||||
remaining = [inv[i] for i in sorted(keep_idx)]
|
||||
|
||||
@@ -974,7 +998,10 @@ async def do_fishbook(user_id: int) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
async def do_prestige(user_id: int) -> dict:
|
||||
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
@@ -1022,7 +1049,10 @@ async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict:
|
||||
if upgrade_id not in PRESTIGE_SHOP:
|
||||
return {"ok": False, "reason": "not_found"}
|
||||
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
@@ -1116,7 +1146,10 @@ _CRIME_LOSE = strings.CRIME_LOSE
|
||||
|
||||
|
||||
async def do_crime(user_id: int) -> dict:
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
@@ -1209,10 +1242,16 @@ async def do_bail(user_id: int) -> dict:
|
||||
# /rob
|
||||
# ---------------------------------------------------------------------------
|
||||
async def do_rob(robber_id: int, target_id: int) -> dict:
|
||||
robber = await get_user(robber_id)
|
||||
try:
|
||||
robber = await get_user(robber_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if robber.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
target = await get_user(target_id)
|
||||
try:
|
||||
target = await get_user(target_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
|
||||
if cd := _cooldown_remaining(robber, "rob"):
|
||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||
@@ -1284,7 +1323,10 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
|
||||
# /roulette
|
||||
# ---------------------------------------------------------------------------
|
||||
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
if jail := _is_jailed(user):
|
||||
@@ -1324,7 +1366,10 @@ async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
||||
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
if jail := _is_jailed(user):
|
||||
@@ -1377,7 +1422,10 @@ def _spin() -> str:
|
||||
|
||||
|
||||
async def do_slots(user_id: int, bet: int) -> dict:
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
if jail := _is_jailed(user):
|
||||
@@ -1430,7 +1478,10 @@ async def do_slots(user_id: int, bet: int) -> dict:
|
||||
# /give
|
||||
# ---------------------------------------------------------------------------
|
||||
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
||||
giver = await get_user(giver_id)
|
||||
try:
|
||||
giver = await get_user(giver_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if giver.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
@@ -1440,7 +1491,10 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
||||
if giver["balance"] < amount:
|
||||
return {"ok": False, "reason": "insufficient"}
|
||||
|
||||
receiver = await get_user(receiver_id)
|
||||
try:
|
||||
receiver = await get_user(receiver_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
giver["balance"] -= amount
|
||||
receiver["balance"] += amount
|
||||
giver["total_given"] = giver.get("total_given", 0) + amount
|
||||
@@ -1462,7 +1516,10 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
||||
async def do_buy(user_id: int, item_id: str) -> dict:
|
||||
if item_id not in SHOP:
|
||||
return {"ok": False, "reason": "not_found"}
|
||||
user = await get_user(user_id)
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
if user.get("eco_banned"):
|
||||
return {"ok": False, "reason": "banned"}
|
||||
|
||||
@@ -1627,7 +1684,10 @@ async def do_set_reminders(user_id: int, commands: list[str]) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
||||
"""Deduct the initial blackjack bet. Returns ok/fail."""
|
||||
user = await get_user(user_id)
|
||||
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):
|
||||
@@ -1682,7 +1742,10 @@ async def do_get_jailed() -> list[tuple[int, timedelta]]:
|
||||
|
||||
async def do_heist_check(user_id: int) -> dict:
|
||||
"""Check whether a user is eligible to join a heist."""
|
||||
user = await get_user(user_id)
|
||||
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):
|
||||
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
|
||||
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
|
||||
import discord
|
||||
|
||||
import config
|
||||
import sheets
|
||||
from . import sheets
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
|
||||
@@ -7,7 +7,7 @@ Environment variables (set in .env):
|
||||
PB_URL Base URL of PocketBase (default: http://127.0.0.1:8090)
|
||||
PB_ADMIN_EMAIL PocketBase admin e-mail
|
||||
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
|
||||
@@ -28,7 +28,7 @@ PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
|
||||
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
|
||||
ECONOMY_COLLECTION = config.PB_ECONOMY_COLLECTION
|
||||
|
||||
_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
||||
_TIMEOUT = aiohttp.ClientTimeout(total=4)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent session (created once, reused for the lifetime of the process)
|
||||
@@ -75,16 +75,28 @@ async def _hdrs() -> dict[str, str]:
|
||||
return {"Authorization": await _ensure_auth()}
|
||||
|
||||
|
||||
def _escape_filter_value(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_record(user_id: str) -> dict[str, Any] | None:
|
||||
"""Fetch one economy record by Discord user_id. Returns None if not found."""
|
||||
return await get_first_record(
|
||||
ECONOMY_COLLECTION,
|
||||
f'user_id="{_escape_filter_value(user_id)}"',
|
||||
)
|
||||
|
||||
|
||||
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/{ECONOMY_COLLECTION}/records",
|
||||
params={"filter": f'user_id="{user_id}"', "perPage": 1},
|
||||
f"{PB_URL}/api/collections/{collection}/records",
|
||||
params={"filter": filter_expr, "perPage": 1},
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
@@ -93,11 +105,22 @@ async def get_record(user_id: str) -> dict[str, Any] | None:
|
||||
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]:
|
||||
"""Create a new economy record. Returns the created record (includes PB id)."""
|
||||
return await create_record_in(ECONOMY_COLLECTION, record)
|
||||
|
||||
|
||||
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/{ECONOMY_COLLECTION}/records",
|
||||
f"{PB_URL}/api/collections/{collection}/records",
|
||||
json=record,
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
@@ -109,9 +132,14 @@ async def create_record(record: 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."""
|
||||
return await update_record_in(ECONOMY_COLLECTION, record_id, data)
|
||||
|
||||
|
||||
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/{ECONOMY_COLLECTION}/records/{record_id}",
|
||||
f"{PB_URL}/api/collections/{collection}/records/{record_id}",
|
||||
json=data,
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
@@ -121,9 +149,14 @@ async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
async def count_records() -> int:
|
||||
"""Return the total number of records in the collection (single cheap request)."""
|
||||
return await count_records_in(ECONOMY_COLLECTION)
|
||||
|
||||
|
||||
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/{ECONOMY_COLLECTION}/records",
|
||||
f"{PB_URL}/api/collections/{collection}/records",
|
||||
params={"perPage": 1, "page": 1},
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
@@ -134,13 +167,18 @@ async def count_records() -> int:
|
||||
|
||||
async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]:
|
||||
"""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]] = []
|
||||
page = 1
|
||||
session = _get_session()
|
||||
hdrs = await _hdrs()
|
||||
while True:
|
||||
async with session.get(
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||
f"{PB_URL}/api/collections/{collection}/records",
|
||||
params={"perPage": page_size, "page": page},
|
||||
headers=hdrs,
|
||||
) as resp:
|
||||
@@ -152,3 +190,51 @@ async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]:
|
||||
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,5 +0,0 @@
|
||||
{
|
||||
"2026-03-14": [
|
||||
"650046190972305409"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"allowed_channels": [
|
||||
"1482398641699291357"
|
||||
]
|
||||
}
|
||||
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.
|
||||
11386
logs/bot.log
11386
logs/bot.log
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
||||
2026-04-01 18:44:29 | WORK user=272518654715887618 earned=+54 lucky=False bal=24077
|
||||
2026-04-01 20:02:11 | BEG user=272518654715887618 earned=+23 jailed=False bal=24100
|
||||
2026-04-01 20:02:53 | ROULETTE_WIN user=178852380018868224 bet=15214708 colour=punane result=punane mult=1 bal=30429416
|
||||
2026-04-01 20:03:42 | ROULETTE_WIN user=178852380018868224 bet=30429416 colour=must result=must mult=1 bal=60858832
|
||||
2026-04-01 20:04:16 | ROULETTE_LOSE user=178852380018868224 bet=60858832 colour=punane result=must mult=1 bal=0
|
||||
2026-04-01 20:06:37 | BEG user=401373976431165449 earned=+52 jailed=False bal=8446
|
||||
2026-04-01 20:06:39 | WORK user=401373976431165449 earned=+60 lucky=False bal=8506
|
||||
2026-04-01 20:06:52 | DAILY user=401373976431165449 earned=+750 streak=1 bal=9256
|
||||
2026-04-01 20:07:07 | DAILY user=272518654715887618 earned=+825 streak=1 bal=24925
|
||||
2026-04-01 20:07:12 | CRIME_WIN user=272518654715887618 earned=+331 bal=25256
|
||||
2026-04-01 20:07:38 | CRIME_WIN user=401373976431165449 earned=+391 bal=9647
|
||||
2026-04-01 20:07:49 | BUY user=401373976431165449 item=echolood cost=-8000 bal=1647
|
||||
2026-04-01 20:09:16 | ROB_BLOCKED robber=824516445382901800 victim=340451525799182357 fine=-118 robber_bal=891 ac_uses_left=1
|
||||
2026-04-01 20:09:23 | ROB_BLOCKED robber=401373976431165449 victim=340451525799182357 fine=-175 robber_bal=1472 ac_uses_left=0
|
||||
2026-04-01 20:09:52 | ROB_WIN robber=178852380018868224 victim=340451525799182357 stolen=+34868 jackpot=False robber_bal=34868 victim_bal=140238
|
||||
2026-04-01 20:10:48 | DAILY user=367347301322326016 earned=+712 streak=1 bal=8462
|
||||
2026-04-01 20:10:55 | WORK user=367347301322326016 earned=+25 lucky=False bal=8487
|
||||
2026-04-01 20:11:00 | BEG user=367347301322326016 earned=+15 jailed=False bal=8502
|
||||
2026-04-01 20:11:37 | ROB_FAIL robber=272518654715887618 victim=340451525799182357 fine=-140 robber_bal=25116
|
||||
2026-04-01 20:15:19 | HEIST_FAIL user=178852380018868224 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=33868
|
||||
2026-04-01 20:15:19 | HEIST_FAIL user=340451525799182357 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=139238
|
||||
2026-04-01 20:15:19 | HEIST_FAIL user=272518654715887618 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=24116
|
||||
2026-04-01 20:15:19 | HEIST_FAIL user=209554152584380420 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=20112
|
||||
2026-04-01 20:15:19 | HEIST_FAIL user=401373976431165449 fine=-220 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=1252
|
||||
2026-04-01 20:15:19 | HEIST_FAIL user=344531774518591498 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=112701
|
||||
2026-04-01 20:15:19 | HEIST_FAIL user=367347301322326016 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=7502
|
||||
2026-04-01 20:15:45 | JAIL_FREE user=272518654715887618 method=doubles
|
||||
2026-04-01 20:20:07 | JAIL_FREE user=344531774518591498 method=doubles
|
||||
2026-04-01 20:20:14 | DAILY user=344531774518591498 earned=+825 streak=1 bal=113526
|
||||
2026-04-01 20:20:16 | WORK user=344531774518591498 earned=+45 lucky=False bal=113571
|
||||
2026-04-01 20:20:19 | WORK user=272518654715887618 earned=+55 lucky=False bal=24171
|
||||
2026-04-01 20:20:19 | BEG user=344531774518591498 earned=+22 jailed=False bal=113593
|
||||
2026-04-01 20:20:36 | BLACKJACK user=272518654715887618 payout=+0 net=-24171 bal=0
|
||||
2026-04-01 20:21:03 | CRIME_FAIL user=344531774518591498 fine=-90 jailed=True bal=113503
|
||||
2026-04-01 20:21:11 | FISH user=272518654715887618 fish=koger weight=590 value=15
|
||||
2026-04-01 20:21:45 | ROB_WIN robber=344531774518591498 victim=340451525799182357 stolen=+15566 jackpot=False robber_bal=129069 victim_bal=123672
|
||||
2026-04-01 20:25:40 | BAIL_PAID user=178852380018868224 fine=-8760 pct=26% bal=25108
|
||||
2026-04-01 20:28:28 | BEG user=178852380018868224 earned=+28 jailed=False bal=25136
|
||||
2026-04-01 20:28:30 | WORK user=178852380018868224 earned=+92 lucky=False bal=25228
|
||||
2026-04-01 20:28:33 | DAILY user=178852380018868224 earned=+825 streak=1 bal=26053
|
||||
2026-04-01 20:28:38 | CRIME_WIN user=178852380018868224 earned=+640 bal=26693
|
||||
2026-04-01 20:35:35 | BEG user=401373976431165449 earned=+56 jailed=True bal=1308
|
||||
2026-04-01 20:36:20 | JAIL_FREE user=401373976431165449 method=doubles
|
||||
2026-04-01 20:37:47 | FISH user=401373976431165449 fish=angerjas weight=989 value=79
|
||||
2026-04-01 20:46:02 | DAILY user=338622999127261185 earned=+300 streak=1 bal=300
|
||||
2026-04-01 20:56:03 | BEG user=344531774518591498 earned=+60 jailed=False bal=129129
|
||||
2026-04-01 20:56:29 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=must mult=1 bal=128129
|
||||
2026-04-01 21:01:23 | BEG user=272518654715887618 earned=+33 jailed=False bal=33
|
||||
2026-04-01 21:01:42 | FISH user=272518654715887618 fish=sarj weight=151 value=6
|
||||
2026-04-01 21:02:38 | FISH user=344531774518591498 fish=viidikas weight=98 value=6
|
||||
2026-04-01 21:02:57 | FISH_SELL user=344531774518591498 count=2 coins=+13 bal=128142
|
||||
2026-04-01 21:03:10 | BEG user=401373976431165449 earned=+54 jailed=False bal=1362
|
||||
2026-04-01 21:03:31 | FISH user=401373976431165449 fish=siig weight=584 value=63
|
||||
2026-04-01 21:05:19 | WORK user=401373976431165449 earned=+131 lucky=False bal=1493
|
||||
2026-04-01 21:05:38 | FISH user=401373976431165449 fish=siig weight=1624 value=112
|
||||
2026-04-01 21:05:48 | FISH_SELL user=401373976431165449 count=3 coins=+254 bal=1747
|
||||
2026-04-01 21:08:31 | BEG user=401373976431165449 earned=+32 jailed=False bal=1779
|
||||
2026-04-01 21:08:45 | FISH user=401373976431165449 fish=tougjas weight=3051 value=207
|
||||
2026-04-01 21:09:01 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=2779
|
||||
2026-04-01 21:09:35 | BLACKJACK user=401373976431165449 payout=+3000 net=+1500 bal=4279
|
||||
2026-04-01 21:09:35 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=must mult=1 bal=127142
|
||||
2026-04-01 21:10:05 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=5279
|
||||
2026-04-01 21:10:46 | ROULETTE_WIN user=344531774518591498 bet=1000 colour=punane result=punane mult=1 bal=128142
|
||||
2026-04-01 21:11:03 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=must result=punane mult=1 bal=4279
|
||||
2026-04-01 21:11:24 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=roheline mult=1 bal=127142
|
||||
2026-04-01 21:15:50 | WORK user=338622999127261185 earned=+15 lucky=False bal=315
|
||||
2026-04-01 21:15:54 | CRIME_WIN user=338622999127261185 earned=+453 bal=768
|
||||
2026-04-01 21:16:00 | BEG user=338622999127261185 earned=+20 jailed=False bal=788
|
||||
2026-04-01 21:16:13 | FISH user=338622999127261185 fish=ahven weight=422 value=14
|
||||
2026-04-01 21:18:36 | BEG user=401373976431165449 earned=+20 jailed=False bal=4299
|
||||
2026-04-01 21:18:52 | FISH user=401373976431165449 fish=karpkala weight=1920 value=47
|
||||
2026-04-01 21:20:58 | SLOTS_TRIPLE user=344531774518591498 bet=1000 change=4000 bal=131142
|
||||
2026-04-01 21:21:39 | SLOTS_MISS user=344531774518591498 bet=1000 change=-1000 bal=130142
|
||||
2026-04-01 21:28:25 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=3299
|
||||
2026-04-01 21:29:10 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=130642
|
||||
2026-04-01 21:29:28 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=2299
|
||||
2026-04-01 21:30:49 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=131142
|
||||
2026-04-01 21:31:30 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=131642
|
||||
2026-04-01 21:31:33 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=3299
|
||||
2026-04-01 21:31:37 | BEG user=401373976431165449 earned=+68 jailed=False bal=3367
|
||||
2026-04-01 21:32:05 | FISH user=401373976431165449 fish=latikas weight=2351 value=66
|
||||
2026-04-01 21:33:14 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=2367
|
||||
2026-04-01 21:35:09 | SLOTS_MISS user=344531774518591498 bet=1000 change=-1000 bal=130642
|
||||
2026-04-01 21:35:14 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=1367
|
||||
2026-04-01 21:48:35 | ROB_FAIL robber=338622999127261185 victim=340451525799182357 fine=-237 robber_bal=551
|
||||
2026-04-01 21:49:59 | BEG user=401373976431165449 earned=+56 jailed=False bal=1423
|
||||
2026-04-01 21:50:15 | FISH user=401373976431165449 fish=vimb weight=856 value=462
|
||||
2026-04-01 22:11:02 | BEG user=401373976431165449 earned=+52 jailed=False bal=1475
|
||||
2026-04-01 22:11:13 | WORK user=401373976431165449 earned=+73 lucky=False bal=1548
|
||||
2026-04-01 22:11:27 | ROB_WIN robber=401373976431165449 victim=367347301322326016 stolen=+1818 jackpot=False robber_bal=3366 victim_bal=5684
|
||||
2026-04-01 22:11:44 | FISH user=401373976431165449 fish=lohe weight=2973 value=313
|
||||
2026-04-01 22:13:30 | ROULETTE_LOSE user=401373976431165449 bet=2000 colour=punane result=roheline mult=1 bal=1366
|
||||
2026-04-01 22:14:33 | ROULETTE_LOSE user=401373976431165449 bet=1366 colour=punane result=must mult=1 bal=0
|
||||
2026-04-01 22:40:19 | WORK user=367347301322326016 earned=+82 lucky=False bal=5766
|
||||
2026-04-01 22:40:23 | CRIME_FAIL user=367347301322326016 fine=-100 jailed=True bal=5666
|
||||
2026-04-01 22:46:27 | WORK user=344531774518591498 earned=+116 lucky=False bal=130758
|
||||
2026-04-01 22:46:30 | BEG user=344531774518591498 earned=+58 jailed=False bal=130816
|
||||
2026-04-01 22:46:35 | CRIME_WIN user=344531774518591498 earned=+419 bal=131235
|
||||
2026-04-01 22:46:44 | ROB_FAIL robber=344531774518591498 victim=340451525799182357 fine=-246 robber_bal=130989
|
||||
2026-04-01 22:47:01 | FISH user=344531774518591498 fish=viidikas weight=80 value=5
|
||||
2026-04-01 22:48:58 | WORK user=178852380018868224 earned=+106 lucky=True bal=26799
|
||||
2026-04-01 22:49:03 | CRIME_WIN user=178852380018868224 earned=+486 bal=27285
|
||||
2026-04-01 22:49:05 | BEG user=178852380018868224 earned=+76 jailed=False bal=27361
|
||||
2026-04-01 22:52:25 | BEG user=401373976431165449 earned=+44 jailed=False bal=44
|
||||
2026-04-01 22:52:29 | WORK user=401373976431165449 earned=+103 lucky=False bal=147
|
||||
2026-04-01 22:52:39 | CRIME_FAIL user=401373976431165449 fine=-125 jailed=False bal=22
|
||||
2026-04-01 22:53:01 | FISH user=401373976431165449 fish=latikas weight=1217 value=40
|
||||
2026-04-01 22:57:11 | SLOTS_PAIR user=344531774518591498 bet=10000 change=5000 bal=135989
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -23,12 +23,12 @@ from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Ensure the project root is on sys.path so pb_client can be imported
|
||||
# Ensure the project root is on sys.path so core modules can be imported
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
load_dotenv()
|
||||
|
||||
import pb_client # noqa: E402 (needs dotenv loaded first)
|
||||
from core import pb_client # noqa: E402 (needs dotenv loaded first)
|
||||
|
||||
DATA_FILE = Path("data") / "economy.json"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Destructively recreate economy PocketBase collections for dev + economy profiles.
|
||||
"""Destructively recreate TipiBOT PocketBase collections.
|
||||
|
||||
Usage:
|
||||
python scripts/reset_pb_collections.py --confirm
|
||||
@@ -6,6 +6,8 @@ Usage:
|
||||
This will DELETE and recreate the collections configured by:
|
||||
- PB_ECONOMY_COLLECTION_DEV
|
||||
- PB_ECONOMY_COLLECTION_ECONOMY
|
||||
- PB_ECONOMY_COLLECTION_LAN
|
||||
- PB_FIENTA_COLLECTION_LAN
|
||||
"""
|
||||
|
||||
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 with session.post(
|
||||
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}")
|
||||
|
||||
|
||||
async def _create_collection(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None:
|
||||
payload = _collection_payload(name)
|
||||
async def _create_collection(
|
||||
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:
|
||||
if resp.status not in (200, 201):
|
||||
raise RuntimeError(f"Create failed for {name} ({resp.status}): {await resp.text()}")
|
||||
@@ -154,10 +205,17 @@ async def main() -> None:
|
||||
if not args.confirm:
|
||||
raise SystemExit("Refusing to run without --confirm (this operation deletes collections).")
|
||||
|
||||
targets = []
|
||||
for name in [config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY]:
|
||||
if name and name not in targets:
|
||||
targets.append(name)
|
||||
targets: list[tuple[str, dict]] = []
|
||||
for name in [
|
||||
config.PB_ECONOMY_COLLECTION_DEV,
|
||||
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:
|
||||
raise SystemExit("No target collections configured.")
|
||||
@@ -167,12 +225,12 @@ async def main() -> None:
|
||||
token = await _auth_token(session)
|
||||
headers = {"Authorization": token}
|
||||
|
||||
for name in targets:
|
||||
for name, payload in targets:
|
||||
await _delete_if_exists(session, headers, name)
|
||||
await _create_collection(session, headers, name)
|
||||
await _create_collection(session, headers, name, payload)
|
||||
|
||||
print("\nDone. Collections recreated:")
|
||||
for name in targets:
|
||||
for name, _ in targets:
|
||||
print(f" - {name}")
|
||||
|
||||
|
||||
|
||||
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
|
||||
@@ -171,6 +171,7 @@ CMD: dict[str, str] = {
|
||||
"fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)",
|
||||
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
|
||||
"fishsell": "Müü kalu oma inventarist",
|
||||
"fientasync": "[Admin] Sünkroniseeri LAN Fienta registreeringud uuesti",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -306,6 +307,7 @@ HELP_CATEGORIES: dict[str, dict] = {
|
||||
("/channels", "Näita lubatud kanalite nimekirja"),
|
||||
("/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"),
|
||||
("/fientasync", "Sünkroniseeri LAN Fienta registreeringute rollid ja avalik tabel uuesti"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user