20 Commits

Author SHA1 Message Date
Rene Arumetsa
2c2621d24e Make exp gains less grindy 2026-05-04 17:48:55 +03:00
Rene Arumetsa
192888625e Refactor db error catch to one helper 2026-05-04 17:31:16 +03:00
Rene Arumetsa
6d344a47f4 Fix 403 auth error 2026-05-04 17:14:21 +03:00
Rene Arumetsa
d65173fbe9 Some bug updates 2026-05-03 14:45:42 +03:00
Rene Arumetsa
58684d5f34 Add patch notes to bot 2026-05-03 12:02:19 +03:00
Rene Arumetsa
8529706809 Remove docker support 2026-05-03 09:21:39 +03:00
Rene Arumetsa
173e2564f1 Add missing import 2026-05-01 10:52:48 +03:00
Rene Arumetsa
93f4d471dc Add db error in functions 2026-04-29 00:04:24 +03:00
Rene Arumetsa
de7cfce833 Lower pb token timeout 2026-04-29 00:00:18 +03:00
Rene Arumetsa
a4a447867f Added ci/cd 2026-04-26 21:51:07 +03:00
Rene Arumetsa
9ae26049c5 Fix fishing sell bug 2026-04-26 20:37:07 +03:00
Rene Arumetsa
b998418c14 Remove data/, usless folder 2026-04-20 23:13:02 +03:00
Rene Arumetsa
94df54dde2 Remove data/ from git logs 2026-04-20 23:06:17 +03:00
Rene Arumetsa
77a3badd41 Feature: Clean up the codebase 2026-04-20 23:01:51 +03:00
Rene Arumetsa
17102ae202 Removed BOT_PROFILE from .env, set in compose.yml instead 2026-04-20 22:52:29 +03:00
Rene Arumetsa
cd41bc2a48 Remove DEV_NOTE3S in root directory 2026-04-20 22:42:26 +03:00
802a6a2e8d Merge pull request 'Add container support' (#2) from containers into master
Reviewed-on: renkar/tipibot#2
2026-04-20 19:39:24 +00:00
Rene Arumetsa
64d9b304a9 Add container support 2026-04-20 22:37:55 +03:00
Rene Arumetsa
07360d3f11 Remove logs from git 2026-04-20 22:28:24 +03:00
8f28832432 Merge pull request 'Feature: Start with rewrite-v2' (#1) from rewrite-v2 into master
Reviewed-on: renkar/tipibot#1
2026-04-20 19:09:45 +00:00
34 changed files with 398 additions and 17552 deletions

View File

@@ -1,5 +1,3 @@
# 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) # Profile-specific Discord bot tokens (from https://discord.com/developers/applications)
DISCORD_TOKEN_DEV=your-dev-bot-token-here DISCORD_TOKEN_DEV=your-dev-bot-token-here

View 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

3
.gitignore vendored
View File

@@ -4,8 +4,7 @@ __pycache__/
*.pyc *.pyc
.venv/ .venv/
venv/ venv/
data/restart_channel.json data/
data/economy.json
pocketbase.exe pocketbase.exe
pocketbase pocketbase
pb_data/ pb_data/

View File

@@ -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 | 1025% of target | 45% success (60% w/ jellyfin); fail = fine; cannot rob TipiBOT directly |
| `/heist` | 4h personal + 1h global | 2055% 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 515s 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

33
bot.py
View File

@@ -21,22 +21,21 @@ import psutil
import config import config
import strings as S import strings as S
import economy from core import economy, pb_client, sheets
import pb_client from core.member_sync import SyncResult
import sheets from commands.dev_member_commands import register_dev_member_commands
from dev_member_commands import register_dev_member_commands from commands.dev_member_runtime import handle_member_join, run_birthday_daily
from dev_member_runtime import handle_member_join, run_birthday_daily from commands.economy_admin_commands import register_economy_admin_commands
from economy_admin_commands import register_economy_admin_commands from commands.economy_extra_commands import register_economy_extra_commands
from economy_extra_commands import register_economy_extra_commands from commands.economy_fish_commands import register_economy_fish_commands
from economy_fish_commands import register_economy_fish_commands from commands.economy_games_commands import register_economy_games_commands
from economy_games_commands import register_economy_games_commands from commands.economy_income_commands import register_economy_income_commands
from economy_income_commands import register_economy_income_commands from commands.economy_prestige_commands import register_prestige_commands
from economy_prestige_commands import register_prestige_commands from commands.economy_profile_commands import register_economy_profile_commands
from economy_profile_commands import register_economy_profile_commands from commands.economy_support_commands import register_economy_support_commands
from economy_support_commands import register_economy_support_commands from commands.ops_channel_commands import register_ops_channel_commands
from ops_channel_commands import register_ops_channel_commands from commands.ops_admin_commands import register_ops_admin_commands
from ops_admin_commands import register_ops_admin_commands from commands.info_commands import register_info_commands
from member_sync import SyncResult
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Logging # Logging
@@ -485,6 +484,8 @@ register_ops_channel_commands(
set_allowed_channels=_set_allowed_channels, set_allowed_channels=_set_allowed_channels,
) )
register_info_commands(tree, bot, log)
@tree.command(name="ping", description=S.CMD["ping"]) @tree.command(name="ping", description=S.CMD["ping"])
async def cmd_ping(interaction: discord.Interaction): async def cmd_ping(interaction: discord.Interaction):

View File

@@ -7,9 +7,9 @@ from typing import Callable
import discord import discord
from discord import app_commands from discord import app_commands
import sheets from core import sheets
import strings as S 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): class BirthdayPages(discord.ui.View):

