#!/usr/bin/env python3 """ Migrate data from SQLite databases to PostgreSQL. Usage (from repo root): docker compose run --rm bot python3 scripts/migrate_sqlite_to_pg.py Or locally (PostgreSQL must be reachable): DATABASE_URL=postgresql://bot:botpass@localhost:5432/botdb python3 scripts/migrate_sqlite_to_pg.py SQLite sources expected in ./db/: economy.sqlite3 → user_balances, deposits, loans, transactions, taxes, economy_settings, daily_activity, central_bank, penis_stats bets.sqlite3 → bets chat_history.sqlite3→ chat_history, chat_state, talk_history, talk_state penis_stats.sqlite3 → (legacy) length column → user_balances.balance + penis_stats """ import os import sqlite3 import sys from pathlib import Path import psycopg2 import psycopg2.extras DB_DIR = Path(os.getenv("SQLITE_DIR", "/db")) DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb") ECONOMY_DB = DB_DIR / "economy.sqlite3" BETS_DB = DB_DIR / "bets.sqlite3" CHAT_DB = DB_DIR / "chat_history.sqlite3" PENIS_LEGACY_DB = DB_DIR / "penis_stats.sqlite3" def open_sqlite(path: Path) -> sqlite3.Connection | None: if not path.exists(): print(f" [skip] {path} not found") return None conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row return conn def migrate_economy(pg: psycopg2.extensions.connection) -> None: src = open_sqlite(ECONOMY_DB) if src is None: return cur = pg.cursor() # --- user_balances --- rows = src.execute("SELECT * FROM user_balances").fetchall() print(f" user_balances: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset, created_at) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (user_id) DO UPDATE SET balance = EXCLUDED.balance, daily_income = EXCLUDED.daily_income, last_daily_reset = EXCLUDED.last_daily_reset """, ( r["user_id"], r["balance"], r["daily_income"], r["last_daily_reset"] or None, r["created_at"] or None, )) # --- deposits --- rows = src.execute("SELECT * FROM deposits").fetchall() print(f" deposits: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO deposits (id, user_id, amount, interest_rate, created_at, matures_at, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING """, ( r["id"], r["user_id"], r["amount"], r["interest_rate"], r["created_at"] or None, r["matures_at"] or None, bool(r["is_active"]), )) _reset_sequence(cur, "deposits", "id") # --- loans --- rows = src.execute("SELECT * FROM loans").fetchall() print(f" loans: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO loans (id, user_id, amount, interest_rate, created_at, due_at, is_repaid) VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING """, ( r["id"], r["user_id"], r["amount"], r["interest_rate"], r["created_at"] or None, r["due_at"] or None, bool(r["is_repaid"]), )) _reset_sequence(cur, "loans", "id") # --- transactions --- rows = src.execute("SELECT * FROM transactions").fetchall() print(f" transactions: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO transactions (id, from_user_id, to_user_id, amount, transaction_type, description, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING """, ( r["id"], r["from_user_id"], r["to_user_id"], r["amount"], r["transaction_type"], r["description"], r["created_at"] or None, )) _reset_sequence(cur, "transactions", "id") # --- taxes --- rows = src.execute("SELECT * FROM taxes").fetchall() print(f" taxes: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO taxes (id, user_id, amount, tax_date, created_at) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING """, ( r["id"], r["user_id"], r["amount"], r["tax_date"] or None, r["created_at"] or None, )) _reset_sequence(cur, "taxes", "id") # --- economy_settings --- rows = src.execute("SELECT * FROM economy_settings").fetchall() print(f" economy_settings: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO economy_settings (key, value, updated_at) VALUES (%s, %s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (r["key"], r["value"], r["updated_at"] if "updated_at" in r.keys() else None)) # --- daily_activity --- rows = src.execute("SELECT * FROM daily_activity").fetchall() print(f" daily_activity: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO daily_activity (id, user_id, activity_date, message_count, last_activity_time, created_at) VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (user_id, activity_date) DO UPDATE SET message_count = EXCLUDED.message_count, last_activity_time = EXCLUDED.last_activity_time """, ( r["id"], r["user_id"], r["activity_date"] or None, r["message_count"], r["last_activity_time"] or None, r["created_at"] or None, )) _reset_sequence(cur, "daily_activity", "id") # --- central_bank --- rows = src.execute("SELECT * FROM central_bank").fetchall() print(f" central_bank: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO central_bank (id, capital, total_taxes_collected, total_inflation_adjustments, total_emissions, last_updated) VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO UPDATE SET capital = EXCLUDED.capital, total_taxes_collected = EXCLUDED.total_taxes_collected, total_inflation_adjustments = EXCLUDED.total_inflation_adjustments, total_emissions = EXCLUDED.total_emissions, last_updated = EXCLUDED.last_updated """, ( r["id"], r["capital"], r["total_taxes_collected"], r["total_inflation_adjustments"], r["total_emissions"], r["last_updated"] or None, )) # --- penis_stats (new schema: user_id, display_name, last_used_ts) --- try: rows = src.execute("SELECT * FROM penis_stats").fetchall() print(f" penis_stats: {len(rows)} rows") cols = rows[0].keys() if rows else [] for r in rows: if "last_used_ts" in cols: cur.execute(""" INSERT INTO penis_stats (user_id, display_name, last_used_ts) VALUES (%s, %s, %s) ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name, last_used_ts = EXCLUDED.last_used_ts """, (r["user_id"], r["display_name"], r["last_used_ts"])) else: # old schema without last_used_ts cur.execute(""" INSERT INTO penis_stats (user_id, display_name) VALUES (%s, %s) ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name """, (r["user_id"], r.get("display_name", ""))) except sqlite3.OperationalError as e: print(f" [warn] penis_stats in economy.sqlite3: {e}") src.close() pg.commit() print(" economy.sqlite3 done") def migrate_legacy_penis(pg: psycopg2.extensions.connection) -> None: """Old penis_stats.sqlite3 had a `length` column — map it to user_balances.balance.""" src = open_sqlite(PENIS_LEGACY_DB) if src is None: return cur = pg.cursor() try: rows = src.execute("SELECT user_id, length, display_name FROM penis_stats").fetchall() except sqlite3.OperationalError: rows = src.execute("SELECT * FROM penis_stats").fetchall() print(f" penis_stats.sqlite3 (legacy): {len(rows)} rows") for r in rows: uid = r["user_id"] length = r["length"] if "length" in r.keys() else 0.0 name = r["display_name"] if "display_name" in r.keys() else "" last_ts = r["last_used_ts"] if "last_used_ts" in r.keys() else None # upsert into user_balances; don't overwrite if already migrated from economy.sqlite3 cur.execute(""" INSERT INTO user_balances (user_id, balance) VALUES (%s, %s) ON CONFLICT (user_id) DO NOTHING """, (uid, float(length))) cur.execute(""" INSERT INTO penis_stats (user_id, display_name, last_used_ts) VALUES (%s, %s, %s) ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name, last_used_ts = COALESCE(EXCLUDED.last_used_ts, penis_stats.last_used_ts) """, (uid, name, last_ts)) src.close() pg.commit() print(" penis_stats.sqlite3 done") def migrate_bets(pg: psycopg2.extensions.connection) -> None: src = open_sqlite(BETS_DB) if src is None: return cur = pg.cursor() rows = src.execute("SELECT * FROM bets").fetchall() print(f" bets: {len(rows)} rows") cols = rows[0].keys() if rows else [] for r in rows: cur.execute(""" INSERT INTO bets (id, user_id, match_id, sport, home_team, away_team, chosen_team, amount, odds, status, payout, created_ts, resolved_ts) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING """, ( r["id"], r["user_id"], r["match_id"], r["sport"], r["home_team"], r["away_team"], r["chosen_team"], r["amount"], r["odds"], r["status"], r["payout"] if "payout" in cols else 0, r["created_ts"] if "created_ts" in cols else None, r["resolved_ts"] if "resolved_ts" in cols else None, )) _reset_sequence(cur, "bets", "id") src.close() pg.commit() print(" bets.sqlite3 done") def migrate_chat(pg: psycopg2.extensions.connection) -> None: src = open_sqlite(CHAT_DB) if src is None: return cur = pg.cursor() # --- chat_history --- rows = src.execute("SELECT * FROM chat_history").fetchall() print(f" chat_history: {len(rows)} rows") cols = rows[0].keys() if rows else [] for r in rows: cur.execute(""" INSERT INTO chat_history (id, chat_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING """, ( r["id"], r["chat_id"], r["role"], r["name"], r["text"], r["created_at"] if "created_at" in cols else 0, )) _reset_sequence(cur, "chat_history", "id") # --- chat_state --- rows = src.execute("SELECT * FROM chat_state").fetchall() print(f" chat_state: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO chat_state (chat_id, key, value) VALUES (%s, %s, %s) ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value """, (r["chat_id"], r["key"], r["value"])) # --- talk_history --- rows = src.execute("SELECT * FROM talk_history").fetchall() print(f" talk_history: {len(rows)} rows") cols = rows[0].keys() if rows else [] for r in rows: cur.execute(""" INSERT INTO talk_history (id, chat_id, user_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO NOTHING """, ( r["id"], r["chat_id"], r["user_id"], r["role"], r["name"], r["text"], r["created_at"] if "created_at" in cols else 0, )) _reset_sequence(cur, "talk_history", "id") # --- talk_state --- rows = src.execute("SELECT * FROM talk_state").fetchall() print(f" talk_state: {len(rows)} rows") for r in rows: cur.execute(""" INSERT INTO talk_state (chat_id, user_id, key, value) VALUES (%s, %s, %s, %s) ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value """, (r["chat_id"], r["user_id"], r["key"], r["value"])) src.close() pg.commit() print(" chat_history.sqlite3 done") def _reset_sequence(cur, table: str, col: str) -> None: """Reset SERIAL sequence to max(col) so future inserts don't collide.""" cur.execute(f"SELECT setval(pg_get_serial_sequence('{table}', '{col}'), COALESCE(MAX({col}), 1)) FROM {table}") def main() -> None: print(f"Connecting to PostgreSQL: {DATABASE_URL}") try: pg = psycopg2.connect(DATABASE_URL) except psycopg2.OperationalError as e: print(f"ERROR: cannot connect to PostgreSQL: {e}", file=sys.stderr) sys.exit(1) pg.autocommit = False print("\n=== economy.sqlite3 ===") migrate_economy(pg) print("\n=== penis_stats.sqlite3 (legacy) ===") migrate_legacy_penis(pg) print("\n=== bets.sqlite3 ===") migrate_bets(pg) print("\n=== chat_history.sqlite3 ===") migrate_chat(pg) pg.close() print("\nMigration complete.") if __name__ == "__main__": main()