Nrighton233j
feat: disappearing messages (24h/60h/3d/7d/1mo) for 1:1 chats
f34c6e1
Raw
History Blame Contribute Delete
74.7 kB
"""
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)
)
""")
# Migrate existing DBs: add media columns if they don't exist yet (safe/idempotent)
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)
)
""")
# Migrate groups: type distinguishes a normal group (anyone posts) from a
# channel (only admins post, everyone else is read-only/follower)
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")
# Migrate messages: is_request flags a message as pending approval (non-friend, non-business sender)
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")
# Migrate users: account_type (personal/business/business_noreply) + verified tick
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")
# Migrate friendships: rebuild with a 5-state status (pending/accepted/declined/blocked/canceled)
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 # not a member — caller should treat as forbidden
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()
# ---------- admin ----------
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, always present ---
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,
})
# --- 1:1 friends ---
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 ---
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, # group read-tracking not implemented yet
"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()
# ---------- statuses ----------
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()
# ---------- unfriend / block ----------
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()
# ---------- chat mute ----------
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()
# ---------- disappearing messages ----------
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()
# ---------- group management ----------
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()
# ---------- group reports ----------
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()
# ---------- points / gamification (dynamic, DB-backed) ----------
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()
# ---------- announcements (single active one at a time) ----------
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()