View File

@@ -6,8 +6,8 @@ from collections.abc import Callable
import discord import discord
import config import config
import sheets from core import sheets
from member_sync import announce_birthday, is_birthday_today, sync_member from core.member_sync import announce_birthday, is_birthday_today, sync_member
async def run_birthday_daily( async def run_birthday_daily(

View File

@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
import discord import discord
from discord import app_commands from discord import app_commands
import economy from core import economy
import strings as S import strings as S

View File

@@ -9,7 +9,7 @@ from collections.abc import Awaitable, Callable, MutableSet
import discord import discord
from discord import app_commands from discord import app_commands
import economy from core import economy
import strings as S import strings as S

View File

@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable, MutableSet
import discord import discord
from discord import app_commands from discord import app_commands
import economy from core import economy
import strings as S import strings as S

View File

@@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable, MutableSet
import discord import discord
from discord import app_commands from discord import app_commands
import economy from core import economy
import strings as S import strings as S

View File

@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
import discord import discord
from discord import app_commands from discord import app_commands
import economy from core import economy
import strings as S import strings as S

View File

@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
import discord import discord
from discord import app_commands from discord import app_commands
import economy from core import economy
import strings as S import strings as S

View File

@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
import discord import discord
from discord import app_commands from discord import app_commands
import economy from core import economy
import strings as S import strings as S

View File

@@ -5,7 +5,7 @@ from collections.abc import Callable
import discord import discord
from discord import app_commands from discord import app_commands
import economy from core import economy
import strings as S import strings as S

148
commands/info_commands.py Normal file
View File

@@ -0,0 +1,148 @@
from __future__ import annotations
import logging
import re
from pathlib import Path
import discord
from discord import app_commands
import strings as S
_PATCHNOTES_PATH = Path(__file__).resolve().parent.parent / "docs" / "PATCHNOTES.md"
_VERSION_RE = re.compile(r"^##\s+(.+?)\s*$")
_EMBED_DESC_MAX = 4096
_SELECT_OPTIONS_MAX = 25
def _load_versions() -> list[tuple[str, str]]:
try:
text = _PATCHNOTES_PATH.read_text(encoding="utf-8")
except FileNotFoundError:
return []
versions: list[tuple[str, str]] = []
cur_header: str | None = None
cur_body: list[str] = []
for line in text.splitlines():
m = _VERSION_RE.match(line)
if m:
if cur_header is not None:
versions.append((cur_header, "\n".join(cur_body).strip()))
cur_header = m.group(1).strip()
cur_body = []
elif cur_header is not None:
cur_body.append(line)
if cur_header is not None:
versions.append((cur_header, "\n".join(cur_body).strip()))
return versions
def _build_embed(versions: list[tuple[str, str]], idx: int) -> discord.Embed:
header, body = versions[idx]
if len(body) > _EMBED_DESC_MAX:
body = body[: _EMBED_DESC_MAX - 1] + ""
embed = discord.Embed(
title=S.PATCHNOTES_UI["title"].format(version=header),
description=body or S.PATCHNOTES_UI["empty_version"],
color=0x5865F2,
)
embed.set_footer(
text=S.PATCHNOTES_UI["footer"].format(idx=idx + 1, total=len(versions))
)
return embed
def register_info_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
log: logging.Logger,
) -> None:
@tree.command(name="patchnotes", description=S.CMD["patchnotes"])
async def cmd_patchnotes(interaction: discord.Interaction):
versions = _load_versions()
if not versions:
await interaction.response.send_message(
S.PATCHNOTES_UI["empty_file"], ephemeral=True
)
return
invoker_id = interaction.user.id
class PatchNotesView(discord.ui.View):
def __init__(self, idx: int = 0):
super().__init__(timeout=180)
self.idx = idx
self._rebuild()
def _rebuild(self):
self.clear_items()
newer_btn = discord.ui.Button(
label=S.PATCHNOTES_UI["btn_newer"],
style=discord.ButtonStyle.secondary,
disabled=self.idx <= 0,
)
older_btn = discord.ui.Button(
label=S.PATCHNOTES_UI["btn_older"],
style=discord.ButtonStyle.secondary,
disabled=self.idx >= len(versions) - 1,
)
newer_btn.callback = self._make_step_cb(-1)
older_btn.callback = self._make_step_cb(+1)
self.add_item(newer_btn)
self.add_item(older_btn)
opts: list[discord.SelectOption] = []
for i, (hdr, _) in enumerate(versions[:_SELECT_OPTIONS_MAX]):
opts.append(
discord.SelectOption(
label=hdr[:100],
value=str(i),
default=(i == self.idx),
)
)
if len(opts) > 1:
select = discord.ui.Select(
placeholder=S.PATCHNOTES_UI["select_placeholder"],
options=opts,
min_values=1,
max_values=1,
)
select.callback = self._make_select_cb(select)
self.add_item(select)
def _make_step_cb(self, delta: int):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != invoker_id:
await interaction.response.send_message(
S.ERR["not_your_menu"], ephemeral=True
)
return
self.idx = max(0, min(len(versions) - 1, self.idx + delta))
self._rebuild()
await interaction.response.edit_message(
embed=_build_embed(versions, self.idx), view=self
)
return _cb
def _make_select_cb(self, select: discord.ui.Select):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != invoker_id:
await interaction.response.send_message(
S.ERR["not_your_menu"], ephemeral=True
)
return
self.idx = int(select.values[0])
self._rebuild()
await interaction.response.edit_message(
embed=_build_embed(versions, self.idx), view=self
)
return _cb
view = PatchNotesView(0)
await interaction.response.send_message(
embed=_build_embed(versions, 0), view=view, ephemeral=True
)
log.info("/patchnotes by %s (%d versions)", interaction.user, len(versions))

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable
import discord import discord
from discord import app_commands from discord import app_commands
import economy from core import economy
import strings as S import strings as S

View File

@@ -6,13 +6,17 @@ All public async functions are the single source of truth for mutations.
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import math import math
import random import random
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from typing import TypedDict from typing import TypedDict
import pb_client import aiohttp
from . import pb_client
from .pb_client import DatabaseError
import strings import strings
@@ -313,16 +317,16 @@ LEVEL_ROLES: list[tuple[int, str]] = [
def get_level(exp: int) -> int: def get_level(exp: int) -> int:
"""Level = max(1, floor(sqrt(exp/10))). """Level = max(1, floor(sqrt(exp/6))).
Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000.""" Level 5 @ 150 EXP, 10 @ 600, 20 @ 2400, 30 @ 5400."""
return max(1, int(math.sqrt(max(0, exp) / 10))) return max(1, int(math.sqrt(max(0, exp) / 6)))
def exp_for_level(level: int) -> int: def exp_for_level(level: int) -> int:
"""Minimum cumulative EXP to reach this level.""" """Minimum cumulative EXP to reach this level. level^2 * 6."""
if level <= 1: if level <= 1:
return 0 return 0
return level * level * 10 return level * level * 6
def level_role_name(level: int) -> str: def level_role_name(level: int) -> str:
@@ -583,11 +587,15 @@ def format_td(td: timedelta) -> str:
async def get_user(user_id: int) -> UserData: async def get_user(user_id: int) -> UserData:
"""Fetch user data from PocketBase, creating a default record if first seen.""" """Fetch user data from PocketBase, creating a default record if first seen."""
uid = str(user_id) uid = str(user_id)
record = await pb_client.get_record(uid) try:
if record is None: record = await pb_client.get_record(uid)
default = _default_user() if record is None:
default["user_id"] = uid # type: ignore[typeddict-unknown-key] default = _default_user()
record = await pb_client.create_record(default) default["user_id"] = uid # type: ignore[typeddict-unknown-key]
record = await pb_client.create_record(default)
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
_log.error("PocketBase unreachable for user %s: %s", user_id, exc)
raise DatabaseError(f"Database unavailable: {exc}") from exc
user = _default_user() user = _default_user()
for key in list(user.keys()): for key in list(user.keys()):
if key in record: if key in record:
@@ -678,25 +686,29 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
# Internal write helper # Internal write helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _commit(user_id: int, user: UserData) -> None: async def _commit(user_id: int, user: UserData) -> None:
record_id = user.get("_pb_id") # type: ignore[typeddict-item]
clean = {k: v for k, v in user.items() if k != "_pb_id"}
clean["user_id"] = str(user_id)
try: try:
record_id = user.get("_pb_id") # type: ignore[typeddict-item]
clean = {k: v for k, v in user.items() if k != "_pb_id"}
clean["user_id"] = str(user_id)
if record_id: if record_id:
await pb_client.update_record(record_id, clean) await pb_client.update_record(record_id, clean)
else: else:
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id) _log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
created = await pb_client.create_record(clean) created = await pb_client.create_record(clean)
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key] user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
except Exception as exc: except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
_log.error("_commit failed for user %s: %s", user_id, exc) _log.error("_commit failed for user %s: %s", user_id, exc)
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /daily # /daily
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def do_daily(user_id: int) -> dict: async def do_daily(user_id: int) -> dict:
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
@@ -772,7 +784,10 @@ _WORK_JOBS = strings.WORK_JOBS
async def do_work(user_id: int) -> dict: async def do_work(user_id: int) -> dict:
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
@@ -822,7 +837,10 @@ _BEG_JAIL_LINES = strings.BEG_JAIL_LINES
async def do_beg(user_id: int) -> dict: async def do_beg(user_id: int) -> dict:
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
@@ -857,7 +875,10 @@ async def do_beg(user_id: int) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def do_fish_start(user_id: int) -> dict: async def do_fish_start(user_id: int) -> dict:
"""Check cooldown + jail, set cooldown. Call before starting the fishing minigame.""" """Check cooldown + jail, set cooldown. Call before starting the fishing minigame."""
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user): if jail := _is_jailed(user):
@@ -928,8 +949,13 @@ async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict:
to_sell = inv to_sell = inv
remaining = [] remaining = []
else: else:
to_sell = [inv[i] for i in sorted(set(indices)) if 0 <= i < len(inv)] sell_idx = {
keep_idx = set(range(len(inv))) - set(indices) (i if i >= 0 else len(inv) + i)
for i in indices
}
sell_idx = {i for i in sell_idx if 0 <= i < len(inv)}
to_sell = [inv[i] for i in sorted(sell_idx)]
keep_idx = set(range(len(inv))) - sell_idx
remaining = [inv[i] for i in sorted(keep_idx)] remaining = [inv[i] for i in sorted(keep_idx)]
if not to_sell: if not to_sell:
@@ -974,7 +1000,10 @@ async def do_fishbook(user_id: int) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def do_prestige(user_id: int) -> dict: async def do_prestige(user_id: int) -> dict:
"""Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns.""" """Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns."""
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
@@ -1022,7 +1051,10 @@ async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict:
if upgrade_id not in PRESTIGE_SHOP: if upgrade_id not in PRESTIGE_SHOP:
return {"ok": False, "reason": "not_found"} return {"ok": False, "reason": "not_found"}
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
@@ -1116,7 +1148,10 @@ _CRIME_LOSE = strings.CRIME_LOSE
async def do_crime(user_id: int) -> dict: async def do_crime(user_id: int) -> dict:
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
@@ -1209,10 +1244,16 @@ async def do_bail(user_id: int) -> dict:
# /rob # /rob
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def do_rob(robber_id: int, target_id: int) -> dict: async def do_rob(robber_id: int, target_id: int) -> dict:
robber = await get_user(robber_id) try:
robber = await get_user(robber_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if robber.get("eco_banned"): if robber.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
target = await get_user(target_id) try:
target = await get_user(target_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if cd := _cooldown_remaining(robber, "rob"): if cd := _cooldown_remaining(robber, "rob"):
return {"ok": False, "reason": "cooldown", "remaining": cd} return {"ok": False, "reason": "cooldown", "remaining": cd}
@@ -1284,7 +1325,10 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
# /roulette # /roulette
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def do_roulette(user_id: int, bet: int, colour: str) -> dict: async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user): if jail := _is_jailed(user):
@@ -1324,7 +1368,10 @@ async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'.""" """Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user): if jail := _is_jailed(user):
@@ -1377,7 +1424,10 @@ def _spin() -> str:
async def do_slots(user_id: int, bet: int) -> dict: async def do_slots(user_id: int, bet: int) -> dict:
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user): if jail := _is_jailed(user):
@@ -1430,7 +1480,10 @@ async def do_slots(user_id: int, bet: int) -> dict:
# /give # /give
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
giver = await get_user(giver_id) try:
giver = await get_user(giver_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if giver.get("eco_banned"): if giver.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
@@ -1440,7 +1493,10 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
if giver["balance"] < amount: if giver["balance"] < amount:
return {"ok": False, "reason": "insufficient"} return {"ok": False, "reason": "insufficient"}
receiver = await get_user(receiver_id) try:
receiver = await get_user(receiver_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
giver["balance"] -= amount giver["balance"] -= amount
receiver["balance"] += amount receiver["balance"] += amount
giver["total_given"] = giver.get("total_given", 0) + amount giver["total_given"] = giver.get("total_given", 0) + amount
@@ -1462,7 +1518,10 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
async def do_buy(user_id: int, item_id: str) -> dict: async def do_buy(user_id: int, item_id: str) -> dict:
if item_id not in SHOP: if item_id not in SHOP:
return {"ok": False, "reason": "not_found"} return {"ok": False, "reason": "not_found"}
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
@@ -1627,7 +1686,10 @@ async def do_set_reminders(user_id: int, commands: list[str]) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def do_blackjack_bet(user_id: int, bet: int) -> dict: async def do_blackjack_bet(user_id: int, bet: int) -> dict:
"""Deduct the initial blackjack bet. Returns ok/fail.""" """Deduct the initial blackjack bet. Returns ok/fail."""
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user): if jail := _is_jailed(user):
@@ -1682,7 +1744,10 @@ async def do_get_jailed() -> list[tuple[int, timedelta]]:
async def do_heist_check(user_id: int) -> dict: async def do_heist_check(user_id: int) -> dict:
"""Check whether a user is eligible to join a heist.""" """Check whether a user is eligible to join a heist."""
user = await get_user(user_id) try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"): if user.get("eco_banned"):
return {"ok": False, "reason": "banned"} return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user): if jail := _is_jailed(user):

