1
0
forked from sass/tipibot

7 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
9 changed files with 268 additions and 132 deletions

View File

@@ -1,14 +0,0 @@
.git
.gitignore
.env
.env.*
*.pyc
__pycache__
data/
logs/
*.log
*.md
.dockerignore
Dockerfile
compose.yaml
credentials.json

View File

@@ -1,15 +0,0 @@
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"]

3
bot.py
View File

@@ -35,6 +35,7 @@ from commands.economy_profile_commands import register_economy_profile_commands
from commands.economy_support_commands import register_economy_support_commands from commands.economy_support_commands import register_economy_support_commands
from commands.ops_channel_commands import register_ops_channel_commands from commands.ops_channel_commands import register_ops_channel_commands
from commands.ops_admin_commands import register_ops_admin_commands from commands.ops_admin_commands import register_ops_admin_commands
from commands.info_commands import register_info_commands
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Logging # Logging
@@ -483,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):

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

@@ -1,33 +0,0 @@
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
volumes:
- ./data:/app/data
- ./logs:/app/logs
- ./credentials.json:/app/credentials.json:ro
volumes:
pb_data:

View File

@@ -6,6 +6,7 @@ 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
@@ -15,17 +16,13 @@ from typing import TypedDict
import aiohttp import aiohttp
from . import pb_client from . import pb_client
from .pb_client import DatabaseError
import strings import strings
_txn_log = logging.getLogger("tipiCOIN.txn") _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: def _txn(event: str, **fields) -> None:
"""Log a single economy transaction to the transactions logger.""" """Log a single economy transaction to the transactions logger."""
body = " ".join(f"{k}={v}" for k, v in fields.items()) body = " ".join(f"{k}={v}" for k, v in fields.items())
@@ -320,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:
@@ -689,18 +686,19 @@ 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:
try:
record_id = user.get("_pb_id") # type: ignore[typeddict-item] record_id = user.get("_pb_id") # type: ignore[typeddict-item]
clean = {k: v for k, v in user.items() if k != "_pb_id"} clean = {k: v for k, v in user.items() if k != "_pb_id"}
clean["user_id"] = str(user_id) clean["user_id"] = str(user_id)
try:
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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -951,9 +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:
valid_indices = [i if i >= 0 else len(inv) + i for i in indices] sell_idx = {
to_sell = [inv[i] for i in sorted(set(valid_indices)) if 0 <= i < len(inv)] (i if i >= 0 else len(inv) + i)
keep_idx = set(range(len(inv))) - set(indices) 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:

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
@@ -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()
try:
async with session.post( async with session.post(
f"{PB_URL}/api/collections/_superusers/auth-with-password", f"{PB_URL}/api/collections/_superusers/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
text = await resp.text() text = await resp.text()
raise RuntimeError(f"PocketBase auth failed ({resp.status}): {text}") raise DatabaseError(f"PocketBase auth failed ({resp.status}): {text}")
data = await resp.json() data = await resp.json()
_token = data["token"] _token = data["token"]
_token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry _token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry
_log.debug("PocketBase admin token refreshed") _log.debug("PocketBase admin token refreshed")
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
raise DatabaseError(f"Database unavailable: {e}") from e
return _token return _token
@@ -75,60 +83,77 @@ 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:
resp.raise_for_status()
data = await resp.json()
items = data.get("items", []) items = data.get("items", [])
return items[0] if items else None 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:
resp.raise_for_status()
data = await resp.json()
return int(data.get("totalItems", 0)) return int(data.get("totalItems", 0))
@@ -136,19 +161,14 @@ 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:
resp.raise_for_status()
data = await resp.json()
batch = data.get("items", []) batch = data.get("items", [])
results.extend(batch) results.extend(batch)
if len(batch) < page_size: if len(batch) < page_size:
break
page += 1
return results return results
page += 1

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)

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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------