forked from sass/tipibot
Compare commits
7 Commits
fienta
...
2c2621d24e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c2621d24e | ||
|
|
192888625e | ||
|
|
6d344a47f4 | ||
|
|
d65173fbe9 | ||
|
|
58684d5f34 | ||
|
|
8529706809 | ||
|
|
173e2564f1 |
@@ -1,14 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
*.pyc
|
||||
__pycache__
|
||||
data/
|
||||
logs/
|
||||
*.log
|
||||
*.md
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
compose.yaml
|
||||
credentials.json
|
||||
15
Dockerfile
15
Dockerfile
@@ -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
3
bot.py
@@ -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.ops_channel_commands import register_ops_channel_commands
|
||||
from commands.ops_admin_commands import register_ops_admin_commands
|
||||
from commands.info_commands import register_info_commands
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -483,6 +484,8 @@ register_ops_channel_commands(
|
||||
set_allowed_channels=_set_allowed_channels,
|
||||
)
|
||||
|
||||
register_info_commands(tree, bot, log)
|
||||
|
||||
|
||||
@tree.command(name="ping", description=S.CMD["ping"])
|
||||
async def cmd_ping(interaction: discord.Interaction):
|
||||
|
||||
148
commands/info_commands.py
Normal file
148
commands/info_commands.py
Normal 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))
|
||||
33
compose.yaml
33
compose.yaml
@@ -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:
|
||||
@@ -6,6 +6,7 @@ All public async functions are the single source of truth for mutations.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
@@ -15,17 +16,13 @@ from typing import TypedDict
|
||||
import aiohttp
|
||||
|
||||
from . import pb_client
|
||||
from .pb_client import DatabaseError
|
||||
|
||||
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())
|
||||
@@ -320,16 +317,16 @@ LEVEL_ROLES: list[tuple[int, str]] = [
|
||||
|
||||
|
||||
def get_level(exp: int) -> int:
|
||||
"""Level = max(1, floor(sqrt(exp/10))).
|
||||
Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000."""
|
||||
return max(1, int(math.sqrt(max(0, exp) / 10)))
|
||||
"""Level = max(1, floor(sqrt(exp/6))).
|
||||
Level 5 @ 150 EXP, 10 @ 600, 20 @ 2400, 30 @ 5400."""
|
||||
return max(1, int(math.sqrt(max(0, exp) / 6)))
|
||||
|
||||
|
||||
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:
|
||||
return 0
|
||||
return level * level * 10
|
||||
return level * level * 6
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
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:
|
||||
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:
|
||||
await pb_client.update_record(record_id, clean)
|
||||
else:
|
||||
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
|
||||
created = await pb_client.create_record(clean)
|
||||
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
|
||||
except Exception as exc:
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
|
||||
_log.error("_commit failed for user %s: %s", user_id, exc)
|
||||
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -951,9 +949,13 @@ async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict:
|
||||
to_sell = inv
|
||||
remaining = []
|
||||
else:
|
||||
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)
|
||||
sell_idx = {
|
||||
(i if i >= 0 else len(inv) + i)
|
||||
for i in indices
|
||||
}
|
||||
sell_idx = {i for i in sell_idx if 0 <= i < len(inv)}
|
||||
to_sell = [inv[i] for i in sorted(sell_idx)]
|
||||
keep_idx = set(range(len(inv))) - sell_idx
|
||||
remaining = [inv[i] for i in sorted(keep_idx)]
|
||||
|
||||
if not to_sell:
|
||||
|
||||
@@ -21,6 +21,11 @@ import aiohttp
|
||||
|
||||
import config
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""Raised when PocketBase is unreachable or returns an error."""
|
||||
pass
|
||||
|
||||
_log = logging.getLogger("tipiCOIN.pb")
|
||||
|
||||
PB_URL = config.PB_URL
|
||||
@@ -57,17 +62,20 @@ async def _ensure_auth() -> str:
|
||||
if time.monotonic() < _token_expiry:
|
||||
return _token
|
||||
session = _get_session()
|
||||
async with session.post(
|
||||
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise RuntimeError(f"PocketBase auth failed ({resp.status}): {text}")
|
||||
data = await resp.json()
|
||||
_token = data["token"]
|
||||
_token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry
|
||||
_log.debug("PocketBase admin token refreshed")
|
||||
try:
|
||||
async with session.post(
|
||||
f"{PB_URL}/api/collections/_superusers/auth-with-password",
|
||||
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise DatabaseError(f"PocketBase auth failed ({resp.status}): {text}")
|
||||
data = await resp.json()
|
||||
_token = data["token"]
|
||||
_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
|
||||
|
||||
|
||||
@@ -75,80 +83,92 @@ async def _hdrs() -> dict[str, str]:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_record(user_id: str) -> dict[str, Any] | None:
|
||||
"""Fetch one economy record by Discord user_id. Returns None if not found."""
|
||||
session = _get_session()
|
||||
async with session.get(
|
||||
data = await _request(
|
||||
"GET",
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||
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", [])
|
||||
return items[0] if items else None
|
||||
)
|
||||
items = data.get("items", [])
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
async def create_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a new economy record. Returns the created record (includes PB id)."""
|
||||
session = _get_session()
|
||||
async with session.post(
|
||||
return await _request(
|
||||
"POST",
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||
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]:
|
||||
"""PATCH an existing record by its PocketBase record id."""
|
||||
session = _get_session()
|
||||
async with session.patch(
|
||||
return await _request(
|
||||
"PATCH",
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}",
|
||||
json=data,
|
||||
headers=await _hdrs(),
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
)
|
||||
|
||||
|
||||
async def count_records() -> int:
|
||||
"""Return the total number of records in the collection (single cheap request)."""
|
||||
session = _get_session()
|
||||
async with session.get(
|
||||
data = await _request(
|
||||
"GET",
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||
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))
|
||||
|
||||
|
||||
async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]:
|
||||
"""Fetch every record in the collection, handling PocketBase pagination."""
|
||||
results: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
session = _get_session()
|
||||
hdrs = await _hdrs()
|
||||
while True:
|
||||
async with session.get(
|
||||
data = await _request(
|
||||
"GET",
|
||||
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
|
||||
params={"perPage": page_size, "page": page},
|
||||
headers=hdrs,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
batch = data.get("items", [])
|
||||
results.extend(batch)
|
||||
if len(batch) < page_size:
|
||||
break
|
||||
page += 1
|
||||
return results
|
||||
)
|
||||
batch = data.get("items", [])
|
||||
results.extend(batch)
|
||||
if len(batch) < page_size:
|
||||
return results
|
||||
page += 1
|
||||
|
||||
10
docs/PATCHNOTES.md
Normal file
10
docs/PATCHNOTES.md
Normal 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)
|
||||
15
strings.py
15
strings.py
@@ -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",
|
||||
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -752,6 +753,20 @@ SEND_UI: dict[str, str] = {
|
||||
"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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user