| """ |
| database.py — SQLite persistence layer for B24 Messenger. |
| """ |
|
|
| import sqlite3 |
| import os |
| import time |
| import json |
|
|
| DB_PATH = os.environ.get("DB_PATH", "/data/b24.db") |
|
|
| AI_USER_ID = 0 |
| AI_USERNAME = "joy" |
| AI_HANDLE = "joy@b24.me" |
|
|
|
|
| def get_conn(): |
| os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) |
| conn = sqlite3.connect(DB_PATH, check_same_thread=False) |
| conn.row_factory = sqlite3.Row |
| conn.execute("PRAGMA foreign_keys = ON") |
| return conn |
|
|
|
|
| def init_db(): |
| conn = get_conn() |
| c = conn.cursor() |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS users ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| username TEXT UNIQUE NOT NULL, |
| password_hash TEXT NOT NULL, |
| handle TEXT UNIQUE NOT NULL, |
| avatar_letter TEXT, |
| created_at INTEGER NOT NULL |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS friendships ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| user_id INTEGER NOT NULL, |
| friend_id INTEGER NOT NULL, |
| status TEXT CHECK(status IN ('pending','accepted','blocked')) DEFAULT 'pending', |
| requested_by INTEGER NOT NULL, |
| created_at INTEGER NOT NULL, |
| UNIQUE(user_id, friend_id), |
| FOREIGN KEY(user_id) REFERENCES users(id), |
| FOREIGN KEY(friend_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS messages ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| sender_id INTEGER NOT NULL, |
| recipient_id INTEGER NOT NULL, |
| content TEXT NOT NULL, |
| read_status INTEGER DEFAULT 0, |
| timestamp INTEGER NOT NULL, |
| FOREIGN KEY(sender_id) REFERENCES users(id), |
| FOREIGN KEY(recipient_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS groups ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| name TEXT NOT NULL, |
| created_by INTEGER NOT NULL, |
| created_at INTEGER NOT NULL, |
| FOREIGN KEY(created_by) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS group_members ( |
| group_id INTEGER NOT NULL, |
| user_id INTEGER NOT NULL, |
| role TEXT CHECK(role IN ('admin','member')) DEFAULT 'member', |
| joined_at INTEGER NOT NULL, |
| PRIMARY KEY(group_id, user_id), |
| FOREIGN KEY(group_id) REFERENCES groups(id), |
| FOREIGN KEY(user_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS group_messages ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| group_id INTEGER NOT NULL, |
| sender_id INTEGER NOT NULL, |
| content TEXT NOT NULL, |
| timestamp INTEGER NOT NULL, |
| FOREIGN KEY(group_id) REFERENCES groups(id), |
| FOREIGN KEY(sender_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS group_message_reads ( |
| group_message_id INTEGER NOT NULL, |
| user_id INTEGER NOT NULL, |
| read_at INTEGER NOT NULL, |
| PRIMARY KEY(group_message_id, user_id) |
| ) |
| """) |
|
|
|
|
| |
| for table in ("messages", "group_messages"): |
| existing_cols = [r["name"] for r in c.execute(f"PRAGMA table_info({table})").fetchall()] |
| if "media_type" not in existing_cols: |
| c.execute(f"ALTER TABLE {table} ADD COLUMN media_type TEXT DEFAULT 'text'") |
| if "media_url" not in existing_cols: |
| c.execute(f"ALTER TABLE {table} ADD COLUMN media_url TEXT") |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS favorites ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| user_id INTEGER NOT NULL, |
| url TEXT NOT NULL, |
| kind TEXT CHECK(kind IN ('sticker','gif')) DEFAULT 'gif', |
| created_at INTEGER NOT NULL, |
| UNIQUE(user_id, url), |
| FOREIGN KEY(user_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| |
| |
| group_cols = [r["name"] for r in c.execute("PRAGMA table_info(groups)").fetchall()] |
| if "type" not in group_cols: |
| c.execute("ALTER TABLE groups ADD COLUMN type TEXT DEFAULT 'group'") |
| if "description" not in group_cols: |
| c.execute("ALTER TABLE groups ADD COLUMN description TEXT DEFAULT ''") |
| if "invite_code" not in group_cols: |
| c.execute("ALTER TABLE groups ADD COLUMN invite_code TEXT") |
|
|
| |
| msg_cols = [r["name"] for r in c.execute("PRAGMA table_info(messages)").fetchall()] |
| if "is_request" not in msg_cols: |
| c.execute("ALTER TABLE messages ADD COLUMN is_request INTEGER DEFAULT 0") |
| if "expires_at" not in msg_cols: |
| c.execute("ALTER TABLE messages ADD COLUMN expires_at INTEGER") |
|
|
| |
| user_cols = [r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()] |
| if "account_type" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN account_type TEXT DEFAULT 'personal'") |
| if "verified" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN verified TEXT DEFAULT NULL") |
| if "points" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN points INTEGER DEFAULT 0") |
| if "avatar_url" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN avatar_url TEXT") |
| if "banner_type" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN banner_type TEXT DEFAULT 'color'") |
| if "banner_value" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN banner_value TEXT") |
| if "bio" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN bio TEXT") |
| if "push_token" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN push_token TEXT") |
| if "privacy_settings" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN privacy_settings TEXT") |
| if "last_seen_at" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN last_seen_at INTEGER") |
| if "frozen_last_seen_at" not in user_cols: |
| c.execute("ALTER TABLE users ADD COLUMN frozen_last_seen_at INTEGER") |
|
|
| |
| friendship_cols = [r["name"] for r in c.execute("PRAGMA table_info(friendships)").fetchall()] |
| needs_rebuild = True |
| try: |
| table_sql = c.execute( |
| "SELECT sql FROM sqlite_master WHERE type='table' AND name='friendships'" |
| ).fetchone() |
| if table_sql and "'canceled'" in table_sql["sql"]: |
| needs_rebuild = False |
| except Exception: |
| pass |
|
|
| if needs_rebuild and friendship_cols: |
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS friendships_new ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| user_id INTEGER NOT NULL, |
| friend_id INTEGER NOT NULL, |
| status TEXT CHECK(status IN ('pending','accepted','declined','blocked','canceled')) DEFAULT 'pending', |
| requested_by INTEGER NOT NULL, |
| created_at INTEGER NOT NULL, |
| UNIQUE(user_id, friend_id), |
| FOREIGN KEY(user_id) REFERENCES users(id), |
| FOREIGN KEY(friend_id) REFERENCES users(id) |
| ) |
| """) |
| c.execute(""" |
| INSERT INTO friendships_new (id, user_id, friend_id, status, requested_by, created_at) |
| SELECT id, user_id, friend_id, status, requested_by, created_at FROM friendships |
| """) |
| c.execute("DROP TABLE friendships") |
| c.execute("ALTER TABLE friendships_new RENAME TO friendships") |
|
|
| c.execute("SELECT id FROM users WHERE id = ?", (AI_USER_ID,)) |
| if not c.fetchone(): |
| c.execute( |
| "INSERT INTO users (id, username, password_hash, handle, avatar_letter, created_at) " |
| "VALUES (?, ?, ?, ?, ?, ?)", |
| (AI_USER_ID, AI_USERNAME, "!", AI_HANDLE, "J", int(time.time())) |
| ) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS calls ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| caller_id INTEGER NOT NULL, |
| callee_id INTEGER NOT NULL, |
| call_type TEXT NOT NULL CHECK(call_type IN ('voice','video')), |
| status TEXT NOT NULL DEFAULT 'ringing', |
| started_at INTEGER NOT NULL, |
| answered_at INTEGER, |
| ended_at INTEGER, |
| duration INTEGER, |
| FOREIGN KEY(caller_id) REFERENCES users(id), |
| FOREIGN KEY(callee_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS reports ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| reporter_id INTEGER NOT NULL, |
| reported_user_id INTEGER NOT NULL, |
| reason TEXT, |
| created_at INTEGER NOT NULL, |
| FOREIGN KEY(reporter_id) REFERENCES users(id), |
| FOREIGN KEY(reported_user_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS reports ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| reporter_id INTEGER NOT NULL, |
| reported_user_id INTEGER NOT NULL, |
| reason TEXT, |
| created_at INTEGER NOT NULL, |
| FOREIGN KEY(reporter_id) REFERENCES users(id), |
| FOREIGN KEY(reported_user_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS statuses ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| user_id INTEGER NOT NULL, |
| content_type TEXT NOT NULL CHECK(content_type IN ('photo','video','text','voice','song')), |
| media_url TEXT, |
| text_content TEXT, |
| bg_color TEXT, |
| privacy TEXT NOT NULL DEFAULT 'all' CHECK(privacy IN ('all','except','only')), |
| created_at INTEGER NOT NULL, |
| expires_at INTEGER NOT NULL, |
| FOREIGN KEY(user_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS status_privacy_list ( |
| status_id INTEGER NOT NULL, |
| user_id INTEGER NOT NULL, |
| PRIMARY KEY(status_id, user_id), |
| FOREIGN KEY(status_id) REFERENCES statuses(id), |
| FOREIGN KEY(user_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS status_views ( |
| status_id INTEGER NOT NULL, |
| viewer_id INTEGER NOT NULL, |
| viewed_at INTEGER NOT NULL, |
| PRIMARY KEY(status_id, viewer_id), |
| FOREIGN KEY(status_id) REFERENCES statuses(id), |
| FOREIGN KEY(viewer_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS completed_tasks ( |
| user_id INTEGER NOT NULL, |
| task_key TEXT NOT NULL, |
| points_awarded INTEGER NOT NULL, |
| completed_at INTEGER NOT NULL, |
| PRIMARY KEY (user_id, task_key), |
| FOREIGN KEY(user_id) REFERENCES users(id) |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS tasks ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| key TEXT UNIQUE NOT NULL, |
| label TEXT NOT NULL, |
| points INTEGER NOT NULL, |
| category TEXT DEFAULT 'General', |
| active INTEGER NOT NULL DEFAULT 1, |
| created_at INTEGER NOT NULL |
| ) |
| """) |
|
|
| _seed_tasks = [ |
| ("first_message", "Send your first message", 10, "Getting Started"), |
| ("add_3_friends", "Add 3 friends", 20, "Getting Started"), |
| ("first_group", "Create or join a group", 15, "Social"), |
| ("first_status", "Post your first status", 10, "Social"), |
| ("first_call", "Make your first call", 15, "Social"), |
| ("add_10_friends", "Add 10 friends", 30, "Power User"), |
| ] |
| for _key, _label, _points, _category in _seed_tasks: |
| c.execute( |
| "INSERT OR IGNORE INTO tasks (key, label, points, category, active, created_at) VALUES (?, ?, ?, ?, 1, ?)", |
| (_key, _label, _points, _category, int(time.time())) |
| ) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS announcements ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| title TEXT NOT NULL, |
| body TEXT, |
| created_at INTEGER NOT NULL, |
| expires_at INTEGER, |
| active INTEGER NOT NULL DEFAULT 1 |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS app_version ( |
| id INTEGER PRIMARY KEY, |
| version TEXT NOT NULL, |
| apk_url TEXT NOT NULL, |
| notes TEXT, |
| updated_at INTEGER NOT NULL |
| ) |
| """) |
|
|
| c.execute(""" |
| CREATE TABLE IF NOT EXISTS ota_update ( |
| id INTEGER PRIMARY KEY, |
| update_id TEXT NOT NULL, |
| runtime_version TEXT NOT NULL, |
| launch_asset_json TEXT NOT NULL, |
| assets_json TEXT NOT NULL, |
| notes TEXT, |
| created_at INTEGER NOT NULL |
| ) |
| """) |
|
|
| conn.commit() |
| conn.close() |
|
|
|
|
| def create_user(username, password_hash, handle, avatar_letter): |
| """Returns the new user's id, or None if the username/handle was taken |
| by a concurrent request (race-safe — relies on the UNIQUE constraint, |
| not just the earlier existence check).""" |
| conn = get_conn() |
| try: |
| cur = conn.execute( |
| "INSERT INTO users (username, password_hash, handle, avatar_letter, created_at) " |
| "VALUES (?, ?, ?, ?, ?)", |
| (username, password_hash, handle, avatar_letter, int(time.time())) |
| ) |
| conn.commit() |
| return cur.lastrowid |
| except sqlite3.IntegrityError: |
| return None |
| finally: |
| conn.close() |
|
|
|
|
| def get_user_by_username(username): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() |
| return dict(row) if row else None |
| finally: |
| conn.close() |
|
|
|
|
| def set_account_type(user_id, account_type): |
| if account_type not in ("personal", "business", "business_noreply"): |
| return False |
| conn = get_conn() |
| try: |
| conn.execute("UPDATE users SET account_type = ? WHERE id = ?", (account_type, user_id)) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def set_verified(user_id, tick): |
| """tick: None to remove, or 'purple'/'cyan'.""" |
| if tick not in (None, "purple", "cyan"): |
| return False |
| conn = get_conn() |
| try: |
| conn.execute("UPDATE users SET verified = ? WHERE id = ?", (tick, user_id)) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def set_avatar(user_id, avatar_url): |
| conn = get_conn() |
| try: |
| conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user_id)) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def set_username(user_id, new_username): |
| new_username = (new_username or "").strip() |
| if len(new_username) < 3 or len(new_username) > 24: |
| return False, "Username must be 3-24 characters" |
| conn = get_conn() |
| try: |
| taken = conn.execute( |
| "SELECT id FROM users WHERE username = ? AND id != ?", (new_username, user_id) |
| ).fetchone() |
| if taken: |
| return False, "That username is already taken" |
| new_handle = f"{new_username}@b24.me" |
| conn.execute("UPDATE users SET username = ?, handle = ? WHERE id = ?", (new_username, new_handle, user_id)) |
| conn.commit() |
| return True, None |
| finally: |
| conn.close() |
|
|
|
|
| def set_bio(user_id, bio): |
| bio = (bio or "")[:150] |
| conn = get_conn() |
| try: |
| conn.execute("UPDATE users SET bio = ? WHERE id = ?", (bio, user_id)) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def set_banner(user_id, banner_type, banner_value): |
| if banner_type not in ("color", "photo", "video"): |
| return False |
| conn = get_conn() |
| try: |
| conn.execute( |
| "UPDATE users SET banner_type = ?, banner_value = ? WHERE id = ?", |
| (banner_type, banner_value, user_id), |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| DEFAULT_PRIVACY_SETTINGS = { |
| "last_seen": True, |
| "freeze_last_seen": False, |
| "ghost_mode": False, |
| "profile_photo_visible": True, |
| "read_receipts": True, |
| "anti_delete": False, |
| } |
|
|
|
|
| def get_privacy_settings(user_id): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT privacy_settings FROM users WHERE id = ?", (user_id,)).fetchone() |
| stored = {} |
| if row and row["privacy_settings"]: |
| try: |
| stored = json.loads(row["privacy_settings"]) |
| except (TypeError, ValueError): |
| stored = {} |
| merged = dict(DEFAULT_PRIVACY_SETTINGS) |
| merged.update({k: v for k, v in stored.items() if k in DEFAULT_PRIVACY_SETTINGS}) |
| return merged |
| finally: |
| conn.close() |
|
|
|
|
| def set_privacy_settings(user_id, patch): |
| current = get_privacy_settings(user_id) |
| current.update({k: v for k, v in (patch or {}).items() if k in DEFAULT_PRIVACY_SETTINGS}) |
| conn = get_conn() |
| try: |
| conn.execute( |
| "UPDATE users SET privacy_settings = ? WHERE id = ?", |
| (json.dumps(current), user_id) |
| ) |
| conn.commit() |
| return current |
| finally: |
| conn.close() |
|
|
|
|
| def set_frozen_last_seen(user_id, frozen_at): |
| """frozen_at: epoch milliseconds (int), or None to clear and go back to live last-seen.""" |
| conn = get_conn() |
| try: |
| conn.execute( |
| "UPDATE users SET frozen_last_seen_at = ? WHERE id = ?", |
| (frozen_at, user_id) |
| ) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
|
|
| def _avatar_or_hidden(owner_id, avatar_url, viewer_id=None): |
| """Mask avatar_url for other users when the owner has profile_photo_visible off.""" |
| if viewer_id is not None and owner_id == viewer_id: |
| return avatar_url |
| if not get_privacy_settings(owner_id).get("profile_photo_visible", True): |
| return None |
| return avatar_url |
|
|
|
|
| def touch_last_seen(user_id): |
| conn = get_conn() |
| try: |
| conn.execute("UPDATE users SET last_seen_at = ? WHERE id = ?", (int(time.time()), user_id)) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
|
|
| def get_user_by_id(user_id): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() |
| return dict(row) if row else None |
| finally: |
| conn.close() |
|
|
|
|
| def send_friend_request(user_id, friend_username): |
| conn = get_conn() |
| try: |
| friend = conn.execute( |
| "SELECT id FROM users WHERE username = ?", (friend_username,) |
| ).fetchone() |
| if not friend: |
| return None, "User not found" |
| friend_id = friend["id"] |
| if friend_id == user_id: |
| return None, "Can't add yourself" |
|
|
| existing = conn.execute( |
| "SELECT id FROM friendships WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)", |
| (user_id, friend_id, friend_id, user_id) |
| ).fetchone() |
| if existing: |
| return None, "Request already exists" |
|
|
| conn.execute( |
| "INSERT INTO friendships (user_id, friend_id, status, requested_by, created_at) " |
| "VALUES (?, ?, 'pending', ?, ?)", |
| (user_id, friend_id, user_id, int(time.time())) |
| ) |
| conn.commit() |
| return friend_id, None |
| finally: |
| conn.close() |
|
|
|
|
| def respond_friend_request(user_id, requester_id, action): |
| """action: 'accept', 'decline', or 'block'. |
| Decline lets them retry later; block is silent and permanent.""" |
| conn = get_conn() |
| try: |
| status_map = {"accept": "accepted", "decline": "declined", "block": "blocked"} |
| status = status_map.get(action) |
| if not status: |
| return False |
| conn.execute( |
| "UPDATE friendships SET status = ? WHERE user_id = ? AND friend_id = ? AND status = 'pending'", |
| (status, requester_id, user_id) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def block_friend(user_id, target_id): |
| """Blocks target_id regardless of prior friendship state (works even |
| on an already-accepted friendship, unlike respond_friend_request).""" |
| conn = get_conn() |
| try: |
| existing = conn.execute( |
| "SELECT id FROM friendships WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)", |
| (user_id, target_id, target_id, user_id) |
| ).fetchone() |
| if existing: |
| conn.execute("UPDATE friendships SET status='blocked' WHERE id=?", (existing["id"],)) |
| else: |
| conn.execute( |
| "INSERT INTO friendships (user_id, friend_id, status, requested_by, created_at) VALUES (?, ?, 'blocked', ?, ?)", |
| (user_id, target_id, user_id, int(time.time())) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def remove_friend(user_id, target_id): |
| """Deletes the friendship row entirely (unfriend).""" |
| conn = get_conn() |
| try: |
| conn.execute( |
| "DELETE FROM friendships WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)", |
| (user_id, target_id, target_id, user_id) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def list_friends(user_id): |
| conn = get_conn() |
| try: |
| rows = conn.execute(""" |
| SELECT u.id, u.username, u.handle, u.avatar_letter, u.avatar_url, f.status |
| FROM friendships f |
| JOIN users u ON u.id = CASE WHEN f.user_id = ? THEN f.friend_id ELSE f.user_id END |
| WHERE (f.user_id = ? OR f.friend_id = ?) AND f.status = 'accepted' |
| """, (user_id, user_id, user_id)).fetchall() |
| result = [dict(r) for r in rows] |
| for r in result: |
| r["avatar_url"] = _avatar_or_hidden(r["id"], r["avatar_url"], user_id) |
| return result |
| finally: |
| conn.close() |
|
|
|
|
| def list_pending_requests(user_id): |
| conn = get_conn() |
| try: |
| rows = conn.execute(""" |
| SELECT f.id as request_id, u.id, u.username, u.handle |
| FROM friendships f |
| JOIN users u ON u.id = f.requested_by |
| WHERE f.friend_id = ? AND f.status = 'pending' |
| """, (user_id,)).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def list_sent_requests(user_id): |
| """Requests *I* sent, grouped by outcome. 'Failed' surfaces requests the |
| other person declined -- declined-from-their-side is a failed request |
| from mine. 'Canceled' is a request I pulled back before they answered.""" |
| conn = get_conn() |
| try: |
| rows = conn.execute(""" |
| SELECT f.id as request_id, f.status, f.created_at, |
| u.id, u.username, u.handle |
| FROM friendships f |
| JOIN users u ON u.id = f.friend_id |
| WHERE f.requested_by = ? AND f.status IN ('pending', 'canceled', 'declined') |
| ORDER BY f.created_at DESC |
| """, (user_id,)).fetchall() |
| result = {"pending": [], "canceled": [], "failed": []} |
| status_key = {"pending": "pending", "canceled": "canceled", "declined": "failed"} |
| for r in rows: |
| result[status_key[r["status"]]].append(dict(r)) |
| return result |
| finally: |
| conn.close() |
|
|
|
|
| def cancel_sent_request(user_id, friend_id): |
| """Lets the sender withdraw their own still-pending outgoing request.""" |
| conn = get_conn() |
| try: |
| cur = conn.execute( |
| "UPDATE friendships SET status = 'canceled' " |
| "WHERE user_id = ? AND friend_id = ? AND requested_by = ? AND status = 'pending'", |
| (user_id, friend_id, user_id) |
| ) |
| conn.commit() |
| return cur.rowcount > 0 |
| finally: |
| conn.close() |
|
|
|
|
| def save_message(sender_id, recipient_id, content, media_type='text', media_url=None, is_request=0, expires_at=None): |
| conn = get_conn() |
| try: |
| cur = conn.execute( |
| "INSERT INTO messages (sender_id, recipient_id, content, timestamp, media_type, media_url, is_request, expires_at) " |
| "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", |
| (sender_id, recipient_id, content, int(time.time()), media_type, media_url, is_request, expires_at) |
| ) |
| conn.commit() |
| return cur.lastrowid |
| finally: |
| conn.close() |
|
|
|
|
| def set_push_token(user_id, token): |
| conn = get_conn() |
| try: |
| conn.execute("UPDATE users SET push_token = ? WHERE id = ?", (token, user_id)) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
|
|
| def get_push_token(user_id): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT push_token FROM users WHERE id = ?", (user_id,)).fetchone() |
| return row["push_token"] if row and row["push_token"] else None |
| finally: |
| conn.close() |
|
|
|
|
| def are_friends(user_a, user_b): |
| conn = get_conn() |
| try: |
| row = conn.execute( |
| "SELECT id FROM friendships WHERE " |
| "((user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)) AND status='accepted'", |
| (user_a, user_b, user_b, user_a) |
| ).fetchone() |
| return row is not None |
| finally: |
| conn.close() |
|
|
|
|
| def can_message_directly(sender_id, recipient_id): |
| """True if the sender can message without landing in the recipient's |
| Message Requests: they're already friends, sender is JOY, or sender is |
| a verified business account.""" |
| if sender_id == AI_USER_ID or recipient_id == AI_USER_ID: |
| return True |
| if are_friends(sender_id, recipient_id): |
| return True |
|
|
| sender = get_user_by_id(sender_id) |
| if sender and sender.get("verified") and sender.get("account_type") in ("business", "business_noreply"): |
| return True |
| return False |
|
|
|
|
| def get_conversation(user_a, user_b, limit=100): |
| conn = get_conn() |
| try: |
| now = int(time.time()) |
| conn.execute(""" |
| DELETE FROM messages |
| WHERE expires_at IS NOT NULL AND expires_at <= ? |
| AND ((sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?)) |
| """, (now, user_a, user_b, user_b, user_a)) |
| conn.commit() |
| rows = conn.execute(""" |
| SELECT * FROM messages |
| WHERE (sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?) |
| ORDER BY timestamp DESC LIMIT ? |
| """, (user_a, user_b, user_b, user_a, limit)).fetchall() |
| return [dict(r) for r in reversed(rows)] |
| finally: |
| conn.close() |
|
|
|
|
| def mark_read(user_id, from_id): |
| conn = get_conn() |
| try: |
| conn.execute( |
| "UPDATE messages SET read_status = 1 WHERE sender_id = ? AND recipient_id = ?", |
| (from_id, user_id) |
| ) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
|
|
| def get_pending_messages(user_id): |
| """1:1 messages waiting on the server for this user's phone to pick up.""" |
| conn = get_conn() |
| try: |
| rows = conn.execute( |
| "SELECT * FROM messages WHERE recipient_id = ? ORDER BY timestamp ASC", |
| (user_id,) |
| ).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def ack_messages(user_id, message_ids): |
| """Recipient confirms these are saved locally -> safe to delete server-side. |
| JOY threads are never deleted; JOY needs that history to answer well.""" |
| if not message_ids: |
| return 0 |
| conn = get_conn() |
| try: |
| placeholders = ",".join("?" for _ in message_ids) |
| cur = conn.execute( |
| f"DELETE FROM messages WHERE id IN ({placeholders}) " |
| f"AND recipient_id = ? AND sender_id != ? AND recipient_id != ?", |
| (*message_ids, user_id, AI_USER_ID, AI_USER_ID) |
| ) |
| conn.commit() |
| return cur.rowcount |
| finally: |
| conn.close() |
|
|
|
|
| def purge_stale_messages(days=30): |
| """Safety net for messages nobody ever acked (uninstalled app, dead account). |
| Never touches JOY conversations.""" |
| cutoff = int(time.time()) - days * 86400 |
| conn = get_conn() |
| try: |
| cur = conn.execute( |
| "DELETE FROM messages WHERE timestamp < ? AND sender_id != ? AND recipient_id != ?", |
| (cutoff, AI_USER_ID, AI_USER_ID) |
| ) |
| conn.commit() |
| return cur.rowcount |
| finally: |
| conn.close() |
|
|
|
|
| def create_group(name, created_by, member_ids, group_type='group', description=''): |
| import secrets |
| if group_type not in ('group', 'channel', 'broadcast'): |
| group_type = 'group' |
| invite_code = secrets.token_urlsafe(6) |
| conn = get_conn() |
| try: |
| cur = conn.execute( |
| "INSERT INTO groups (name, created_by, created_at, type, description, invite_code) VALUES (?, ?, ?, ?, ?, ?)", |
| (name, created_by, int(time.time()), group_type, description, invite_code) |
| ) |
| group_id = cur.lastrowid |
| conn.execute( |
| "INSERT INTO group_members (group_id, user_id, role, joined_at) VALUES (?, ?, 'admin', ?)", |
| (group_id, created_by, int(time.time())) |
| ) |
| for uid in member_ids: |
| if uid != created_by: |
| conn.execute( |
| "INSERT OR IGNORE INTO group_members (group_id, user_id, role, joined_at) " |
| "VALUES (?, ?, 'member', ?)", |
| (group_id, uid, int(time.time())) |
| ) |
| conn.commit() |
| return group_id, invite_code |
| finally: |
| conn.close() |
|
|
|
|
| def add_group_members(group_id, user_ids, added_by): |
| """Adds users to an existing group. Returns the list of ids actually added.""" |
| conn = get_conn() |
| try: |
| member = conn.execute( |
| "SELECT role FROM group_members WHERE group_id = ? AND user_id = ?", |
| (group_id, added_by) |
| ).fetchone() |
| if not member: |
| return None |
| added = [] |
| for uid in user_ids: |
| existing = conn.execute( |
| "SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?", (group_id, uid) |
| ).fetchone() |
| if existing: |
| continue |
| conn.execute( |
| "INSERT INTO group_members (group_id, user_id, role, joined_at) VALUES (?, ?, 'member', ?)", |
| (group_id, uid, int(time.time())) |
| ) |
| added.append(uid) |
| conn.commit() |
| return added |
| finally: |
| conn.close() |
|
|
|
|
| def get_group_by_invite_code(code): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT * FROM groups WHERE invite_code = ?", (code,)).fetchone() |
| return dict(row) if row else None |
| finally: |
| conn.close() |
|
|
|
|
| def get_group_members(group_id): |
| conn = get_conn() |
| try: |
| rows = conn.execute( |
| "SELECT user_id, role FROM group_members WHERE group_id = ?", (group_id,) |
| ).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def save_group_message(group_id, sender_id, content, media_type='text', media_url=None): |
| conn = get_conn() |
| try: |
| cur = conn.execute( |
| "INSERT INTO group_messages (group_id, sender_id, content, timestamp, media_type, media_url) " |
| "VALUES (?, ?, ?, ?, ?, ?)", |
| (group_id, sender_id, content, int(time.time()), media_type, media_url) |
| ) |
| conn.commit() |
| return cur.lastrowid |
| finally: |
| conn.close() |
|
|
|
|
| def get_group_conversation(group_id, limit=100): |
| conn = get_conn() |
| try: |
| rows = conn.execute(""" |
| SELECT gm.*, u.username as sender_username, u.verified as sender_verified |
| FROM group_messages gm |
| JOIN users u ON u.id = gm.sender_id |
| WHERE gm.group_id = ? ORDER BY gm.timestamp DESC LIMIT ? |
| """, (group_id, limit)).fetchall() |
| result = [] |
| for r in reversed(rows): |
| d = dict(r) |
| d["sender_name"] = d["sender_username"] |
| d["sender_color"] = _color_for_id(d["sender_id"]) |
| result.append(d) |
| return result |
| finally: |
| conn.close() |
|
|
|
|
| def list_user_groups(user_id): |
| conn = get_conn() |
| try: |
| rows = conn.execute(""" |
| SELECT g.id, g.name, g.type FROM groups g |
| JOIN group_members gm ON gm.group_id = g.id |
| WHERE gm.user_id = ? |
| """, (user_id,)).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def get_pending_group_messages(user_id): |
| """Group messages this user hasn't acked yet, across every group they're in.""" |
| conn = get_conn() |
| try: |
| group_ids = [g["id"] for g in conn.execute( |
| "SELECT group_id as id FROM group_members WHERE user_id = ?", (user_id,) |
| ).fetchall()] |
| if not group_ids: |
| return [] |
| placeholders = ",".join("?" for _ in group_ids) |
| rows = conn.execute(f""" |
| SELECT gm.* FROM group_messages gm |
| WHERE gm.group_id IN ({placeholders}) |
| AND gm.id NOT IN ( |
| SELECT group_message_id FROM group_message_reads WHERE user_id = ? |
| ) |
| ORDER BY gm.timestamp ASC |
| """, (*group_ids, user_id)).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def ack_group_messages(user_id, message_ids): |
| """Member confirms these are saved locally. Once every current member has |
| acked a message, it's deleted from the server.""" |
| if not message_ids: |
| return 0 |
| conn = get_conn() |
| try: |
| for mid in message_ids: |
| conn.execute( |
| "INSERT OR IGNORE INTO group_message_reads (group_message_id, user_id, read_at) VALUES (?, ?, ?)", |
| (mid, user_id, int(time.time())) |
| ) |
| conn.commit() |
|
|
| placeholders = ",".join("?" for _ in message_ids) |
| rows = conn.execute( |
| f"SELECT DISTINCT group_message_id FROM group_message_reads WHERE group_message_id IN ({placeholders})", |
| message_ids |
| ).fetchall() |
| for row in rows: |
| mid = row["group_message_id"] |
| gm = conn.execute("SELECT group_id FROM group_messages WHERE id = ?", (mid,)).fetchone() |
| if not gm: |
| continue |
| member_ids = {m["user_id"] for m in get_group_members(gm["group_id"])} |
| acked_ids = {r["user_id"] for r in conn.execute( |
| "SELECT user_id FROM group_message_reads WHERE group_message_id = ?", (mid,) |
| ).fetchall()} |
| if member_ids and member_ids.issubset(acked_ids): |
| conn.execute("DELETE FROM group_messages WHERE id = ?", (mid,)) |
| conn.execute("DELETE FROM group_message_reads WHERE group_message_id = ?", (mid,)) |
| conn.commit() |
| return len(rows) |
| finally: |
| conn.close() |
|
|
|
|
| def purge_stale_group_messages(days=30): |
| cutoff = int(time.time()) - days * 86400 |
| conn = get_conn() |
| try: |
| cur = conn.execute("DELETE FROM group_messages WHERE timestamp < ?", (cutoff,)) |
| conn.execute(""" |
| DELETE FROM group_message_reads WHERE group_message_id NOT IN ( |
| SELECT id FROM group_messages |
| ) |
| """) |
| conn.commit() |
| return cur.rowcount |
| finally: |
| conn.close() |
|
|
|
|
| |
|
|
| def count_users(): |
| conn = get_conn() |
| try: |
| return conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"] |
| finally: |
| conn.close() |
|
|
|
|
| def count_messages(): |
| conn = get_conn() |
| try: |
| direct = conn.execute("SELECT COUNT(*) as c FROM messages").fetchone()["c"] |
| group = conn.execute("SELECT COUNT(*) as c FROM group_messages").fetchone()["c"] |
| return direct + group |
| finally: |
| conn.close() |
|
|
|
|
| def count_groups(): |
| conn = get_conn() |
| try: |
| return conn.execute("SELECT COUNT(*) as c FROM groups").fetchone()["c"] |
| finally: |
| conn.close() |
|
|
|
|
| def list_all_users(limit=100, offset=0): |
| conn = get_conn() |
| try: |
| rows = conn.execute( |
| "SELECT id, username, handle, avatar_letter, avatar_url, created_at, verified, account_type FROM users " |
| "ORDER BY created_at DESC LIMIT ? OFFSET ?", |
| (limit, offset) |
| ).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def db_file_size_bytes(): |
| try: |
| return os.path.getsize(DB_PATH) |
| except OSError: |
| return 0 |
|
|
|
|
| def get_group_type(group_id): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT type FROM groups WHERE id = ?", (group_id,)).fetchone() |
| return row["type"] if row else None |
| finally: |
| conn.close() |
|
|
|
|
| def get_member_role(group_id, user_id): |
| conn = get_conn() |
| try: |
| row = conn.execute( |
| "SELECT role FROM group_members WHERE group_id = ? AND user_id = ?", |
| (group_id, user_id) |
| ).fetchone() |
| return row["role"] if row else None |
| finally: |
| conn.close() |
|
|
| def add_favorite(user_id, url, kind='gif'): |
| conn = get_conn() |
| try: |
| conn.execute( |
| "INSERT OR IGNORE INTO favorites (user_id, url, kind, created_at) VALUES (?, ?, ?, ?)", |
| (user_id, url, kind, int(time.time())) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def remove_favorite(user_id, url): |
| conn = get_conn() |
| try: |
| conn.execute("DELETE FROM favorites WHERE user_id = ? AND url = ?", (user_id, url)) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def list_favorites(user_id): |
| conn = get_conn() |
| try: |
| rows = conn.execute( |
| "SELECT url, kind, created_at FROM favorites WHERE user_id = ? ORDER BY created_at DESC", |
| (user_id,) |
| ).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
| def create_call(caller_id, callee_id, call_type): |
| conn = get_conn() |
| try: |
| cur = conn.execute( |
| "INSERT INTO calls (caller_id, callee_id, call_type, started_at) VALUES (?, ?, ?, ?)", |
| (caller_id, callee_id, call_type, int(time.time())) |
| ) |
| conn.commit() |
| return cur.lastrowid |
| finally: |
| conn.close() |
|
|
|
|
| def update_call_status(call_id, status): |
| conn = get_conn() |
| try: |
| if status == 'answered': |
| conn.execute("UPDATE calls SET status=?, answered_at=? WHERE id=?", (status, int(time.time()), call_id)) |
| else: |
| conn.execute("UPDATE calls SET status=? WHERE id=?", (status, call_id)) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
|
|
| def end_call(call_id): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT answered_at FROM calls WHERE id=?", (call_id,)).fetchone() |
| now = int(time.time()) |
| duration = (now - row["answered_at"]) if row and row["answered_at"] else None |
| conn.execute( |
| "UPDATE calls SET status='ended', ended_at=?, duration=? WHERE id=?", |
| (now, duration, call_id) |
| ) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
|
|
| def get_call_history(user_id, limit=50): |
| conn = get_conn() |
| try: |
| rows = conn.execute( |
| "SELECT * FROM calls WHERE caller_id=? OR callee_id=? ORDER BY started_at DESC LIMIT ?", |
| (user_id, user_id, limit) |
| ).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def _color_for_id(seed): |
| """Deterministic pastel-ish color per user/group id, so the same |
| contact always shows the same avatar color across devices.""" |
| palette = ["#9333ea", "#f97316", "#0ea5a4", "#4f46e5", "#db2777", |
| "#16a34a", "#ea580c", "#0891b2", "#7c3aed", "#c026d3"] |
| return palette[seed % len(palette)] |
|
|
|
|
| def get_chat_list(user_id): |
| conn = get_conn() |
| try: |
| chats = [] |
|
|
| |
| joy_last = conn.execute(""" |
| SELECT content, media_type, timestamp FROM messages |
| WHERE (sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?) |
| ORDER BY timestamp DESC LIMIT 1 |
| """, (user_id, AI_USER_ID, AI_USER_ID, user_id)).fetchone() |
|
|
| chats.append({ |
| "id": str(AI_USER_ID), |
| "name": "Joy", |
| "preview": _preview_text(joy_last), |
| "time": joy_last["timestamp"] if joy_last else 0, |
| "unread": 0, |
| "color": "#9333ea", |
| "category": "all", |
| "isGroup": False, |
| }) |
|
|
| |
| friends = conn.execute(""" |
| SELECT u.id, u.username, u.account_type, u.verified, u.avatar_url, u.bio |
| FROM friendships f |
| JOIN users u ON u.id = CASE WHEN f.user_id = ? THEN f.friend_id ELSE f.user_id END |
| WHERE (f.user_id = ? OR f.friend_id = ?) AND f.status = 'accepted' |
| """, (user_id, user_id, user_id)).fetchall() |
|
|
| for friend in friends: |
| fid = friend["id"] |
| last_msg = conn.execute(""" |
| SELECT content, media_type, timestamp FROM messages |
| WHERE (sender_id = ? AND recipient_id = ?) OR (sender_id = ? AND recipient_id = ?) |
| ORDER BY timestamp DESC LIMIT 1 |
| """, (user_id, fid, fid, user_id)).fetchone() |
|
|
| unread = conn.execute(""" |
| SELECT COUNT(*) as c FROM messages |
| WHERE sender_id = ? AND recipient_id = ? AND read_status = 0 |
| """, (fid, user_id)).fetchone()["c"] |
|
|
| category = "business" if friend["account_type"] in ("business", "business_noreply") else "all" |
|
|
| chats.append({ |
| "id": str(fid), |
| "name": friend["username"], |
| "preview": _preview_text(last_msg), |
| "time": last_msg["timestamp"] if last_msg else 0, |
| "unread": unread, |
| "color": _color_for_id(fid), |
| "category": category, |
| "isGroup": False, |
| "verified": friend["verified"], |
| "avatarUrl": _avatar_or_hidden(fid, friend["avatar_url"], user_id), |
| "bio": friend["bio"], |
| }) |
|
|
| |
| groups = conn.execute(""" |
| SELECT g.id, g.name, g.type FROM groups g |
| JOIN group_members gm ON gm.group_id = g.id |
| WHERE gm.user_id = ? |
| """, (user_id,)).fetchall() |
|
|
| for group in groups: |
| gid = group["id"] |
| last_msg = conn.execute(""" |
| SELECT content, media_type, timestamp FROM group_messages |
| WHERE group_id = ? ORDER BY timestamp DESC LIMIT 1 |
| """, (gid,)).fetchone() |
|
|
| chats.append({ |
| "id": str(gid), |
| "name": group["name"], |
| "preview": _preview_text(last_msg), |
| "time": last_msg["timestamp"] if last_msg else 0, |
| "unread": 0, |
| "color": _color_for_id(gid + 1000), |
| "category": "groups", |
| "isGroup": True, |
| "groupType": group["type"], |
| }) |
|
|
| chats.sort(key=lambda c: c["time"], reverse=True) |
| return chats |
| finally: |
| conn.close() |
|
|
|
|
| def _preview_text(row): |
| if not row: |
| return "" |
| media_labels = {"image": "Photo", "voice": "Voice note", "file": "File"} |
| if row["media_type"] in media_labels: |
| return media_labels[row["media_type"]] |
| return row["content"] |
|
|
|
|
| def search_content(user_id, query): |
| """Searches the user's friends by username, plus their direct and |
| group message content. Returns (friends, messages).""" |
| conn = get_conn() |
| try: |
| like = f"%{query}%" |
|
|
| friend_rows = conn.execute(""" |
| SELECT u.id, u.username, u.verified |
| FROM friendships f |
| JOIN users u ON u.id = CASE WHEN f.user_id = ? THEN f.friend_id ELSE f.user_id END |
| WHERE (f.user_id = ? OR f.friend_id = ?) AND f.status = 'accepted' |
| AND u.username LIKE ? |
| LIMIT 20 |
| """, (user_id, user_id, user_id, like)).fetchall() |
| friends = [{ |
| "id": r["id"], "name": r["username"], "username": r["username"], |
| "color": _color_for_id(r["id"]), "verified": r["verified"], |
| } for r in friend_rows] |
|
|
| messages = [] |
|
|
| dm_rows = conn.execute(""" |
| SELECT m.id, m.content, m.timestamp, |
| CASE WHEN m.sender_id = ? THEN m.recipient_id ELSE m.sender_id END as other_id |
| FROM messages m |
| WHERE (m.sender_id = ? OR m.recipient_id = ?) AND m.content LIKE ? |
| ORDER BY m.timestamp DESC LIMIT 20 |
| """, (user_id, user_id, user_id, like)).fetchall() |
| for r in dm_rows: |
| other = conn.execute("SELECT username FROM users WHERE id = ?", (r["other_id"],)).fetchone() |
| messages.append({ |
| "id": f"dm{r['id']}", |
| "chatId": str(r["other_id"]), |
| "chatName": other["username"] if other else "Unknown", |
| "snippet": r["content"][:80], |
| }) |
|
|
| gm_rows = conn.execute(""" |
| SELECT gmsg.id, gmsg.content, gmsg.timestamp, gmsg.group_id |
| FROM group_messages gmsg |
| JOIN group_members mem ON mem.group_id = gmsg.group_id AND mem.user_id = ? |
| WHERE gmsg.content LIKE ? |
| ORDER BY gmsg.timestamp DESC LIMIT 20 |
| """, (user_id, like)).fetchall() |
| for r in gm_rows: |
| grp = conn.execute("SELECT name FROM groups WHERE id = ?", (r["group_id"],)).fetchone() |
| messages.append({ |
| "id": f"gm{r['id']}", |
| "chatId": str(r["group_id"]), |
| "chatName": grp["name"] if grp else "Group", |
| "snippet": r["content"][:80], |
| }) |
|
|
| return friends, messages |
| finally: |
| conn.close() |
|
|
|
|
| def create_report(reporter_id, reported_user_id, reason): |
| conn = get_conn() |
| try: |
| conn.execute( |
| "INSERT INTO reports (reporter_id, reported_user_id, reason, created_at) VALUES (?, ?, ?, ?)", |
| (reporter_id, reported_user_id, reason, int(time.time())) |
| ) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
| def get_reports_for_user(user_id): |
| conn = get_conn() |
| try: |
| rows = conn.execute(''' |
| SELECT r.id, r.reporter_id, u.username as reporter_username, r.reason, r.created_at |
| FROM reports r |
| LEFT JOIN users u ON u.id = r.reporter_id |
| WHERE r.reported_user_id = ? |
| ORDER BY r.created_at DESC |
| ''', (user_id,)).fetchall() |
| return [dict(row) for row in rows] |
| finally: |
| conn.close() |
|
|
| def get_report_counts(): |
| conn = get_conn() |
| try: |
| rows = conn.execute(''' |
| SELECT reported_user_id, COUNT(*) as count |
| FROM reports |
| GROUP BY reported_user_id |
| ''').fetchall() |
| return {row["reported_user_id"]: row["count"] for row in rows} |
| finally: |
| conn.close() |
|
|
|
|
| |
|
|
| def create_status(user_id, content_type, media_url=None, text_content=None, |
| bg_color=None, privacy='all', privacy_user_ids=None, duration_hours=24): |
| conn = get_conn() |
| try: |
| now = int(time.time()) |
| expires_at = now + duration_hours * 3600 |
| cur = conn.execute( |
| "INSERT INTO statuses (user_id, content_type, media_url, text_content, bg_color, privacy, created_at, expires_at) " |
| "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", |
| (user_id, content_type, media_url, text_content, bg_color, privacy, now, expires_at) |
| ) |
| status_id = cur.lastrowid |
| if privacy in ('except', 'only') and privacy_user_ids: |
| for uid in privacy_user_ids: |
| conn.execute( |
| "INSERT OR IGNORE INTO status_privacy_list (status_id, user_id) VALUES (?, ?)", |
| (status_id, uid) |
| ) |
| conn.commit() |
| return status_id |
| finally: |
| conn.close() |
|
|
|
|
| def _can_view_status(conn, status_row, viewer_id): |
| if status_row["user_id"] == viewer_id: |
| return True |
| privacy = status_row["privacy"] |
| if privacy == 'all': |
| return True |
| listed = conn.execute( |
| "SELECT 1 FROM status_privacy_list WHERE status_id = ? AND user_id = ?", |
| (status_row["id"], viewer_id) |
| ).fetchone() is not None |
| if privacy == 'except': |
| return not listed |
| if privacy == 'only': |
| return listed |
| return False |
|
|
|
|
| def get_status_feed(user_id): |
| """Active (non-expired) statuses from the user's friends, grouped by |
| author, filtered per-status by privacy (all / except / only).""" |
| conn = get_conn() |
| try: |
| now = int(time.time()) |
| friend_rows = conn.execute(""" |
| SELECT u.id, u.username, u.avatar_letter, u.avatar_url |
| FROM friendships f |
| JOIN users u ON u.id = CASE WHEN f.user_id = ? THEN f.friend_id ELSE f.user_id END |
| WHERE (f.user_id = ? OR f.friend_id = ?) AND f.status = 'accepted' |
| """, (user_id, user_id, user_id)).fetchall() |
|
|
| result = [] |
| for friend in friend_rows: |
| fid = friend["id"] |
| rows = conn.execute( |
| "SELECT * FROM statuses WHERE user_id = ? AND expires_at > ? ORDER BY created_at ASC", |
| (fid, now) |
| ).fetchall() |
| visible = [dict(r) for r in rows if _can_view_status(conn, r, user_id)] |
| if not visible: |
| continue |
|
|
| placeholders = ",".join("?" * len(visible)) |
| viewed_ids = { |
| r["status_id"] for r in conn.execute( |
| f"SELECT status_id FROM status_views WHERE viewer_id = ? AND status_id IN ({placeholders})", |
| [user_id] + [s["id"] for s in visible] |
| ).fetchall() |
| } |
| for s in visible: |
| s["viewed"] = s["id"] in viewed_ids |
|
|
| result.append({ |
| "user_id": fid, |
| "username": friend["username"], |
| "avatar_letter": friend["avatar_letter"], |
| "avatar_url": _avatar_or_hidden(fid, friend["avatar_url"], user_id), |
| "statuses": visible, |
| "all_viewed": all(s["viewed"] for s in visible), |
| }) |
| return result |
| finally: |
| conn.close() |
|
|
|
|
| def get_my_statuses(user_id): |
| conn = get_conn() |
| try: |
| now = int(time.time()) |
| rows = conn.execute( |
| "SELECT * FROM statuses WHERE user_id = ? AND expires_at > ? ORDER BY created_at ASC", |
| (user_id, now) |
| ).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def record_status_view(status_id, viewer_id): |
| conn = get_conn() |
| try: |
| conn.execute( |
| "INSERT OR IGNORE INTO status_views (status_id, viewer_id, viewed_at) VALUES (?, ?, ?)", |
| (status_id, viewer_id, int(time.time())) |
| ) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
|
|
| def get_status_viewers(status_id, owner_id): |
| """Only returns data if owner_id actually owns the status.""" |
| conn = get_conn() |
| try: |
| status = conn.execute("SELECT user_id FROM statuses WHERE id = ?", (status_id,)).fetchone() |
| if not status or status["user_id"] != owner_id: |
| return None |
| rows = conn.execute(""" |
| SELECT u.id, u.username, u.avatar_letter, u.avatar_url, sv.viewed_at |
| FROM status_views sv |
| JOIN users u ON u.id = sv.viewer_id |
| WHERE sv.status_id = ? |
| ORDER BY sv.viewed_at DESC |
| """, (status_id,)).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def delete_status(status_id, user_id): |
| """Returns {"deleted": True, "media_url": ...} on success (media_url may be |
| None for text statuses), or None if not found / not owned by user_id.""" |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT user_id, media_url FROM statuses WHERE id = ?", (status_id,)).fetchone() |
| if not row or row["user_id"] != user_id: |
| return None |
| media_url = row["media_url"] |
| conn.execute("DELETE FROM status_views WHERE status_id = ?", (status_id,)) |
| conn.execute("DELETE FROM status_privacy_list WHERE status_id = ?", (status_id,)) |
| conn.execute("DELETE FROM statuses WHERE id = ?", (status_id,)) |
| conn.commit() |
| return {"deleted": True, "media_url": media_url} |
| finally: |
| conn.close() |
|
|
|
|
| def delete_expired_statuses(): |
| """Deletes all status rows past their expires_at. Returns the list of |
| media_url values (None entries included for text statuses) so the caller |
| can remove any local files on disk.""" |
| conn = get_conn() |
| try: |
| now = int(time.time()) |
| rows = conn.execute("SELECT id, media_url FROM statuses WHERE expires_at <= ?", (now,)).fetchall() |
| if not rows: |
| return [] |
| ids = [r["id"] for r in rows] |
| placeholders = ",".join("?" * len(ids)) |
| conn.execute(f"DELETE FROM status_views WHERE status_id IN ({placeholders})", ids) |
| conn.execute(f"DELETE FROM status_privacy_list WHERE status_id IN ({placeholders})", ids) |
| conn.execute(f"DELETE FROM statuses WHERE id IN ({placeholders})", ids) |
| conn.commit() |
| return [r["media_url"] for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| |
|
|
| def remove_friendship(user_id, friend_id): |
| conn = get_conn() |
| try: |
| conn.execute( |
| "DELETE FROM friendships WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)", |
| (user_id, friend_id, friend_id, user_id) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def block_user(user_id, target_id): |
| """Blocks target_id directly, regardless of prior friendship state.""" |
| conn = get_conn() |
| try: |
| existing = conn.execute( |
| "SELECT id FROM friendships WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)", |
| (user_id, target_id, target_id, user_id) |
| ).fetchone() |
| if existing: |
| conn.execute( |
| "UPDATE friendships SET status='blocked', user_id=?, friend_id=? WHERE id=?", |
| (user_id, target_id, existing["id"]) |
| ) |
| else: |
| conn.execute( |
| "INSERT INTO friendships (user_id, friend_id, status, requested_by, created_at) " |
| "VALUES (?, ?, 'blocked', ?, ?)", |
| (user_id, target_id, user_id, int(time.time())) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| |
|
|
| def _ensure_mutes_table(conn): |
| conn.execute(""" |
| CREATE TABLE IF NOT EXISTS chat_mutes ( |
| user_id INTEGER NOT NULL, |
| chat_id TEXT NOT NULL, |
| is_group INTEGER NOT NULL DEFAULT 0, |
| muted_at INTEGER NOT NULL, |
| PRIMARY KEY (user_id, chat_id) |
| ) |
| """) |
|
|
|
|
| def set_chat_mute(user_id, chat_id, muted, is_group=False): |
| conn = get_conn() |
| try: |
| _ensure_mutes_table(conn) |
| if muted: |
| conn.execute( |
| "INSERT OR REPLACE INTO chat_mutes (user_id, chat_id, is_group, muted_at) VALUES (?, ?, ?, ?)", |
| (user_id, str(chat_id), 1 if is_group else 0, int(time.time())) |
| ) |
| else: |
| conn.execute( |
| "DELETE FROM chat_mutes WHERE user_id=? AND chat_id=?", |
| (user_id, str(chat_id)) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| |
|
|
| def _ensure_disappearing_table(conn): |
| conn.execute(""" |
| CREATE TABLE IF NOT EXISTS disappearing_settings ( |
| user_id INTEGER NOT NULL, |
| chat_id TEXT NOT NULL, |
| seconds INTEGER NOT NULL, |
| set_at INTEGER NOT NULL, |
| PRIMARY KEY (user_id, chat_id) |
| ) |
| """) |
|
|
|
|
| def set_disappearing_duration(user_id, chat_id, seconds): |
| conn = get_conn() |
| try: |
| _ensure_disappearing_table(conn) |
| if seconds and seconds > 0: |
| conn.execute( |
| "INSERT OR REPLACE INTO disappearing_settings (user_id, chat_id, seconds, set_at) VALUES (?, ?, ?, ?)", |
| (user_id, str(chat_id), int(seconds), int(time.time())) |
| ) |
| else: |
| conn.execute( |
| "DELETE FROM disappearing_settings WHERE user_id=? AND chat_id=?", |
| (user_id, str(chat_id)) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def get_disappearing_duration(user_id, chat_id): |
| conn = get_conn() |
| try: |
| _ensure_disappearing_table(conn) |
| row = conn.execute( |
| "SELECT seconds FROM disappearing_settings WHERE user_id=? AND chat_id=?", |
| (user_id, str(chat_id)) |
| ).fetchone() |
| return row["seconds"] if row else None |
| finally: |
| conn.close() |
|
|
|
|
| |
|
|
| def _ensure_group_perm_columns(conn): |
| cols = [r["name"] for r in conn.execute("PRAGMA table_info(groups)").fetchall()] |
| if "send_perm" not in cols: |
| conn.execute("ALTER TABLE groups ADD COLUMN send_perm TEXT DEFAULT 'all'") |
| if "edit_perm" not in cols: |
| conn.execute("ALTER TABLE groups ADD COLUMN edit_perm TEXT DEFAULT 'admins'") |
|
|
|
|
| def get_group_members_detailed(group_id, viewer_id=None): |
| conn = get_conn() |
| try: |
| rows = conn.execute(""" |
| SELECT u.id, u.username, u.avatar_letter, u.avatar_url, u.verified, gm.role |
| FROM group_members gm |
| JOIN users u ON u.id = gm.user_id |
| WHERE gm.group_id = ? |
| ORDER BY CASE gm.role WHEN 'admin' THEN 0 ELSE 1 END, u.username |
| """, (group_id,)).fetchall() |
| result = [dict(r) for r in rows] |
| for r in result: |
| r["avatar_url"] = _avatar_or_hidden(r["id"], r["avatar_url"], viewer_id) |
| return result |
| finally: |
| conn.close() |
|
|
|
|
| def _is_group_owner(conn, group_id, user_id): |
| row = conn.execute("SELECT created_by FROM groups WHERE id=?", (group_id,)).fetchone() |
| return row is not None and row["created_by"] == user_id |
|
|
|
|
| def promote_member(group_id, target_id, requester_id): |
| conn = get_conn() |
| try: |
| if not _is_group_owner(conn, group_id, requester_id): |
| return False, "Only the owner can promote members" |
| conn.execute( |
| "UPDATE group_members SET role='admin' WHERE group_id=? AND user_id=?", |
| (group_id, target_id) |
| ) |
| conn.commit() |
| return True, None |
| finally: |
| conn.close() |
|
|
|
|
| def demote_member(group_id, target_id, requester_id): |
| conn = get_conn() |
| try: |
| if not _is_group_owner(conn, group_id, requester_id): |
| return False, "Only the owner can demote members" |
| conn.execute( |
| "UPDATE group_members SET role='member' WHERE group_id=? AND user_id=?", |
| (group_id, target_id) |
| ) |
| conn.commit() |
| return True, None |
| finally: |
| conn.close() |
|
|
|
|
| def remove_member(group_id, target_id, requester_id): |
| conn = get_conn() |
| try: |
| requester_role = conn.execute( |
| "SELECT role FROM group_members WHERE group_id=? AND user_id=?", |
| (group_id, requester_id) |
| ).fetchone() |
| target_role = conn.execute( |
| "SELECT role FROM group_members WHERE group_id=? AND user_id=?", |
| (group_id, target_id) |
| ).fetchone() |
| is_owner = _is_group_owner(conn, group_id, requester_id) |
| if not requester_role or (requester_role["role"] != "admin" and not is_owner): |
| return False, "Not authorized" |
| if target_role and target_role["role"] == "admin" and not is_owner: |
| return False, "Only the owner can remove an admin" |
| conn.execute( |
| "DELETE FROM group_members WHERE group_id=? AND user_id=?", |
| (group_id, target_id) |
| ) |
| conn.commit() |
| return True, None |
| finally: |
| conn.close() |
|
|
|
|
| def leave_group(group_id, user_id): |
| conn = get_conn() |
| try: |
| conn.execute( |
| "DELETE FROM group_members WHERE group_id=? AND user_id=?", |
| (group_id, user_id) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def delete_group(group_id, requester_id): |
| conn = get_conn() |
| try: |
| if not _is_group_owner(conn, group_id, requester_id): |
| return False |
| conn.execute("DELETE FROM group_messages WHERE group_id=?", (group_id,)) |
| conn.execute("DELETE FROM group_members WHERE group_id=?", (group_id,)) |
| conn.execute("DELETE FROM groups WHERE id=?", (group_id,)) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def update_group_permissions(group_id, requester_id, send_perm=None, edit_perm=None): |
| conn = get_conn() |
| try: |
| _ensure_group_perm_columns(conn) |
| if not _is_group_owner(conn, group_id, requester_id): |
| role = conn.execute( |
| "SELECT role FROM group_members WHERE group_id=? AND user_id=?", |
| (group_id, requester_id) |
| ).fetchone() |
| if not role or role["role"] != "admin": |
| return False |
| if send_perm is not None: |
| conn.execute("UPDATE groups SET send_perm=? WHERE id=?", (send_perm, group_id)) |
| if edit_perm is not None: |
| conn.execute("UPDATE groups SET edit_perm=? WHERE id=?", (edit_perm, group_id)) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def get_group_permissions(group_id): |
| conn = get_conn() |
| try: |
| _ensure_group_perm_columns(conn) |
| row = conn.execute("SELECT send_perm, edit_perm FROM groups WHERE id=?", (group_id,)).fetchone() |
| return dict(row) if row else {"send_perm": "all", "edit_perm": "admins"} |
| finally: |
| conn.close() |
|
|
|
|
| |
|
|
| def _ensure_group_report_column(conn): |
| cols = [r["name"] for r in conn.execute("PRAGMA table_info(reports)").fetchall()] |
| if "reported_group_id" not in cols: |
| conn.execute("ALTER TABLE reports ADD COLUMN reported_group_id INTEGER") |
|
|
|
|
| def create_group_report(reporter_id, group_id, reason): |
| conn = get_conn() |
| try: |
| _ensure_group_report_column(conn) |
| conn.execute( |
| "INSERT INTO reports (reporter_id, reported_user_id, reported_group_id, reason, created_at) " |
| "VALUES (?, 0, ?, ?, ?)", |
| (reporter_id, group_id, reason, int(time.time())) |
| ) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
|
|
| def get_group_invite(group_id, requester_id): |
| """Returns {id, name, invite_code} if requester_id is a member, else None.""" |
| conn = get_conn() |
| try: |
| member = conn.execute( |
| "SELECT 1 FROM group_members WHERE group_id=? AND user_id=?", |
| (group_id, requester_id) |
| ).fetchone() |
| if not member: |
| return None |
| row = conn.execute( |
| "SELECT id, name, invite_code FROM groups WHERE id=?", (group_id,) |
| ).fetchone() |
| return dict(row) if row else None |
| finally: |
| conn.close() |
|
|
|
|
| def clear_group_messages(group_id, requester_id): |
| """Deletes all messages in a group. Admins or the owner only.""" |
| conn = get_conn() |
| try: |
| role = conn.execute( |
| "SELECT role FROM group_members WHERE group_id=? AND user_id=?", |
| (group_id, requester_id) |
| ).fetchone() |
| is_owner = _is_group_owner(conn, group_id, requester_id) |
| if not role or (role["role"] != "admin" and not is_owner): |
| return False |
| conn.execute("DELETE FROM group_messages WHERE group_id=?", (group_id,)) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
| |
|
|
| def create_task(key, label, points, category="General"): |
| conn = get_conn() |
| try: |
| cur = conn.execute( |
| "INSERT INTO tasks (key, label, points, category, active, created_at) VALUES (?, ?, ?, ?, 1, ?)", |
| (key, label, points, category, int(time.time())) |
| ) |
| conn.commit() |
| return cur.lastrowid |
| except sqlite3.IntegrityError: |
| return None |
| finally: |
| conn.close() |
|
|
|
|
| def list_all_tasks(): |
| conn = get_conn() |
| try: |
| rows = conn.execute("SELECT * FROM tasks ORDER BY created_at ASC").fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def toggle_task_active(task_id): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT active FROM tasks WHERE id = ?", (task_id,)).fetchone() |
| if not row: |
| return None |
| new_state = 0 if row["active"] else 1 |
| conn.execute("UPDATE tasks SET active = ? WHERE id = ?", (new_state, task_id)) |
| conn.commit() |
| return bool(new_state) |
| finally: |
| conn.close() |
|
|
|
|
| def get_task_by_id(task_id): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() |
| return dict(row) if row else None |
| finally: |
| conn.close() |
|
|
|
|
| def _get_task_by_key(conn, task_key): |
| row = conn.execute("SELECT * FROM tasks WHERE key = ? AND active = 1", (task_key,)).fetchone() |
| return dict(row) if row else None |
|
|
|
|
| def award_points(user_id, task_key): |
| """Idempotent — awards points for task_key only if not already completed |
| and the task exists and is active. Returns True if newly awarded.""" |
| if user_id == AI_USER_ID: |
| return False |
| conn = get_conn() |
| try: |
| task = _get_task_by_key(conn, task_key) |
| if not task: |
| return False |
| cur = conn.execute( |
| "INSERT OR IGNORE INTO completed_tasks (user_id, task_key, points_awarded, completed_at) VALUES (?, ?, ?, ?)", |
| (user_id, task_key, task["points"], int(time.time())) |
| ) |
| if cur.rowcount == 0: |
| return False |
| conn.execute("UPDATE users SET points = points + ? WHERE id = ?", (task["points"], user_id)) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def admin_award_task(task_id, user_id): |
| """Admin manually awards a task to a specific user, bypassing any auto-detection. |
| Still idempotent — won't double-award the same task to the same user.""" |
| conn = get_conn() |
| try: |
| task = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() |
| if not task: |
| return None, "Task not found" |
| cur = conn.execute( |
| "INSERT OR IGNORE INTO completed_tasks (user_id, task_key, points_awarded, completed_at) VALUES (?, ?, ?, ?)", |
| (user_id, task["key"], task["points"], int(time.time())) |
| ) |
| if cur.rowcount == 0: |
| return False, "Already awarded to this user" |
| conn.execute("UPDATE users SET points = points + ? WHERE id = ?", (task["points"], user_id)) |
| conn.commit() |
| return True, None |
| finally: |
| conn.close() |
|
|
|
|
| def maybe_award_friend_count_tasks(user_id): |
| """Call after a friendship becomes accepted — checks 3/10 friend thresholds.""" |
| count = len(list_friends(user_id)) |
| if count >= 3: |
| award_points(user_id, "add_3_friends") |
| if count >= 10: |
| award_points(user_id, "add_10_friends") |
|
|
|
|
| def get_points_summary(user_id): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT points FROM users WHERE id = ?", (user_id,)).fetchone() |
| points = row["points"] if row and row["points"] is not None else 0 |
|
|
| active_tasks = conn.execute("SELECT * FROM tasks WHERE active = 1").fetchall() |
| task_by_key = {t["key"]: dict(t) for t in active_tasks} |
|
|
| completed_rows = conn.execute( |
| "SELECT task_key, points_awarded, completed_at FROM completed_tasks WHERE user_id = ? ORDER BY completed_at ASC", |
| (user_id,) |
| ).fetchall() |
| completed_keys = {r["task_key"] for r in completed_rows} |
|
|
| completed = [] |
| for r in completed_rows: |
| t = task_by_key.get(r["task_key"]) |
| completed.append({ |
| "id": r["task_key"], |
| "label": t["label"] if t else r["task_key"], |
| "points": r["points_awarded"], |
| }) |
|
|
| available = [ |
| {"id": t["key"], "label": t["label"], "points": t["points"], "category": t["category"]} |
| for t in task_by_key.values() if t["key"] not in completed_keys |
| ] |
| return {"points": points, "completed": completed, "available": available} |
| finally: |
| conn.close() |
|
|
|
|
| |
|
|
| def create_announcement(title, body=None, expires_in_hours=72): |
| """Retires any currently-active announcement, then creates a new active one.""" |
| conn = get_conn() |
| try: |
| now = int(time.time()) |
| expires_at = now + int(expires_in_hours * 3600) if expires_in_hours else None |
| conn.execute("UPDATE announcements SET active = 0 WHERE active = 1") |
| cur = conn.execute( |
| "INSERT INTO announcements (title, body, created_at, expires_at, active) VALUES (?, ?, ?, ?, 1)", |
| (title, body, now, expires_at) |
| ) |
| conn.commit() |
| return cur.lastrowid |
| finally: |
| conn.close() |
|
|
|
|
| def get_active_announcement(): |
| """Returns the current active, non-expired announcement dict, or None.""" |
| conn = get_conn() |
| try: |
| now = int(time.time()) |
| row = conn.execute( |
| "SELECT * FROM announcements WHERE active = 1 AND (expires_at IS NULL OR expires_at > ?) " |
| "ORDER BY created_at DESC LIMIT 1", |
| (now,) |
| ).fetchone() |
| return dict(row) if row else None |
| finally: |
| conn.close() |
|
|
|
|
| def list_announcements(limit=50): |
| conn = get_conn() |
| try: |
| rows = conn.execute( |
| "SELECT * FROM announcements ORDER BY created_at DESC LIMIT ?", (limit,) |
| ).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def deactivate_announcement(announcement_id): |
| conn = get_conn() |
| try: |
| cur = conn.execute("UPDATE announcements SET active = 0 WHERE id = ?", (announcement_id,)) |
| conn.commit() |
| return cur.rowcount > 0 |
| finally: |
| conn.close() |
|
|
|
|
| def clear_direct_messages(user_a, user_b): |
| """Deletes all direct messages between two users, both directions.""" |
| conn = get_conn() |
| try: |
| conn.execute( |
| "DELETE FROM messages WHERE (sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)", |
| (user_a, user_b, user_b, user_a) |
| ) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def list_blocked_users(user_id): |
| """Returns users blocked BY user_id. Relies on block_user() always |
| normalizing so the blocker is stored in the user_id column.""" |
| conn = get_conn() |
| try: |
| rows = conn.execute(""" |
| SELECT u.id, u.username, u.handle, u.avatar_letter, u.avatar_url |
| FROM friendships f |
| JOIN users u ON u.id = f.friend_id |
| WHERE f.user_id = ? AND f.status = 'blocked' |
| """, (user_id,)).fetchall() |
| return [dict(r) for r in rows] |
| finally: |
| conn.close() |
|
|
|
|
| def set_latest_app_version(version, apk_url, notes=None): |
| """Always overwrites the single row (id=1) — there's only ever one 'latest' version.""" |
| conn = get_conn() |
| try: |
| conn.execute(""" |
| INSERT INTO app_version (id, version, apk_url, notes, updated_at) |
| VALUES (1, ?, ?, ?, ?) |
| ON CONFLICT(id) DO UPDATE SET version=excluded.version, apk_url=excluded.apk_url, |
| notes=excluded.notes, updated_at=excluded.updated_at |
| """, (version, apk_url, notes, int(time.time()))) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def get_latest_app_version(): |
| conn = get_conn() |
| try: |
| row = conn.execute("SELECT version, apk_url, notes, updated_at FROM app_version WHERE id = 1").fetchone() |
| return dict(row) if row else None |
| finally: |
| conn.close() |
|
|
|
|
| def set_latest_ota_update(update_id, runtime_version, launch_asset_json, assets_json, notes=None): |
| """Always overwrites the single row (id=1) -- there's only ever one |
| 'latest' OTA update, same pattern as set_latest_app_version. Callers |
| pass launch_asset_json/assets_json as already-serialized JSON strings |
| (the exact objects the Expo Updates manifest needs).""" |
| conn = get_conn() |
| try: |
| conn.execute(""" |
| INSERT INTO ota_update (id, update_id, runtime_version, launch_asset_json, assets_json, notes, created_at) |
| VALUES (1, ?, ?, ?, ?, ?, ?) |
| ON CONFLICT(id) DO UPDATE SET update_id=excluded.update_id, runtime_version=excluded.runtime_version, |
| launch_asset_json=excluded.launch_asset_json, assets_json=excluded.assets_json, |
| notes=excluded.notes, created_at=excluded.created_at |
| """, (update_id, runtime_version, launch_asset_json, assets_json, notes, int(time.time()))) |
| conn.commit() |
| return True |
| finally: |
| conn.close() |
|
|
|
|
| def get_latest_ota_update(): |
| conn = get_conn() |
| try: |
| row = conn.execute(""" |
| SELECT update_id, runtime_version, launch_asset_json, assets_json, notes, created_at |
| FROM ota_update WHERE id = 1 |
| """).fetchone() |
| return dict(row) if row else None |
| finally: |
| conn.close() |
|
|