forked from sass/tipibot
Compare commits
14 Commits
07360d3f11
...
fienta
| Author | SHA1 | Date | |
|---|---|---|---|
| 4abc367faf | |||
|
|
691f160a09 | ||
|
|
3c2b4342a2 | ||
|
|
93f4d471dc | ||
|
|
de7cfce833 | ||
|
|
a4a447867f | ||
|
|
9ae26049c5 | ||
|
|
b998418c14 | ||
|
|
94df54dde2 | ||
|
|
77a3badd41 | ||
|
|
17102ae202 | ||
|
|
cd41bc2a48 | ||
| 802a6a2e8d | |||
|
|
64d9b304a9 |
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.
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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