View File

@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
import discord import discord
import config import config
import sheets from . import sheets
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"} _PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}

View File

@@ -21,6 +21,11 @@ import aiohttp
import config import config
class DatabaseError(Exception):
"""Raised when PocketBase is unreachable or returns an error."""
pass
_log = logging.getLogger("tipiCOIN.pb") _log = logging.getLogger("tipiCOIN.pb")
PB_URL = config.PB_URL PB_URL = config.PB_URL
@@ -28,7 +33,7 @@ PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
ECONOMY_COLLECTION = config.PB_ECONOMY_COLLECTION ECONOMY_COLLECTION = config.PB_ECONOMY_COLLECTION
_TIMEOUT = aiohttp.ClientTimeout(total=10) _TIMEOUT = aiohttp.ClientTimeout(total=4)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Persistent session (created once, reused for the lifetime of the process) # Persistent session (created once, reused for the lifetime of the process)
@@ -57,17 +62,20 @@ async def _ensure_auth() -> str:
if time.monotonic() < _token_expiry: if time.monotonic() < _token_expiry:
return _token return _token
session = _get_session() session = _get_session()
async with session.post( try:
f"{PB_URL}/api/collections/_superusers/auth-with-password", async with session.post(
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, f"{PB_URL}/api/collections/_superusers/auth-with-password",
) as resp: json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
if resp.status != 200: ) as resp:
text = await resp.text() if resp.status != 200:
raise RuntimeError(f"PocketBase auth failed ({resp.status}): {text}") text = await resp.text()
data = await resp.json() raise DatabaseError(f"PocketBase auth failed ({resp.status}): {text}")
_token = data["token"] data = await resp.json()
_token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry _token = data["token"]
_log.debug("PocketBase admin token refreshed") _token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry
_log.debug("PocketBase admin token refreshed")
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
raise DatabaseError(f"Database unavailable: {e}") from e
return _token return _token
@@ -75,80 +83,92 @@ async def _hdrs() -> dict[str, str]:
return {"Authorization": await _ensure_auth()} return {"Authorization": await _ensure_auth()}
def _invalidate_token() -> None:
global _token_expiry
_token_expiry = 0.0
# ---------------------------------------------------------------------------
# Request helper with auth-retry and error wrapping
# ---------------------------------------------------------------------------
async def _request(method: str, url: str, **kwargs: Any) -> Any:
"""Make an authenticated request, retrying once on 401/403 by re-authing.
Returns the parsed JSON body. Raises DatabaseError on connection issues or
non-2xx responses after retrying.
"""
session = _get_session()
for attempt in range(2):
kwargs["headers"] = await _hdrs()
try:
async with session.request(method, url, **kwargs) as resp:
if resp.status in (401, 403) and attempt == 0:
_invalidate_token()
continue
if not resp.ok:
text = await resp.text()
raise DatabaseError(f"Database unavailable: {resp.status}, {text}")
return await resp.json()
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
raise DatabaseError(f"Database unavailable: {e}") from e
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CRUD helpers # CRUD helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def get_record(user_id: str) -> dict[str, Any] | None: async def get_record(user_id: str) -> dict[str, Any] | None:
"""Fetch one economy record by Discord user_id. Returns None if not found.""" """Fetch one economy record by Discord user_id. Returns None if not found."""
session = _get_session() data = await _request(
async with session.get( "GET",
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
params={"filter": f'user_id="{user_id}"', "perPage": 1}, params={"filter": f'user_id="{user_id}"', "perPage": 1},
headers=await _hdrs(), )
) as resp: items = data.get("items", [])
resp.raise_for_status() return items[0] if items else None
data = await resp.json()
items = data.get("items", [])
return items[0] if items else None
async def create_record(record: dict[str, Any]) -> dict[str, Any]: async def create_record(record: dict[str, Any]) -> dict[str, Any]:
"""Create a new economy record. Returns the created record (includes PB id).""" """Create a new economy record. Returns the created record (includes PB id)."""
session = _get_session() return await _request(
async with session.post( "POST",
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
json=record, json=record,
headers=await _hdrs(), )
) as resp:
if resp.status not in (200, 201):
text = await resp.text()
raise RuntimeError(f"PocketBase create failed ({resp.status}): {text}")
return await resp.json()
async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]: async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]:
"""PATCH an existing record by its PocketBase record id.""" """PATCH an existing record by its PocketBase record id."""
session = _get_session() return await _request(
async with session.patch( "PATCH",
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}", f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}",
json=data, json=data,
headers=await _hdrs(), )
) as resp:
resp.raise_for_status()
return await resp.json()
async def count_records() -> int: async def count_records() -> int:
"""Return the total number of records in the collection (single cheap request).""" """Return the total number of records in the collection (single cheap request)."""
session = _get_session() data = await _request(
async with session.get( "GET",
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
params={"perPage": 1, "page": 1}, params={"perPage": 1, "page": 1},
headers=await _hdrs(), )
) as resp: return int(data.get("totalItems", 0))
resp.raise_for_status()
data = await resp.json()
return int(data.get("totalItems", 0))
async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]: async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]:
"""Fetch every record in the collection, handling PocketBase pagination.""" """Fetch every record in the collection, handling PocketBase pagination."""
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
page = 1 page = 1
session = _get_session()
hdrs = await _hdrs()
while True: while True:
async with session.get( data = await _request(
"GET",
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
params={"perPage": page_size, "page": page}, params={"perPage": page_size, "page": page},
headers=hdrs, )
) as resp: batch = data.get("items", [])
resp.raise_for_status() results.extend(batch)
data = await resp.json() if len(batch) < page_size:
batch = data.get("items", []) return results
results.extend(batch) page += 1
if len(batch) < page_size:
break
page += 1
return results

