This commit is contained in:
2026-08-12 18:22:26 -05:00
parent 7e3b3dff6d
commit 9cc7093c48
6 changed files with 102 additions and 39 deletions
+31
View File
@@ -0,0 +1,31 @@
import sqlite3
import time
LOCKED_MSGS = ("database is locked", "database is busy")
def connect(db_path, timeout=30):
"""Open a SQLite connection hardened against cross-process contention.
Enables WAL mode and a busy timeout so that the multiple bot processes
sharing a database file wait for each other instead of raising
``OperationalError: database is locked`` immediately.
"""
conn = sqlite3.connect(db_path, timeout=timeout)
conn.execute("PRAGMA busy_timeout=%d" % (timeout * 1000))
conn.execute("PRAGMA journal_mode=WAL")
return conn
def commit(conn, retries=5, backoff=0.2):
"""Commit, retrying briefly when the database is locked by another writer."""
for attempt in range(retries):
try:
conn.commit()
return
except sqlite3.OperationalError as e:
if str(e).lower() in LOCKED_MSGS and attempt < retries - 1:
time.sleep(backoff * (attempt + 1))
continue
raise
+6 -5
View File
@@ -11,6 +11,7 @@ import time
import sys import sys
from nio import AsyncClient, LoginResponse, RoomMessagesResponse, SyncResponse from nio import AsyncClient, LoginResponse, RoomMessagesResponse, SyncResponse
from room import Room from room import Room
import dbutil
class UserListManager: class UserListManager:
@@ -29,7 +30,7 @@ class UserListManager:
def init_user_db(self): def init_user_db(self):
"""Initialize the user list database.""" """Initialize the user list database."""
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -67,7 +68,7 @@ class UserListManager:
if 'most_recent_post' not in cols: if 'most_recent_post' not in cols:
c.execute('ALTER TABLE fetch_log ADD COLUMN most_recent_post INTEGER DEFAULT 0') c.execute('ALTER TABLE fetch_log ADD COLUMN most_recent_post INTEGER DEFAULT 0')
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
async def get_room_members(self): async def get_room_members(self):
@@ -163,7 +164,7 @@ class UserListManager:
print("No users to write to database") print("No users to write to database")
return return
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
current_time = int(time.time()) current_time = int(time.time())
@@ -209,13 +210,13 @@ class UserListManager:
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
''', (current_time, len(self.users), datetime.now().isoformat(), getattr(self, 'most_recent_post', 0))) ''', (current_time, len(self.users), datetime.now().isoformat(), getattr(self, 'most_recent_post', 0)))
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
print(f"Wrote {len(self.users)} users to {self.db_path}") print(f"Wrote {len(self.users)} users to {self.db_path}")
def read_users_from_db(self): def read_users_from_db(self):
"""Read all users from the database.""" """Read all users from the database."""
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
c.execute('SELECT user_id, display_name, last_seen, last_updated, num_posts, last_active FROM users ORDER BY num_posts DESC, display_name') c.execute('SELECT user_id, display_name, last_seen, last_updated, num_posts, last_active FROM users ORDER BY num_posts DESC, display_name')
users = [] users = []
+10 -10
View File
@@ -56,8 +56,8 @@ async def main():
def init_getvotes_table(room): def init_getvotes_table(room):
"""Initialize the getvotes tracking table in the database.""" """Initialize the getvotes tracking table in the database."""
import sqlite3 import dbutil
conn = sqlite3.connect(room.db_path) conn = dbutil.connect(room.db_path)
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
CREATE TABLE IF NOT EXISTS getvotes ( CREATE TABLE IF NOT EXISTS getvotes (
@@ -70,14 +70,14 @@ def init_getvotes_table(room):
UNIQUE(sender, timestamp) UNIQUE(sender, timestamp)
) )
''') ''')
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
def get_last_getvotes_timestamp(room): def get_last_getvotes_timestamp(room):
"""Get the timestamp of the last !getvotes command for this room.""" """Get the timestamp of the last !getvotes command for this room."""
import sqlite3 import dbutil
conn = sqlite3.connect(room.db_path) conn = dbutil.connect(room.db_path)
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
SELECT MAX(timestamp) FROM getvotes SELECT MAX(timestamp) FROM getvotes
@@ -91,10 +91,10 @@ def get_last_getvotes_timestamp(room):
def record_getvotes(room, sender, timestamp, event_id=None): def record_getvotes(room, sender, timestamp, event_id=None):
"""Record a !getvotes detection; returns True if newly recorded (not duplicate).""" """Record a !getvotes detection; returns True if newly recorded (not duplicate)."""
import sqlite3 import dbutil
import datetime import datetime
conn = sqlite3.connect(room.db_path) conn = dbutil.connect(room.db_path)
c = conn.cursor() c = conn.cursor()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds') dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute(''' c.execute('''
@@ -102,7 +102,7 @@ def record_getvotes(room, sender, timestamp, event_id=None):
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str)) ''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1 inserted = c.rowcount == 1
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
return inserted return inserted
@@ -154,10 +154,10 @@ def format_vote_results(room):
return "❌ No vote data available yet. The voting system may not have received any votes." return "❌ No vote data available yet. The voting system may not have received any votes."
try: try:
import sqlite3 import dbutil
# Read votes from database # Read votes from database
conn = sqlite3.connect(vote_db) conn = dbutil.connect(vote_db)
c = conn.cursor() c = conn.cursor()
# Get all votes grouped by submission # Get all votes grouped by submission
+2 -2
View File
@@ -1,17 +1,17 @@
import yaml import yaml
import re import re
import asyncio import asyncio
import sqlite3
import sys import sys
from nio import AsyncClient, LoginResponse, RoomSendResponse, RoomMessageText, SyncResponse from nio import AsyncClient, LoginResponse, RoomSendResponse, RoomMessageText, SyncResponse
import time import time
from post import Post from post import Post
from room import Room from room import Room
import dbutil
def get_last_getvotes_timestamp(room): def get_last_getvotes_timestamp(room):
"""Get the timestamp of the last !getvotes command for this room.""" """Get the timestamp of the last !getvotes command for this room."""
conn = sqlite3.connect(room.db_path) conn = dbutil.connect(room.db_path)
c = conn.cursor() c = conn.cursor()
# Check if getvotes table exists # Check if getvotes table exists
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='getvotes'") c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='getvotes'")
+22 -21
View File
@@ -13,6 +13,7 @@ import asyncio
from nio import AsyncClient, LoginResponse, RoomMessageText, SyncResponse, RoomSendResponse from nio import AsyncClient, LoginResponse, RoomMessageText, SyncResponse, RoomSendResponse
from post import Post from post import Post
import requests import requests
import dbutil
default_search_terms = ["!suggest", "!Suggest", "!suggestion", "!Suggestion", "SUGGEST", "!sug"] default_search_terms = ["!suggest", "!Suggest", "!suggestion", "!Suggestion", "SUGGEST", "!sug"]
@@ -61,7 +62,7 @@ class Room:
return f"suggestions_{safe}.db" return f"suggestions_{safe}.db"
def init_db(self): def init_db(self):
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
CREATE TABLE IF NOT EXISTS suggestions ( CREATE TABLE IF NOT EXISTS suggestions (
@@ -137,7 +138,7 @@ class Room:
UNIQUE(sender, timestamp) UNIQUE(sender, timestamp)
) )
''') ''')
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
async def login_to_matrix(self): async def login_to_matrix(self):
@@ -297,7 +298,7 @@ class Room:
def write_to_db(self): def write_to_db(self):
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
self.newly_inserted_posts = [] self.newly_inserted_posts = []
@@ -311,12 +312,12 @@ class Room:
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
continue continue
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
def update_last_run(self): def update_last_run(self):
room_id = self.config.room_id room_id = self.config.room_id
conn = sqlite3.connect("last_run.db") conn = dbutil.connect("last_run.db")
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
CREATE TABLE IF NOT EXISTS last_run ( CREATE TABLE IF NOT EXISTS last_run (
@@ -333,12 +334,12 @@ class Room:
now = int(_time.time()) now = int(_time.time())
now_str = datetime.datetime.now().isoformat(sep=' ', timespec='seconds') now_str = datetime.datetime.now().isoformat(sep=' ', timespec='seconds')
c.execute('INSERT OR REPLACE INTO last_run (room_id, timestamp, datetime) VALUES (?, ?, ?)', (room_id, now, now_str)) c.execute('INSERT OR REPLACE INTO last_run (room_id, timestamp, datetime) VALUES (?, ?, ?)', (room_id, now, now_str))
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
def get_last_run(self): def get_last_run(self):
room_id = self.config.room_id room_id = self.config.room_id
conn = sqlite3.connect("last_run.db") conn = dbutil.connect("last_run.db")
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
CREATE TABLE IF NOT EXISTS last_run ( CREATE TABLE IF NOT EXISTS last_run (
@@ -362,7 +363,7 @@ class Room:
# New: record a !gettitles detection; returns True if newly recorded (not duplicate) # New: record a !gettitles detection; returns True if newly recorded (not duplicate)
def record_gettitles(self, sender, timestamp, event_id=None): def record_gettitles(self, sender, timestamp, event_id=None):
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds') dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute(''' c.execute('''
@@ -370,14 +371,14 @@ class Room:
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str)) ''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1 inserted = c.rowcount == 1
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
return inserted return inserted
# New: record a !mayhem detection; returns True if newly recorded (not duplicate) # New: record a !mayhem detection; returns True if newly recorded (not duplicate)
def record_mayhem(self, sender, timestamp, event_id=None): def record_mayhem(self, sender, timestamp, event_id=None):
"""Record a !mayhem detection; returns True if newly recorded (not duplicate)""" """Record a !mayhem detection; returns True if newly recorded (not duplicate)"""
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds') dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute(''' c.execute('''
@@ -385,14 +386,14 @@ class Room:
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str)) ''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1 inserted = c.rowcount == 1
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
return inserted return inserted
# New: record a !countvotes detection; returns True if newly recorded (not duplicate) # New: record a !countvotes detection; returns True if newly recorded (not duplicate)
def record_countvotes(self, sender, timestamp, event_id=None): def record_countvotes(self, sender, timestamp, event_id=None):
"""Record a !countvotes detection; returns True if newly recorded (not duplicate)""" """Record a !countvotes detection; returns True if newly recorded (not duplicate)"""
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds') dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute(''' c.execute('''
@@ -400,14 +401,14 @@ class Room:
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str)) ''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1 inserted = c.rowcount == 1
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
return inserted return inserted
# New: record a !voteurl detection; returns True if newly recorded (not duplicate) # New: record a !voteurl detection; returns True if newly recorded (not duplicate)
def record_voteurl(self, sender, timestamp, event_id=None): def record_voteurl(self, sender, timestamp, event_id=None):
"""Record a !voteurl detection; returns True if newly recorded (not duplicate)""" """Record a !voteurl detection; returns True if newly recorded (not duplicate)"""
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds') dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute(''' c.execute('''
@@ -415,7 +416,7 @@ class Room:
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str)) ''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1 inserted = c.rowcount == 1
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
return inserted return inserted
@@ -611,7 +612,7 @@ class Room:
self.formatted_sugs = "\n".join(lines) self.formatted_sugs = "\n".join(lines)
def read_db(self, since_timestamp=None, min_timestamp=None, only_unposted=False): def read_db(self, since_timestamp=None, min_timestamp=None, only_unposted=False):
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
query = "SELECT user, display_name, message, timestamp, age, is_posted FROM suggestions" query = "SELECT user, display_name, message, timestamp, age, is_posted FROM suggestions"
params = [] params = []
@@ -633,7 +634,7 @@ class Room:
self.posts = [Post(poster=row[0], display_name=row[1], content=row[2], time=row[3], age=row[4], is_posted=bool(row[5])) for row in rows] self.posts = [Post(poster=row[0], display_name=row[1], content=row[2], time=row[3], age=row[4], is_posted=bool(row[5])) for row in rows]
def get_last_post(self): def get_last_post(self):
conn = sqlite3.connect("last_post.db") conn = dbutil.connect("last_post.db")
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
CREATE TABLE IF NOT EXISTS last_post ( CREATE TABLE IF NOT EXISTS last_post (
@@ -654,7 +655,7 @@ class Room:
return None return None
def update_last_post(self): def update_last_post(self):
conn = sqlite3.connect("last_post.db") conn = dbutil.connect("last_post.db")
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
CREATE TABLE IF NOT EXISTS last_post ( CREATE TABLE IF NOT EXISTS last_post (
@@ -672,7 +673,7 @@ class Room:
now_str = datetime.datetime.now().isoformat(sep=' ', timespec='seconds') now_str = datetime.datetime.now().isoformat(sep=' ', timespec='seconds')
c.execute('INSERT OR REPLACE INTO last_post (room_id, timestamp, datetime) VALUES (?, ?, ?)', c.execute('INSERT OR REPLACE INTO last_post (room_id, timestamp, datetime) VALUES (?, ?, ?)',
(self.config.room_id, now, now_str)) (self.config.room_id, now, now_str))
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
async def post_suggestions(self): async def post_suggestions(self):
@@ -787,14 +788,14 @@ class Room:
if posts is None: if posts is None:
posts = self.posts posts = self.posts
conn = sqlite3.connect(self.db_path) conn = dbutil.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
for post in posts: for post in posts:
c.execute( c.execute(
"UPDATE suggestions SET is_posted = 1 WHERE user = ? AND message = ? AND timestamp = ?", "UPDATE suggestions SET is_posted = 1 WHERE user = ? AND message = ? AND timestamp = ?",
(post.poster, post.content, post.time) (post.poster, post.content, post.time)
) )
conn.commit() dbutil.commit(conn)
conn.close() conn.close()
async def close(self): async def close(self):
+31 -1
View File
@@ -133,6 +133,7 @@ cleanup() {
kill "$pid" 2>/dev/null || true kill "$pid" 2>/dev/null || true
fi fi
rm -f "$pidfile" rm -f "$pidfile"
rm -f "$PID_DIR/$name.cmd"
fi fi
done done
fi fi
@@ -174,6 +175,7 @@ start_go_service() {
echo -e "${GREEN}Starting $name...${NC}" echo -e "${GREEN}Starting $name...${NC}"
(cd "$dir" && ./"$binary" > "$LOG_DIR/$name.log" 2>&1) & (cd "$dir" && ./"$binary" > "$LOG_DIR/$name.log" 2>&1) &
echo $! > "$PID_DIR/$name.pid" echo $! > "$PID_DIR/$name.pid"
echo "(cd \"$dir\" && .\"/$binary\" > \"$LOG_DIR/$name.log\" 2>&1) &" > "$PID_DIR/$name.cmd"
# Brief wait to check if it started successfully # Brief wait to check if it started successfully
sleep 1 sleep 1
@@ -203,6 +205,7 @@ start_python_service() {
# Activate virtual environment and run script # Activate virtual environment and run script
(source .venv/bin/activate && python3 "$script" $config_arg > "$LOG_DIR/$label.log" 2>&1) & (source .venv/bin/activate && python3 "$script" $config_arg > "$LOG_DIR/$label.log" 2>&1) &
echo $! > "$PID_DIR/$label.pid" echo $! > "$PID_DIR/$label.pid"
echo "(source .venv/bin/activate && python3 \"$script\" $config_arg > \"$LOG_DIR/$label.log\" 2>&1) &" > "$PID_DIR/$label.cmd"
# Brief wait to check if it started successfully # Brief wait to check if it started successfully
sleep 1 sleep 1
@@ -324,6 +327,32 @@ echo ""
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}" echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}"
echo "" echo ""
# Function to restart a dead service from its saved relaunch command
restart_service() {
local name=$1
local cmd_file="$PID_DIR/$name.cmd"
if [ ! -f "$cmd_file" ]; then
echo -e "${RED}$name: no restart command stored, cannot auto-restart${NC}"
return 1
fi
echo -e "${YELLOW}Restarting $name...${NC}"
rm -f "$PID_DIR/$name.pid"
# shellcheck disable=SC2046
eval "$(cat "$cmd_file")"
echo $! > "$PID_DIR/$name.pid"
sleep 1
if kill -0 $(cat "$PID_DIR/$name.pid") 2>/dev/null; then
echo -e "${GREEN}$name restarted (PID: $(cat "$PID_DIR/$name.pid"))${NC}"
return 0
else
echo -e "${RED}$name failed to restart (check $LOG_DIR/$name.log)${NC}"
return 1
fi
}
# Keep script running and monitor processes # Keep script running and monitor processes
while true; do while true; do
# Check if all processes are still running # Check if all processes are still running
@@ -335,12 +364,13 @@ while true; do
name=$(basename "$pidfile" .pid) name=$(basename "$pidfile" .pid)
echo -e "${RED}Warning: $name (PID: $pid) has stopped unexpectedly${NC}" echo -e "${RED}Warning: $name (PID: $pid) has stopped unexpectedly${NC}"
all_running=false all_running=false
restart_service "$name"
fi fi
fi fi
done done
if [ "$all_running" = false ]; then if [ "$all_running" = false ]; then
echo -e "${YELLOW}Some services have stopped. Check logs in $LOG_DIR${NC}" echo -e "${YELLOW}Some services were restarted. Check logs in $LOG_DIR${NC}"
fi fi
sleep 10 sleep 10