1
0
forked from sass/tipibot

More bug fixes

This commit is contained in:
Rene Arumetsa
2026-05-03 15:11:32 +03:00
parent d65173fbe9
commit 07e7f5e0b2
9 changed files with 295 additions and 90 deletions

2
bot.py
View File

@@ -386,7 +386,7 @@ async def on_ready():
# Pull sheet data into cache
if IS_DEV_PROFILE:
try:
data = sheets.refresh()
data = await sheets.refresh()
log.info("Loaded %d member rows from Google Sheets", len(data))
except Exception as e:
log.error("Failed to load sheet on startup: %s", e)

View File

@@ -157,7 +157,7 @@ def register_dev_member_commands(
await interaction.response.defer()
try:
sheets.refresh()
await sheets.refresh()
except Exception as e:
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
return
@@ -177,7 +177,7 @@ def register_dev_member_commands(
return
try:
data = sheets.refresh()
data = await sheets.refresh()
except Exception as e:
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
return
@@ -195,7 +195,7 @@ def register_dev_member_commands(
guild.members,
)
if guild_member:
sheets.set_user_id(discord_name, guild_member.id)
await sheets.set_user_id(discord_name, guild_member.id)
ids_filled += 1
data = sheets.get_cache()
@@ -243,7 +243,7 @@ def register_dev_member_commands(
if sync_updates:
try:
sheets.batch_set_synced(sync_updates)
await sheets.batch_set_synced(sync_updates)
except Exception as e:
log.error("/check batch_set_synced failed: %s", e)

View File

@@ -23,7 +23,7 @@ async def run_birthday_daily(
return
try:
data = sheets.refresh()
data = await sheets.refresh()
except Exception as e:
log.error("Birthday task: sheet refresh failed: %s", e)
data = sheets.get_cache()
@@ -68,13 +68,13 @@ async def handle_member_join(
log.info("Member joined: %s (ID: %s)", member, member.id)
if not sheets.get_cache():
sheets.refresh()
await sheets.refresh()
result = await sync_member(member, member.guild)
if result.not_found:
try:
sheets.add_new_member_row(member.name, member.id)
await sheets.add_new_member_row(member.name, member.id)
log.info(
"%s not in sheet, added new row (Discord=%s, ID=%s)",
member,
@@ -86,7 +86,7 @@ async def handle_member_join(
return
log_sync_result(member, result)
sheets.set_synced(member.id, result.synced)
await sheets.set_synced(member.id, result.synced)
if result.birthday_soon and not has_announced_today(member.id):
await announce_birthday(member, bot)

View File

@@ -276,18 +276,16 @@ def register_economy_games_commands(
bet_line_a = bet_line_b = ""
if self.bet > 0:
if winner == "a":
res = await economy.do_give(self.player_b.id, self.player_a.id, self.bet)
await economy.do_rps_pvp_payout(self.player_a.id, self.bet)
bet_line_a = f"\n+{coin(self.bet)}"
bet_line_b = f"\n-{coin(self.bet)}"
elif winner == "b":
res = await economy.do_give(self.player_a.id, self.player_b.id, self.bet)
await economy.do_rps_pvp_payout(self.player_b.id, self.bet)
bet_line_a = f"\n-{coin(self.bet)}"
bet_line_b = f"\n+{coin(self.bet)}"
else:
res = {"ok": True}
if self.bet > 0 and winner is not None:
if res.get("ok"):
bet_line_a = f"\n{'+' if winner == 'a' else '-'}{coin(self.bet)}"
bet_line_b = f"\n{'+' if winner == 'b' else '-'}{coin(self.bet)}"
else:
bet_line_a = bet_line_b = S.RPS_UI["duel_broke"]
await economy.do_rps_pvp_refund(self.player_a.id, self.bet)
await economy.do_rps_pvp_refund(self.player_b.id, self.bet)
data_a = await economy.get_user(self.player_a.id)
data_b = await economy.get_user(self.player_b.id)
@@ -375,6 +373,9 @@ def register_economy_games_commands(
if self.game._resolved:
return
self.game._resolved = True
if self.game.bet > 0:
await economy.do_rps_pvp_refund(self.game.player_a.id, self.game.bet)
await economy.do_rps_pvp_refund(self.game.player_b.id, self.game.bet)
active_games.discard(self.game.player_a.id)
active_games.discard(self.game.player_b.id)
for item in self.children:
@@ -431,13 +432,25 @@ def register_economy_games_commands(
active_games.add(self.game.player_b.id)
if self.game.bet > 0:
data_a = await economy.get_user(self.game.player_a.id)
data_b = await economy.get_user(self.game.player_b.id)
for player, data in ((self.game.player_a, data_a), (self.game.player_b, data_b)):
if data["balance"] < self.game.bet:
deposit_a = await economy.do_rps_pvp_deposit(self.game.player_a.id, self.game.bet)
if not deposit_a.get("ok"):
embed = discord.Embed(
title=S.TITLE["rps_duel_cancel"],
description=S.RPS_UI["duel_insufficient"].format(mention=player.mention),
description=S.RPS_UI["duel_insufficient"].format(mention=self.game.player_a.mention),
color=0xED4245,
)
await interaction.response.edit_message(embed=embed, view=None)
async with self.game._lock:
self.game._resolved = True
active_games.discard(self.game.player_a.id)
active_games.discard(self.game.player_b.id)
return
deposit_b = await economy.do_rps_pvp_deposit(self.game.player_b.id, self.game.bet)
if not deposit_b.get("ok"):
await economy.do_rps_pvp_refund(self.game.player_a.id, self.game.bet)
embed = discord.Embed(
title=S.TITLE["rps_duel_cancel"],
description=S.RPS_UI["duel_insufficient"].format(mention=self.game.player_b.mention),
color=0xED4245,
)
await interaction.response.edit_message(embed=embed, view=None)
@@ -479,6 +492,9 @@ def register_economy_games_commands(
if dm_failed:
async with self.game._lock:
self.game._resolved = True
if self.game.bet > 0:
await economy.do_rps_pvp_refund(self.game.player_a.id, self.game.bet)
await economy.do_rps_pvp_refund(self.game.player_b.id, self.game.bet)
active_games.discard(self.game.player_a.id)
active_games.discard(self.game.player_b.id)
embed = discord.Embed(

View File

@@ -1306,12 +1306,32 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
pct = random.uniform(0.10, 0.25)
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
target["balance"] -= stolen
prev_lifetime_earned = robber.get("lifetime_earned", 0)
prev_biggest_win = robber.get("biggest_win", 0)
prev_peak_balance = robber.get("peak_balance", 0)
robber["balance"] += stolen
robber["lifetime_earned"] = robber.get("lifetime_earned", 0) + stolen
robber["biggest_win"] = max(robber.get("biggest_win", 0), stolen)
robber["peak_balance"] = max(robber.get("peak_balance", 0), robber["balance"])
robber["lifetime_earned"] = prev_lifetime_earned + stolen
robber["biggest_win"] = max(prev_biggest_win, stolen)
robber["peak_balance"] = max(prev_peak_balance, robber["balance"])
try:
await _commit(robber_id, robber)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
try:
await _commit(target_id, target)
except DatabaseError:
robber["balance"] -= stolen
robber["lifetime_earned"] = prev_lifetime_earned
robber["biggest_win"] = prev_biggest_win
robber["peak_balance"] = prev_peak_balance
try:
await _commit(robber_id, robber)
except DatabaseError as exc2:
_log.critical(
"do_rob rollback failed for robber %s after target commit failed: %s",
robber_id, exc2,
)
return {"ok": False, "reason": "db_error"}
_txn("ROB_WIN", robber=robber_id, victim=target_id, stolen=f"+{stolen}", jackpot=jackpot, robber_bal=robber["balance"], victim_bal=target["balance"])
return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot}
else:
@@ -1400,6 +1420,66 @@ async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
return {"ok": True, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /rps PvP escrow (deposit/payout/refund)
# ---------------------------------------------------------------------------
async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
"""Hold `bet` coins from a player as escrow for a PvP RPS duel."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
user["balance"] -= bet
user["total_wagered"] = user.get("total_wagered", 0) + bet
try:
await _commit(user_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("RPS_PVP_DEPOSIT", user=user_id, bet=bet, bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
"""Credit the duel winner with 2*bet (their stake back + opponent's)."""
try:
user = await get_user(winner_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
payout = bet * 2
user["balance"] = user.get("balance", 0) + payout
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
try:
await _commit(winner_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("RPS_PVP_PAYOUT", user=winner_id, payout=f"+{payout}", bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
user["balance"] = user.get("balance", 0) + bet
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
try:
await _commit(user_id, user)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("RPS_PVP_REFUND", user=user_id, bet=bet, bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /slots
# ---------------------------------------------------------------------------
@@ -1505,8 +1585,23 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
receiver["balance"] += amount
giver["total_given"] = giver.get("total_given", 0) + amount
receiver["total_received"] = receiver.get("total_received", 0) + amount
try:
await _commit(giver_id, giver)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
try:
await _commit(receiver_id, receiver)
except DatabaseError:
giver["balance"] += amount
giver["total_given"] = max(0, giver.get("total_given", 0) - amount)
try:
await _commit(giver_id, giver)
except DatabaseError as exc2:
_log.critical(
"do_give rollback failed for giver %s after receiver commit failed: %s",
giver_id, exc2,
)
return {"ok": False, "reason": "db_error"}
_txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"])
return {
@@ -1760,23 +1855,41 @@ async def do_heist_check(user_id: int) -> dict:
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
"""Apply heist outcome to all participants. On win, steals from house."""
"""Apply heist outcome to all participants. On win, steals from house.
Per-user commit failures attempt to compensate the house so the economy
stays balanced. If compensation also fails, a CRITICAL log is emitted.
"""
now = _now()
payout_each = 0
failed_users: list[int] = []
if success and HOUSE_ID is not None:
try:
house = await get_user(HOUSE_ID)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
pct = random.uniform(0.20, 0.55)
total = max(300, int(house["balance"] * pct))
payout_each = total // len(user_ids)
house["balance"] = max(0, house["balance"] - total)
try:
await _commit(HOUSE_ID, house)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"])
for uid in user_ids:
try:
user = await get_user(uid)
except DatabaseError:
failed_users.append(uid)
if success and payout_each > 0:
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
continue
user["last_heist"] = now.isoformat()
user["heists_joined"] = user.get("heists_joined", 0) + 1
fine_credited = False
if success:
user["balance"] += payout_each
user["heists_won"] = user.get("heists_won", 0) + 1
@@ -1792,7 +1905,51 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
if fine > 0:
try:
await _credit_house(fine)
fine_credited = True
except DatabaseError:
pass # user commit will still be attempted; if both fail, no economy effect
try:
await _commit(uid, user)
except DatabaseError:
failed_users.append(uid)
if success and payout_each > 0:
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
elif not success and fine_credited:
await _refund_user_safe(HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
return {"ok": True, "payout_each": payout_each, "success": success}
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
async def _refund_house_safe(amount: int, context: str, related_uid: int) -> None:
"""Best-effort refund of `amount` to the house. Logs critical if it fails."""
if HOUSE_ID is None or amount <= 0:
return
try:
house = await get_user(HOUSE_ID)
house["balance"] = house.get("balance", 0) + amount
await _commit(HOUSE_ID, house)
except DatabaseError as exc:
_log.critical(
"House compensation failed (%s, related uid %s, amount %s): %s",
context, related_uid, amount, exc,
)
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
"""Best-effort debit of `amount` from the house (compensates a failed user fine).
Reads house, subtracts amount, commits. Logs critical if it fails.
"""
if HOUSE_ID is None or amount <= 0:
return
try:
house = await get_user(HOUSE_ID)
house["balance"] = max(0, house.get("balance", 0) - amount)
await _commit(HOUSE_ID, house)
except DatabaseError as exc:
_log.critical(
"House debit compensation failed (%s, related uid %s, amount %s): %s",
context, uid, amount, exc,
)

View File

@@ -126,12 +126,12 @@ async def sync_member(
# --- Backfill User ID if missing ---
raw_id = str(row.get("User ID", "")).strip()
if not raw_id or raw_id == "0":
sheets.set_user_id(member.name, member.id)
await sheets.set_user_id(member.name, member.id)
# --- Update Discord username in sheet if it changed ---
sheet_username = str(row.get("Discord", "")).strip()
if sheet_username.lower() != member.name.lower():
sheets.update_username(member.id, member.name)
await sheets.update_username(member.id, member.name)
# --- Nickname (Nimi = real name, formatted as first name + last initial) ---
nimi = str(row.get("Nimi", "")).strip()

View File

@@ -1,4 +1,12 @@
"""Google Sheets integration - read/write member data via gspread."""
"""Google Sheets integration - read/write member data via gspread.
Public network-hitting functions are async and delegate the blocking gspread
work to `asyncio.to_thread` so the discord.py event loop is not stalled
(stalled loops drop gateway heartbeats and can disconnect the bot).
Pure-cache helpers (get_cache, find_*) remain sync.
"""
import asyncio
import gspread
from google.oauth2.service_account import Credentials
@@ -69,11 +77,7 @@ def _ensure_headers(ws: gspread.Worksheet) -> None:
ws.update_cell(1, col_idx, header)
def refresh() -> list[dict]:
"""Pull all rows from the sheet into the in-memory cache.
Returns the cache (list of dicts keyed by header names).
"""
def _refresh_sync() -> list[dict]:
global _cache
ws = _get_worksheet()
_ensure_headers(ws)
@@ -83,6 +87,11 @@ def refresh() -> list[dict]:
return _cache
async def refresh() -> list[dict]:
"""Pull all rows from the sheet into the in-memory cache (non-blocking)."""
return await asyncio.to_thread(_refresh_sync)
def get_cache() -> list[dict]:
"""Return the current in-memory cache without re-querying."""
return _cache
@@ -122,16 +131,12 @@ def _row_index_for_member(discord_id: int | None = None, username: str | None =
return None
def update_cell_for_member(
def _update_cell_for_member_sync(
discord_id: int | None,
username: str | None,
column_name: str,
value: str,
) -> bool:
"""Write a value to a specific column for a member row.
Returns True if the write succeeded.
"""
ws = _worksheet or _get_worksheet()
row_idx = _row_index_for_member(discord_id=discord_id, username=username)
if row_idx is None:
@@ -145,7 +150,6 @@ def update_cell_for_member(
ws.update([[value]], gspread.utils.rowcol_to_a1(row_idx, col_idx),
value_input_option="USER_ENTERED")
# Keep cache in sync
cache_idx = row_idx - 3
if 0 <= cache_idx < len(_cache):
_cache[cache_idx][column_name] = value
@@ -153,8 +157,19 @@ def update_cell_for_member(
return True
def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
"""Batch-write 'Discordis synced?' for multiple members in a single API call."""
async def update_cell_for_member(
discord_id: int | None,
username: str | None,
column_name: str,
value: str,
) -> bool:
"""Write a value to a specific column for a member row (non-blocking)."""
return await asyncio.to_thread(
_update_cell_for_member_sync, discord_id, username, column_name, value
)
def _batch_set_synced_sync(updates: list[tuple[int, bool]]) -> None:
ws = _worksheet or _get_worksheet()
col_idx = EXPECTED_HEADERS.index("Discordis synced?") + 1
cells = []
@@ -170,9 +185,14 @@ def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
ws.update_cells(cells, value_input_option="USER_ENTERED")
def set_user_id(username: str, discord_id: int) -> bool:
async def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
"""Batch-write 'Discordis synced?' for multiple members (non-blocking)."""
await asyncio.to_thread(_batch_set_synced_sync, updates)
async def set_user_id(username: str, discord_id: int) -> bool:
"""Write a Discord user ID for a row matched by Discord username."""
return update_cell_for_member(
return await update_cell_for_member(
discord_id=None,
username=username,
column_name="User ID",
@@ -180,9 +200,9 @@ def set_user_id(username: str, discord_id: int) -> bool:
)
def set_synced(discord_id: int, synced: bool) -> bool:
async def set_synced(discord_id: int, synced: bool) -> bool:
"""Mark a member as synced (TRUE) or not (FALSE)."""
return update_cell_for_member(
return await update_cell_for_member(
discord_id=discord_id,
username=None,
column_name="Discordis synced?",
@@ -190,9 +210,9 @@ def set_synced(discord_id: int, synced: bool) -> bool:
)
def update_username(discord_id: int, new_username: str) -> bool:
async def update_username(discord_id: int, new_username: str) -> bool:
"""Update the Discord column for a member (keeps sheet in sync with Discord)."""
return update_cell_for_member(
return await update_cell_for_member(
discord_id=discord_id,
username=None,
column_name="Discord",
@@ -200,17 +220,17 @@ def update_username(discord_id: int, new_username: str) -> bool:
)
def add_new_member_row(username: str, discord_id: int) -> None:
"""Append a new row to the sheet with Discord username and User ID pre-filled.
All other columns are left empty for manual entry by an admin.
"""
def _add_new_member_row_sync(username: str, discord_id: int) -> None:
ws = _worksheet or _get_worksheet()
row = [""] * len(EXPECTED_HEADERS)
row[EXPECTED_HEADERS.index("Discord")] = username
row[EXPECTED_HEADERS.index("User ID")] = str(discord_id)
row[EXPECTED_HEADERS.index("Discordis synced?")] = "FALSE"
ws.append_row(row, value_input_option="USER_ENTERED")
# Add to local cache so subsequent find_member() calls work in the same session
new_entry = {h: row[i] for i, h in enumerate(EXPECTED_HEADERS)}
_cache.append(new_entry)
async def add_new_member_row(username: str, discord_id: int) -> None:
"""Append a new row pre-filled with Discord username and User ID (non-blocking)."""
await asyncio.to_thread(_add_new_member_row_sync, username, discord_id)

View File

@@ -8,3 +8,7 @@ Format each version with a `## ` header (e.g. `## 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)
- Fixed RPS PvP duels having no bet escrow — bets are now held when the duel is accepted, paid out to the winner, and refunded on tie / timeout / cancel (previously the loser could spend their balance before the duel resolved and the winner would get nothing)
- Fixed multi-party transfers leaving coins in limbo on partial failures — `/give` and `/rob` now roll back the first commit if the second fails; heists try to refund the house when a participant payout fails
- Fixed Google Sheets I/O blocking the Discord gateway — sheet reads/writes now run in a worker thread so heartbeats stay alive during slow API calls
- Fixed migration script overwriting accumulated PocketBase state on re-run — fields not present in the legacy JSON are now preserved instead of being clobbered with defaults

View File

@@ -42,10 +42,23 @@ async def main() -> None:
total = len(raw)
print(f"Found {total} user(s) in {DATA_FILE}")
created = skipped = errors = 0
created = updated = errors = 0
for uid, user in raw.items():
try:
existing = await pb_client.get_record(uid)
if existing:
# Merge JSON fields *onto* the existing record so values that have
# accumulated in PB (items, daily_streak, reminders, etc.) are not
# clobbered by JSON defaults on a re-run. JSON values take
# precedence only for keys that are actually present.
merged: dict = {k: v for k, v in existing.items() if not k.startswith("_") and k != "id"}
merged.update(user)
merged["user_id"] = uid
await pb_client.update_record(existing["id"], merged)
print(f" [UPDATE] {uid}")
updated += 1
else:
record = dict(user)
record["user_id"] = uid
record.setdefault("balance", 0)
@@ -55,13 +68,6 @@ async def main() -> None:
record.setdefault("reminders", ["daily", "work", "beg", "crime", "rob"])
record.setdefault("eco_banned", False)
record.setdefault("daily_streak", 0)
existing = await pb_client.get_record(uid)
if existing:
await pb_client.update_record(existing["id"], record)
print(f" [UPDATE] {uid}")
skipped += 1 # reuse skipped counter as "updated"
else:
await pb_client.create_record(record)
print(f" [CREATE] {uid}")
created += 1
@@ -69,7 +75,9 @@ async def main() -> None:
print(f" [ERROR] {uid}: {exc}")
errors += 1
print(f"\nDone. Created: {created} Skipped: {skipped} Errors: {errors}")
print(f"\nDone. Created: {created} Updated: {updated} Errors: {errors}")
if errors:
sys.exit(1)
if __name__ == "__main__":