View File

View File

@@ -1,5 +0,0 @@
{
"2026-03-14": [
"650046190972305409"
]
}

View File

@@ -1,5 +0,0 @@
{
"allowed_channels": [
"1482398641699291357"
]
}

10
docs/PATCHNOTES.md Normal file
View File

@@ -0,0 +1,10 @@
# TipiBOT changelog
Here you'll find an overview of TipiBOT updates. Latest changes are at the top.
Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`).
## v0.1.0 — 2026-05-03
- Added `/patchnotes`
- Fixed silent swallowing of database write errors — failed saves now show the user an error instead of appearing to succeed
- Fixed fish-sell bug that let the last fish be duplicated (sold and kept in inventory)

11386
logs/bot.log

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
2026-04-01 18:44:29 | WORK user=272518654715887618 earned=+54 lucky=False bal=24077
2026-04-01 20:02:11 | BEG user=272518654715887618 earned=+23 jailed=False bal=24100
2026-04-01 20:02:53 | ROULETTE_WIN user=178852380018868224 bet=15214708 colour=punane result=punane mult=1 bal=30429416
2026-04-01 20:03:42 | ROULETTE_WIN user=178852380018868224 bet=30429416 colour=must result=must mult=1 bal=60858832
2026-04-01 20:04:16 | ROULETTE_LOSE user=178852380018868224 bet=60858832 colour=punane result=must mult=1 bal=0
2026-04-01 20:06:37 | BEG user=401373976431165449 earned=+52 jailed=False bal=8446
2026-04-01 20:06:39 | WORK user=401373976431165449 earned=+60 lucky=False bal=8506
2026-04-01 20:06:52 | DAILY user=401373976431165449 earned=+750 streak=1 bal=9256
2026-04-01 20:07:07 | DAILY user=272518654715887618 earned=+825 streak=1 bal=24925
2026-04-01 20:07:12 | CRIME_WIN user=272518654715887618 earned=+331 bal=25256
2026-04-01 20:07:38 | CRIME_WIN user=401373976431165449 earned=+391 bal=9647
2026-04-01 20:07:49 | BUY user=401373976431165449 item=echolood cost=-8000 bal=1647
2026-04-01 20:09:16 | ROB_BLOCKED robber=824516445382901800 victim=340451525799182357 fine=-118 robber_bal=891 ac_uses_left=1
2026-04-01 20:09:23 | ROB_BLOCKED robber=401373976431165449 victim=340451525799182357 fine=-175 robber_bal=1472 ac_uses_left=0
2026-04-01 20:09:52 | ROB_WIN robber=178852380018868224 victim=340451525799182357 stolen=+34868 jackpot=False robber_bal=34868 victim_bal=140238
2026-04-01 20:10:48 | DAILY user=367347301322326016 earned=+712 streak=1 bal=8462
2026-04-01 20:10:55 | WORK user=367347301322326016 earned=+25 lucky=False bal=8487
2026-04-01 20:11:00 | BEG user=367347301322326016 earned=+15 jailed=False bal=8502
2026-04-01 20:11:37 | ROB_FAIL robber=272518654715887618 victim=340451525799182357 fine=-140 robber_bal=25116
2026-04-01 20:15:19 | HEIST_FAIL user=178852380018868224 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=33868
2026-04-01 20:15:19 | HEIST_FAIL user=340451525799182357 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=139238
2026-04-01 20:15:19 | HEIST_FAIL user=272518654715887618 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=24116
2026-04-01 20:15:19 | HEIST_FAIL user=209554152584380420 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=20112
2026-04-01 20:15:19 | HEIST_FAIL user=401373976431165449 fine=-220 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=1252
2026-04-01 20:15:19 | HEIST_FAIL user=344531774518591498 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=112701
2026-04-01 20:15:19 | HEIST_FAIL user=367347301322326016 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=7502
2026-04-01 20:15:45 | JAIL_FREE user=272518654715887618 method=doubles
2026-04-01 20:20:07 | JAIL_FREE user=344531774518591498 method=doubles
2026-04-01 20:20:14 | DAILY user=344531774518591498 earned=+825 streak=1 bal=113526
2026-04-01 20:20:16 | WORK user=344531774518591498 earned=+45 lucky=False bal=113571
2026-04-01 20:20:19 | WORK user=272518654715887618 earned=+55 lucky=False bal=24171
2026-04-01 20:20:19 | BEG user=344531774518591498 earned=+22 jailed=False bal=113593
2026-04-01 20:20:36 | BLACKJACK user=272518654715887618 payout=+0 net=-24171 bal=0
2026-04-01 20:21:03 | CRIME_FAIL user=344531774518591498 fine=-90 jailed=True bal=113503
2026-04-01 20:21:11 | FISH user=272518654715887618 fish=koger weight=590 value=15
2026-04-01 20:21:45 | ROB_WIN robber=344531774518591498 victim=340451525799182357 stolen=+15566 jackpot=False robber_bal=129069 victim_bal=123672
2026-04-01 20:25:40 | BAIL_PAID user=178852380018868224 fine=-8760 pct=26% bal=25108
2026-04-01 20:28:28 | BEG user=178852380018868224 earned=+28 jailed=False bal=25136
2026-04-01 20:28:30 | WORK user=178852380018868224 earned=+92 lucky=False bal=25228
2026-04-01 20:28:33 | DAILY user=178852380018868224 earned=+825 streak=1 bal=26053
2026-04-01 20:28:38 | CRIME_WIN user=178852380018868224 earned=+640 bal=26693
2026-04-01 20:35:35 | BEG user=401373976431165449 earned=+56 jailed=True bal=1308
2026-04-01 20:36:20 | JAIL_FREE user=401373976431165449 method=doubles
2026-04-01 20:37:47 | FISH user=401373976431165449 fish=angerjas weight=989 value=79
2026-04-01 20:46:02 | DAILY user=338622999127261185 earned=+300 streak=1 bal=300
2026-04-01 20:56:03 | BEG user=344531774518591498 earned=+60 jailed=False bal=129129
2026-04-01 20:56:29 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=must mult=1 bal=128129
2026-04-01 21:01:23 | BEG user=272518654715887618 earned=+33 jailed=False bal=33
2026-04-01 21:01:42 | FISH user=272518654715887618 fish=sarj weight=151 value=6
2026-04-01 21:02:38 | FISH user=344531774518591498 fish=viidikas weight=98 value=6
2026-04-01 21:02:57 | FISH_SELL user=344531774518591498 count=2 coins=+13 bal=128142
2026-04-01 21:03:10 | BEG user=401373976431165449 earned=+54 jailed=False bal=1362
2026-04-01 21:03:31 | FISH user=401373976431165449 fish=siig weight=584 value=63
2026-04-01 21:05:19 | WORK user=401373976431165449 earned=+131 lucky=False bal=1493
2026-04-01 21:05:38 | FISH user=401373976431165449 fish=siig weight=1624 value=112
2026-04-01 21:05:48 | FISH_SELL user=401373976431165449 count=3 coins=+254 bal=1747
2026-04-01 21:08:31 | BEG user=401373976431165449 earned=+32 jailed=False bal=1779
2026-04-01 21:08:45 | FISH user=401373976431165449 fish=tougjas weight=3051 value=207
2026-04-01 21:09:01 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=2779
2026-04-01 21:09:35 | BLACKJACK user=401373976431165449 payout=+3000 net=+1500 bal=4279
2026-04-01 21:09:35 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=must mult=1 bal=127142
2026-04-01 21:10:05 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=5279
2026-04-01 21:10:46 | ROULETTE_WIN user=344531774518591498 bet=1000 colour=punane result=punane mult=1 bal=128142
2026-04-01 21:11:03 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=must result=punane mult=1 bal=4279
2026-04-01 21:11:24 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=roheline mult=1 bal=127142
2026-04-01 21:15:50 | WORK user=338622999127261185 earned=+15 lucky=False bal=315
2026-04-01 21:15:54 | CRIME_WIN user=338622999127261185 earned=+453 bal=768
2026-04-01 21:16:00 | BEG user=338622999127261185 earned=+20 jailed=False bal=788
2026-04-01 21:16:13 | FISH user=338622999127261185 fish=ahven weight=422 value=14
2026-04-01 21:18:36 | BEG user=401373976431165449 earned=+20 jailed=False bal=4299
2026-04-01 21:18:52 | FISH user=401373976431165449 fish=karpkala weight=1920 value=47
2026-04-01 21:20:58 | SLOTS_TRIPLE user=344531774518591498 bet=1000 change=4000 bal=131142
2026-04-01 21:21:39 | SLOTS_MISS user=344531774518591498 bet=1000 change=-1000 bal=130142
2026-04-01 21:28:25 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=3299
2026-04-01 21:29:10 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=130642
2026-04-01 21:29:28 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=2299
2026-04-01 21:30:49 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=131142
2026-04-01 21:31:30 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=131642
2026-04-01 21:31:33 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=3299
2026-04-01 21:31:37 | BEG user=401373976431165449 earned=+68 jailed=False bal=3367
2026-04-01 21:32:05 | FISH user=401373976431165449 fish=latikas weight=2351 value=66
2026-04-01 21:33:14 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=2367
2026-04-01 21:35:09 | SLOTS_MISS user=344531774518591498 bet=1000 change=-1000 bal=130642
2026-04-01 21:35:14 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=1367
2026-04-01 21:48:35 | ROB_FAIL robber=338622999127261185 victim=340451525799182357 fine=-237 robber_bal=551
2026-04-01 21:49:59 | BEG user=401373976431165449 earned=+56 jailed=False bal=1423
2026-04-01 21:50:15 | FISH user=401373976431165449 fish=vimb weight=856 value=462
2026-04-01 22:11:02 | BEG user=401373976431165449 earned=+52 jailed=False bal=1475
2026-04-01 22:11:13 | WORK user=401373976431165449 earned=+73 lucky=False bal=1548
2026-04-01 22:11:27 | ROB_WIN robber=401373976431165449 victim=367347301322326016 stolen=+1818 jackpot=False robber_bal=3366 victim_bal=5684
2026-04-01 22:11:44 | FISH user=401373976431165449 fish=lohe weight=2973 value=313
2026-04-01 22:13:30 | ROULETTE_LOSE user=401373976431165449 bet=2000 colour=punane result=roheline mult=1 bal=1366
2026-04-01 22:14:33 | ROULETTE_LOSE user=401373976431165449 bet=1366 colour=punane result=must mult=1 bal=0
2026-04-01 22:40:19 | WORK user=367347301322326016 earned=+82 lucky=False bal=5766
2026-04-01 22:40:23 | CRIME_FAIL user=367347301322326016 fine=-100 jailed=True bal=5666
2026-04-01 22:46:27 | WORK user=344531774518591498 earned=+116 lucky=False bal=130758
2026-04-01 22:46:30 | BEG user=344531774518591498 earned=+58 jailed=False bal=130816
2026-04-01 22:46:35 | CRIME_WIN user=344531774518591498 earned=+419 bal=131235
2026-04-01 22:46:44 | ROB_FAIL robber=344531774518591498 victim=340451525799182357 fine=-246 robber_bal=130989
2026-04-01 22:47:01 | FISH user=344531774518591498 fish=viidikas weight=80 value=5
2026-04-01 22:48:58 | WORK user=178852380018868224 earned=+106 lucky=True bal=26799
2026-04-01 22:49:03 | CRIME_WIN user=178852380018868224 earned=+486 bal=27285
2026-04-01 22:49:05 | BEG user=178852380018868224 earned=+76 jailed=False bal=27361
2026-04-01 22:52:25 | BEG user=401373976431165449 earned=+44 jailed=False bal=44
2026-04-01 22:52:29 | WORK user=401373976431165449 earned=+103 lucky=False bal=147
2026-04-01 22:52:39 | CRIME_FAIL user=401373976431165449 fine=-125 jailed=False bal=22
2026-04-01 22:53:01 | FISH user=401373976431165449 fish=latikas weight=1217 value=40
2026-04-01 22:57:11 | SLOTS_PAIR user=344531774518591498 bet=10000 change=5000 bal=135989

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -23,12 +23,12 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
# Ensure the project root is on sys.path so pb_client can be imported # Ensure the project root is on sys.path so core modules can be imported
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
load_dotenv() load_dotenv()
import pb_client # noqa: E402 (needs dotenv loaded first) from core import pb_client # noqa: E402 (needs dotenv loaded first)
DATA_FILE = Path("data") / "economy.json" DATA_FILE = Path("data") / "economy.json"

View File

@@ -171,6 +171,7 @@ CMD: dict[str, str] = {
"fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)", "fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)",
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike", "fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
"fishsell": "Müü kalu oma inventarist", "fishsell": "Müü kalu oma inventarist",
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -752,6 +753,20 @@ SEND_UI: dict[str, str] = {
"forbidden": "❌ Mul pole õigust kanalisse {channel} kirjutada.", "forbidden": "❌ Mul pole õigust kanalisse {channel} kirjutada.",
} }
# ---------------------------------------------------------------------------
# /patchnotes UI strings
# ---------------------------------------------------------------------------
PATCHNOTES_UI: dict[str, str] = {
"title": "📝 Muudatuste logi — {version}",
"footer": "Versioon {idx}/{total}",
"btn_newer": "◀ Uuem",
"btn_older": "Vanem ▶",
"select_placeholder": "Vali versioon…",
"empty_file": " Muudatuste logi on hetkel tühi.",
"empty_version": "_(selle versiooni kohta märkmeid pole)_",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /allowchannel /denychannel /channels UI strings # /allowchannel /denychannel /channels UI strings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------