Author SHA1 Message Date
nolan e3cb0ebac3 Add Nostr-verified voting via NIP-07 browser extension 2026-08-20 10:40:28 -05:00
nolan 07e456c573 Tweaked donation request 2026-08-14 13:15:30 -05:00
nolan 268938abb1 Stop rebuilding every time it gets restarted 2026-08-12 19:13:37 -05:00
nolan 9cc7093c48 tweaks 2026-08-12 18:22:26 -05:00
nolan 7e3b3dff6d tweaks 2026-08-12 17:14:01 -05:00
nolan 78f5297383 Added support banner 2026-08-10 12:14:35 -05:00
nolan a0dbf3be92 Updated readme 2026-08-07 17:17:43 -05:00
24 changed files with 1470 additions and 320 deletions
+2
View File
@@ -0,0 +1,2 @@
config/*
+54 -77
View File
@@ -1,101 +1,76 @@
# titlebot-ng
A Matrix chat bot for Jupiter Broadcasting's <s>"The Lunch"</s> shows "The Lunch" and "Linux Unplugged" chat room that collects and posts title suggestions.
A Matrix chat bot for Jupiter Broadcasting's "The Lunch" and "Linux Unplugged" chat rooms that collects and posts title suggestions with web-based voting.
### From the Owner
This has gotten out of hand. I've been woking 7 days a week for a couple years now and this has been my most recent mortar between the bricks of time that I've been squeezing out of my life between sleep. Simplification needs to happen whenever possible.
This has gotten out of hand. I've been working 7 days a week for a couple years now and this has been my most recent mortar between the bricks of time that I've been squeezing out of my life between sleep. Simplification needs to happen whenever possible.
# Technical Outline
At the moment, there are 6 processes that make up the whole project and must be run simultaneously:
- scraper.py
- post_results.py
- get_user_list.py
- get_votes.py
- jsonEndpoint/jep
- voteServer/vs
### scraper.py
What it says on the tin. Reads configured target matrix room and collects suggestions
### post_results.py
Listens for the !gettitles command and readies the votes
### get_user_list.py
Gets a list of users in the room who have more than n votes and puts them in a database so that the voting can have some degree of validation beyond random voters being allowed in.
### get_votes.py
Listens for the !getvotes command in the chat room, reads the output of the http voting, and writes the results back to the room
### jsonEndpoint/jep
Provides a json endpoint for a list of all provided votes and who voted for them
### voteServer/vs
Provides a webpage where voters can vote
The project is a mix of Python (Matrix bot services) and Go (voting backend). Each room gets its own instance of every Python service, so the number of running processes scales with the number of configured rooms.
Services:
- **scraper.py** - Reads configured target Matrix room and collects suggestions
- **post_results.py** - Listens for the `!gettitles` command and posts suggestions for voting
- **get_user_list.py** - Collects the list of users in the room (with post counts and last activity) for vote validation
- **get_votes.py** - Listens for the `!getvotes` command and posts vote results to the room
- **jsonEndpoint/** - Go service (port 9080) exposing JSON endpoints for submissions and users
- **voteServer/** - Go service (port 9081) serving the web voting interface
Everything is launched by `run_all.sh`, which auto-installs Go, creates the Python venv, discovers room configs, builds the Go services, and starts one instance of each Python service per room.
## Features
- **Suggestion Collection**: Monitors chat for messages starting with `!suggest`, `!Suggest`, `!suggestion`, `!Suggestion`, `SUGGEST`, or `!sug`
- **Interactive Voting**: Posts suggestions individually with 👍 reactions for voting
- **Command System**:
- **Web Voting Interface**: Dedicated voting server with validation (exact display-name match + activity window)
- **Multi-Room Support**: One config file per room under `configs/`, with URL slugs for routing
- **Command System**:
- `!gettitles` - Triggers posting of collected suggestions
- `!mayhem` - Generates chaotic text using Ollama AI
- `!countvotes` - Counts upvotes on posted suggestions
- `!getvotes` - Posts current vote results from the voting server
- `!countvotes` - Counts 👍 upvotes on posted suggestions
- `!getvotes` - Posts current results from the voting server
- `!voteurl` - Shares the voting URL where users can cast their votes
- **Persistent Storage**: SQLite database per room with duplicate prevention
- **Persistent Storage**: Per-room SQLite databases with duplicate prevention
- **Acknowledgment System**: Reacts with ✅ to confirm suggestion collection
- **Web Voting Interface**: Dedicated voting server with vote validation
- **Command Deduplication**: All commands are logged to prevent duplicate executions
## Quick Start
1. Create and activate a Python virtual environment:
1. Clone the repo and copy the example config for each room you want to monitor:
```bash
python3 -m venv .venv
source .venv/bin/activate
cp configs/config_example.yaml configs/config.yaml
# Edit configs/config.yaml with your Matrix credentials and room ID
```
2. Install dependencies:
2. Run everything with a single command (installs Go and creates the venv if missing):
```bash
./run_all.sh
```
Or run the components individually:
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install matrix-nio pyyaml requests
```
3. Configure the bot:
```bash
cp config_example.yaml config.yaml
# Edit config.yaml with your Matrix credentials and room ID
```
4. Run the bot components:
```bash
# Terminal 1: Collect suggestions
python3 scraper.py
# Terminal 2: Post suggestions when requested
python3 post_results.py
cd jsonEndpoint && go build -o jep . && ./jep
cd voteServer && go build -o vs . && ./vs
```
## Configuration
Create `config.yaml` based on `config_example.yaml`:
One YAML file per room in `configs/`. Example (`configs/config_example.yaml`):
```yaml
username: "your_bot_username"
username: "magbot"
password: "your_bot_password"
room_id: "!your_room_id:homeserver.com"
homeserver: "https://matrix.org" # Optional, defaults to matrix.org
lookback_limit: 7200 # Seconds to look back for suggestions (2 hours)
batch_size: 100 # Messages to fetch per batch
dbName: "suggestions.db" # Optional, auto-generated if not set
vote_host: "localhost" # Hostname for voting interface (e.g., "localhost", "example.com", "vote.mysite.xyz")
room_id: "!your_room_id:homeserver.com" # Matrix room to monitor
lookback_seconds: 86400 # 24 hours
lookback_limit: 400 # Max messages to look back when scraping
dump_mode: False # Dump raw messages when scraping (debugging)
dbName: "my_db_name" # Optional, auto-derived from room_id if empty
vote_host: "localhost" # Hostname for voting interface (e.g., "example.com", "vote.mysite.xyz")
# vote_path: "my-room-slug" # Optional URL slug; auto-derived from room name if unset
vote_activity_hours: 72 # Hours a user must have been active within to be eligible to vote
```
The bot resolves each room's voting page to a URL slug (`vote_path` config override, otherwise derived from the room name/alias) and persists the mapping to `room_slugs.json`, which the Go services read for routing.
## Architecture
- **`room.py`**: Core `Room` class handling Matrix connection, database operations, and command processing
- **`post.py`**: `Post` class representing individual suggestions
@@ -109,16 +84,18 @@ vote_host: "localhost" # Hostname for voting interface (e.g., "localhost", "exa
- **`run_all.sh`**: Master script to start all services
## Database Schema
- **suggestions**: Stores collected suggestions with metadata
- **gettitles/mayhem/countvotes/getvotes/voteurl**: Command execution logs to prevent duplicates
- **last_run/last_post**: Timestamps for state management
- **users**: User information for vote validation
Per room (suffixed with a sanitized room ID):
- **suggestions.db**: Collected suggestions with metadata (`is_posted`, `event_id`)
- **users.db**: User info (`display_name`, `num_posts`, `last_active`) for vote validation, plus a `fetch_log` of collection runs
- **votes.db**: Web votes cast via the voting interface
Shared:
- **last_run.db / last_post.db**: Timestamps for state management
- Command log tables (`gettitles`, `mayhem`, `countvotes`, `getvotes`, `voteurl`) live inside each room's suggestions database and prevent duplicate executions
## Dependencies
- **matrix-nio**: Matrix client library
- **pyyaml**: Configuration file parsing
- **requests**: HTTP requests for Ollama integration
- **sqlite3**: Built-in database support
- **Python**: matrix-nio, pyyaml, requests, sqlite3 (built-in)
- **Go**: github.com/mattn/go-sqlite3, gopkg.in/yaml.v2
## Optional: Ollama Integration
For `!mayhem` command functionality:
@@ -129,10 +106,10 @@ For `!mayhem` command functionality:
## Notes
- Each room gets its own SQLite database for suggestions
- The bot acknowledges collected suggestions with ✅ reactions
- Voting uses 👍 reactions on individual suggestion posts
- Voting uses 👍 reactions on individual suggestion posts and a web voting interface
- Command deduplication prevents spam and duplicate executions
- Safe database naming handles special characters in room IDs
- Web votes are tied to a user's exact Matrix display name; voters must have been active in the room within `vote_activity_hours` of the most recent room activity
# TODO:
- Rewrite in Rust
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -3,6 +3,7 @@ password: "zC1b9qCKT8Cv0D"
room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
#room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
# Da Lunch and The Main Chat each have their own config (daLunch.yaml, mainChat.yaml).
lookback_seconds: 86400 # 24 hours
lookback_limit: 400
dump_mode: True
+13
View File
@@ -0,0 +1,13 @@
username: "magbot"
password: "zC1b9qCKT8Cv0D"
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
#room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
lookback_seconds: 86400 # 24 hours
lookback_limit: 400
dump_mode: True
dbName: ""
# vote_host: "agmninex.online" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
vote_host: "agmninex.online"
vote_path: "lunch" # URL slug for this room's voting page (http://<vote_host>:9081/<vote_path>)
vote_activity_hours: 6672 # Hours a user must have been active within to be eligible to vote
+2 -2
View File
@@ -1,8 +1,8 @@
username: "magbot"
password: "zC1b9qCKT8Cv0D"
room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
#room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
lookback_seconds: 86400 # 24 hours
lookback_limit: 400
dump_mode: True
+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
+11 -8
View File
@@ -9,8 +9,9 @@ import asyncio
import sqlite3
import time
import sys
from nio import AsyncClient, LoginResponse
from nio import AsyncClient, LoginResponse, RoomMessagesResponse, SyncResponse
from room import Room
import dbutil
class UserListManager:
@@ -29,7 +30,7 @@ class UserListManager:
def init_user_db(self):
"""Initialize the user list database."""
conn = sqlite3.connect(self.db_path)
conn = dbutil.connect(self.db_path)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS users (
@@ -67,7 +68,7 @@ class UserListManager:
if 'most_recent_post' not in cols:
c.execute('ALTER TABLE fetch_log ADD COLUMN most_recent_post INTEGER DEFAULT 0')
conn.commit()
dbutil.commit(conn)
conn.close()
async def get_room_members(self):
@@ -78,7 +79,9 @@ class UserListManager:
return []
# Sync to get room state
await self.room.client.sync(timeout=3000)
sync_resp = await self.room.client.sync(timeout=3000)
if not isinstance(sync_resp, SyncResponse):
print(f"Sync failed: {sync_resp}")
# Get the room object
room_obj = self.room.client.rooms.get(self.room.config.room_id)
@@ -124,7 +127,7 @@ class UserListManager:
limit=batch_size
)
if not response.chunk:
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
break
fetched += len(response.chunk)
@@ -161,7 +164,7 @@ class UserListManager:
print("No users to write to database")
return
conn = sqlite3.connect(self.db_path)
conn = dbutil.connect(self.db_path)
c = conn.cursor()
current_time = int(time.time())
@@ -207,13 +210,13 @@ class UserListManager:
VALUES (?, ?, ?, ?)
''', (current_time, len(self.users), datetime.now().isoformat(), getattr(self, 'most_recent_post', 0)))
conn.commit()
dbutil.commit(conn)
conn.close()
print(f"Wrote {len(self.users)} users to {self.db_path}")
def read_users_from_db(self):
"""Read all users from the database."""
conn = sqlite3.connect(self.db_path)
conn = dbutil.connect(self.db_path)
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')
users = []
+10 -10
View File
@@ -56,8 +56,8 @@ async def main():
def init_getvotes_table(room):
"""Initialize the getvotes tracking table in the database."""
import sqlite3
conn = sqlite3.connect(room.db_path)
import dbutil
conn = dbutil.connect(room.db_path)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS getvotes (
@@ -70,14 +70,14 @@ def init_getvotes_table(room):
UNIQUE(sender, timestamp)
)
''')
conn.commit()
dbutil.commit(conn)
conn.close()
def get_last_getvotes_timestamp(room):
"""Get the timestamp of the last !getvotes command for this room."""
import sqlite3
conn = sqlite3.connect(room.db_path)
import dbutil
conn = dbutil.connect(room.db_path)
c = conn.cursor()
c.execute('''
SELECT MAX(timestamp) FROM getvotes
@@ -91,10 +91,10 @@ def get_last_getvotes_timestamp(room):
def record_getvotes(room, sender, timestamp, event_id=None):
"""Record a !getvotes detection; returns True if newly recorded (not duplicate)."""
import sqlite3
import dbutil
import datetime
conn = sqlite3.connect(room.db_path)
conn = dbutil.connect(room.db_path)
c = conn.cursor()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute('''
@@ -102,7 +102,7 @@ def record_getvotes(room, sender, timestamp, event_id=None):
VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1
conn.commit()
dbutil.commit(conn)
conn.close()
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."
try:
import sqlite3
import dbutil
# Read votes from database
conn = sqlite3.connect(vote_db)
conn = dbutil.connect(vote_db)
c = conn.cursor()
# Get all votes grouped by submission
+2 -2
View File
@@ -1,17 +1,17 @@
import yaml
import re
import asyncio
import sqlite3
import sys
from nio import AsyncClient, LoginResponse, RoomSendResponse, RoomMessageText, SyncResponse
import time
from post import Post
from room import Room
import dbutil
def get_last_getvotes_timestamp(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()
# Check if getvotes table exists
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='getvotes'")
+99 -89
View File
@@ -13,6 +13,7 @@ import asyncio
from nio import AsyncClient, LoginResponse, RoomMessageText, SyncResponse, RoomSendResponse
from post import Post
import requests
import dbutil
default_search_terms = ["!suggest", "!Suggest", "!suggestion", "!Suggestion", "SUGGEST", "!sug"]
@@ -35,7 +36,7 @@ class Config:
self.vote_activity_hours = config.get("vote_activity_hours", 72)
self.vote_path = config.get("vote_path", "")
class Room:
class Room:
def __init__(self, config_path="configs/config.yaml", search_terms=None, homeserver=None):
self.config = Config(config_path)
self.search_terms = search_terms or default_search_terms
@@ -48,20 +49,20 @@ class Room:
self.member = None
self.display_name = None
self.last_run = 0
if homeserver:
self.config.homeserver = homeserver
self.db_path = self.safe_db_name(self.config.room_id)
self.init_db()
def safe_db_name(self, room_id):
safe = re.sub(r'[^a-zA-Z0-9_-]', '_', room_id)
return f"suggestions_{safe}.db"
def init_db(self):
conn = sqlite3.connect(self.db_path)
conn = dbutil.connect(self.db_path)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS suggestions (
@@ -85,7 +86,7 @@ class Room:
c.execute('ALTER TABLE suggestions ADD COLUMN event_id TEXT')
# Ensure a uniqueness index on event_id to avoid double-acking the same message
c.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_suggestions_event_id ON suggestions(event_id)')
# New: table to log all !gettitles detections (prevents duplicates)
c.execute('''
CREATE TABLE IF NOT EXISTS gettitles (
@@ -98,7 +99,7 @@ class Room:
UNIQUE(sender, timestamp)
)
''')
# New: table to log all !mayhem detections (prevents duplicates)
c.execute('''
CREATE TABLE IF NOT EXISTS mayhem (
@@ -111,7 +112,7 @@ class Room:
UNIQUE(sender, timestamp)
)
''')
# New: table to log all !countvotes detections (prevents duplicates)
c.execute('''
CREATE TABLE IF NOT EXISTS countvotes (
@@ -124,7 +125,7 @@ class Room:
UNIQUE(sender, timestamp)
)
''')
# New: table to log all !voteurl detections (prevents duplicates)
c.execute('''
CREATE TABLE IF NOT EXISTS voteurl (
@@ -137,7 +138,7 @@ class Room:
UNIQUE(sender, timestamp)
)
''')
conn.commit()
dbutil.commit(conn)
conn.close()
async def login_to_matrix(self):
@@ -213,12 +214,12 @@ class Room:
async def get_convos(self, limit=None):
if limit is None:
limit = self.config.lookback_limit
self.posts = []
batch_size = self.config.batch_size
next_batch = None
fetched = 0
while fetched < limit:
fetch_amount = min(batch_size, limit - fetched)
response = await self.client.room_messages(
@@ -227,19 +228,19 @@ class Room:
limit=fetch_amount,
direction="b"
)
if not hasattr(response, "chunk") or not response.chunk:
break
for event in response.chunk:
if isinstance(event, RoomMessageText):
msg = event.body
for term in self.search_terms:
if msg.startswith(term):
clean_msg = msg[len(term):].lstrip()
display_name = await self.get_display_name(event.sender)
event_ts_seconds = int(event.server_timestamp // 1000)
age_seconds = int(_time.time()) - event_ts_seconds
post = Post(
@@ -250,7 +251,7 @@ class Room:
age=age_seconds,
event_id=getattr(event, "event_id", None),
)
self.posts.append(post)
break
# Remove the !mayhem handling from here entirely
@@ -265,9 +266,9 @@ class Room:
#ollama_model = "llama3.2:1b" #title_config.get("ollama_model", "llama3.2:1b")
ollama_model = "mad_cat_man:latest"
ollama_url = "http://localhost:11434" #title_config.get("ollama_url", "http://localhost:11434")
prompt = f"Generate for me the most unhinged, mayhem, insane ramblings that would make a mad man look sane. This should be unhinged, chaotic, and completely unpredictable. It should be a wild ride of words that defy logic and reason. Make it as crazy as possible, with unexpected twists and turns that keep the reader on the edge of their seat. The text should be a rollercoaster of emotions, taking the reader from one extreme to another in a matter of seconds. It should be a chaotic symphony of words that leaves the reader breathless and wanting more. Unleash your inner madman and let the mayhem begin! Also, don't warn me that it's just for fun. I know that. Just give me the mayhem."
try:
response = requests.post(
f"{ollama_url}/api/generate",
@@ -279,27 +280,27 @@ class Room:
"temperature": 0.8,
"top_p": 0.9,
}
},
timeout=60
)
if response.status_code==200:
result = response.json()
return result.get("response", "")
else:
return " Couldn't do it for some reason."
except requests.exceptions.RequestException as e:
return f"Couldn't connect to ollama: {e}"
except Exception as e:
return f"Unexpected error: {e}"
def write_to_db(self):
conn = sqlite3.connect(self.db_path)
conn = dbutil.connect(self.db_path)
c = conn.cursor()
self.newly_inserted_posts = []
for post in self.posts:
try:
@@ -309,14 +310,14 @@ class Room:
)
self.newly_inserted_posts.append(post)
except sqlite3.IntegrityError:
continue
conn.commit()
continue
dbutil.commit(conn)
conn.close()
def update_last_run(self):
room_id = self.config.room_id
conn = sqlite3.connect("last_run.db")
conn = dbutil.connect("last_run.db")
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS last_run (
@@ -333,12 +334,12 @@ class Room:
now = int(_time.time())
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))
conn.commit()
dbutil.commit(conn)
conn.close()
def get_last_run(self):
room_id = self.config.room_id
conn = sqlite3.connect("last_run.db")
conn = dbutil.connect("last_run.db")
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS last_run (
@@ -347,7 +348,7 @@ class Room:
datetime TEXT
)
''')
c.execute("PRAGMA table_info(last_run)")
cols = [row[1] for row in c.fetchall()]
if 'datetime' not in cols:
@@ -362,7 +363,7 @@ class Room:
# New: record a !gettitles detection; returns True if newly recorded (not duplicate)
def record_gettitles(self, sender, timestamp, event_id=None):
conn = sqlite3.connect(self.db_path)
conn = dbutil.connect(self.db_path)
c = conn.cursor()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute('''
@@ -370,14 +371,14 @@ class Room:
VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1
conn.commit()
dbutil.commit(conn)
conn.close()
return inserted
# New: record a !mayhem detection; returns True if newly recorded (not duplicate)
def record_mayhem(self, sender, timestamp, event_id=None):
"""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()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute('''
@@ -385,14 +386,14 @@ class Room:
VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1
conn.commit()
dbutil.commit(conn)
conn.close()
return inserted
# New: record a !countvotes detection; returns True if newly recorded (not duplicate)
def record_countvotes(self, sender, timestamp, event_id=None):
"""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()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute('''
@@ -400,14 +401,14 @@ class Room:
VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1
conn.commit()
dbutil.commit(conn)
conn.close()
return inserted
# New: record a !voteurl detection; returns True if newly recorded (not duplicate)
def record_voteurl(self, sender, timestamp, event_id=None):
"""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()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute('''
@@ -415,7 +416,7 @@ class Room:
VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1
conn.commit()
dbutil.commit(conn)
conn.close()
return inserted
@@ -426,7 +427,7 @@ class Room:
next_batch = None
suggestion_votes = {}
all_events = []
# First pass: collect all recent events (both messages and reactions)
for _ in range(15): # Look back further to catch more events
response = await self.client.room_messages(
@@ -435,21 +436,21 @@ class Room:
limit=batch_size,
direction="b"
)
if not hasattr(response, "chunk") or not response.chunk:
break
all_events.extend(response.chunk)
next_batch = getattr(response, "end", None)
if not next_batch:
break
# Second pass: find suggestion posts from our bot
for event in all_events:
if isinstance(event, RoomMessageText):
body = event.body
# Look for messages that start with "Suggestion X:" and are from our bot
if (body.startswith("Suggestion ") and ":" in body and
if (body.startswith("Suggestion ") and ":" in body and
event.sender == self.user_id):
try:
# Extract suggestion number and content
@@ -458,7 +459,7 @@ class Room:
suggestion_num = parts[0].replace("Suggestion ", "").strip()
content = ":".join(parts[1:]).strip()
event_id = getattr(event, "event_id", None)
if event_id:
suggestion_votes[suggestion_num] = {
'content': content,
@@ -469,40 +470,40 @@ class Room:
except Exception as e:
print(f"Error parsing suggestion: {e}")
continue
if not suggestion_votes:
return "No suggestion posts found to count votes for."
# Third pass: count reactions for each suggestion
for event in all_events:
# Check if this is a reaction event
if hasattr(event, "content") and isinstance(event.content, dict):
relates_to = event.content.get("m.relates_to", {})
if (relates_to.get("rel_type") == "m.annotation" and
if (relates_to.get("rel_type") == "m.annotation" and
relates_to.get("key") == "👍"):
target_event_id = relates_to.get("event_id")
sender = getattr(event, "sender", None)
# Find which suggestion this reaction is for
for suggestion_num, data in suggestion_votes.items():
if (data['event_id'] == target_event_id and
sender and sender not in data['voters'] and
if (data['event_id'] == target_event_id and
sender and sender not in data['voters'] and
sender != self.user_id): # Don't count bot's own reactions
data['voters'].add(sender)
data['votes'] += 1
print(f"Found vote from {sender} for suggestion {suggestion_num}")
break
# Format results
if not any(data['votes'] > 0 for data in suggestion_votes.values()):
return "No votes found for any suggestions."
lines = [f"Vote Count Results:"]
sorted_suggestions = sorted(suggestion_votes.items(),
key=lambda x: x[1]['votes'],
sorted_suggestions = sorted(suggestion_votes.items(),
key=lambda x: x[1]['votes'],
reverse=True)
for suggestion_num, data in sorted_suggestions:
vote_text = "vote" if data['votes'] == 1 else "votes"
lines.append(f"Suggestion {suggestion_num}: {data['votes']} {vote_text}")
@@ -511,7 +512,7 @@ class Room:
if len(content) > 80:
content = content[:77] + "..."
lines.append(f" └─ {content}")
return "\n".join(lines)
# Renamed and expanded to handle both !gettitles and !mayhem commands
@@ -525,10 +526,10 @@ class Room:
sync_response = await self.client.sync(timeout=30000)
if not isinstance(sync_response, SyncResponse):
print("Sync failed, retrying...")
continue
continue
room_info = sync_response.rooms.join.get(self.config.room_id)
if not room_info or not hasattr(room_info, "timeline") or not hasattr(room_info.timeline, "events"):
continue
continue
for event in reversed(room_info.timeline.events):
if isinstance(event, RoomMessageText):
# Ignore old events (pre-listen timestamp)
@@ -538,7 +539,7 @@ class Room:
if event.sender == self.user_id:
continue
body = event.body.strip()
# Handle !gettitles
if body == "!gettitles":
event_id = getattr(event, "event_id", None)
@@ -547,7 +548,7 @@ class Room:
continue
print("!gettitles detected")
return "gettitles"
# Handle !mayhem
elif body.startswith("!mayhem"):
event_id = getattr(event, "event_id", None)
@@ -555,7 +556,7 @@ class Room:
if self.record_mayhem(event.sender, ts_seconds, event_id):
await self.alert_post("Standby. Ollama is being instructed to generate mayhem. This may take a moment...")
await self.alert_post(self.generate_mayhem())
# Handle !countvotes
elif body == "!countvotes":
event_id = getattr(event, "event_id", None)
@@ -566,7 +567,7 @@ class Room:
await self.alert_post("Counting votes... This may take a moment...")
vote_results = await self.count_votes()
await self.alert_post(vote_results)
# Handle !voteurl
elif body == "!voteurl":
event_id = getattr(event, "event_id", None)
@@ -586,15 +587,15 @@ class Room:
async def alert_post(self, post):
content = {
"msgtype": "m.text",
"body": post
"body": post
}
resp = await self.client.room_send(
self.config.room_id,
message_type="m.room.message",
content=content
)
if isinstance(resp, RoomSendResponse):
print("Alert posted")
else:
@@ -604,14 +605,14 @@ class Room:
lines = ["Suggestions:"]
for post in self.posts:
clean_msg = "".join(post.content.strip().splitlines())
user = post.display_name
user = post.display_name
if len(user) > 20:
user = user[:20] + "..."
lines.append(f"- {user}: {clean_msg}")
self.formatted_sugs = "\n".join(lines)
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()
query = "SELECT user, display_name, message, timestamp, age, is_posted FROM suggestions"
params = []
@@ -626,14 +627,14 @@ class Room:
conditions.append("is_posted = 0")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY timestamp ASC"
query += " ORDER BY timestamp ASC"
c.execute(query, tuple(params))
rows = c.fetchall()
conn.close()
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):
conn = sqlite3.connect("last_post.db")
conn = dbutil.connect("last_post.db")
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS last_post (
@@ -654,7 +655,7 @@ class Room:
return None
def update_last_post(self):
conn = sqlite3.connect("last_post.db")
conn = dbutil.connect("last_post.db")
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS last_post (
@@ -667,38 +668,38 @@ class Room:
cols = [row[1] for row in c.fetchall()]
if 'datetime' not in cols:
c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
now = int(_time.time())
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))
conn.commit()
dbutil.commit(conn)
conn.close()
async def post_suggestions(self):
# Filter out already posted suggestions
unposted_posts = [post for post in self.posts if not post.is_posted]
if not unposted_posts:
await self.alert_post("No new unposted suggestions found.")
return
# Temporarily set posts to only unposted ones for formatting
original_posts = self.posts
self.posts = unposted_posts
self.format_suggestions_block()
content = {
"msgtype": "m.text",
"body": self.formatted_sugs
}
resp = await self.client.room_send(
self.config.room_id,
message_type="m.room.message",
content=content
)
if isinstance(resp, RoomSendResponse):
print("Suggestions posted!")
# Mark posts as posted in both objects and database
@@ -707,7 +708,7 @@ class Room:
self.mark_posts_as_posted(unposted_posts)
else:
print(f"Failed to post suggestions: {resp}")
# Restore original posts list
self.posts = original_posts
@@ -719,7 +720,7 @@ class Room:
return
await self.alert_post(f"""
Time to vote! 🚢🚢🚢
Time to vote! 🚢🚢🚢
Each title is a separate post below. Tap the 👍 reaction on the one you like to cast your vote!
@@ -727,6 +728,17 @@ class Room:
NOTE: HTTP VOTING IS IN PRE-ALPHA. IF YOU WOULD LIKE TO SEE THE EARLY PRE-RELEASE, GO TO {self.vote_url()}
MAKE MAGBOT BETTER:
GIT REPO: https://magbot.online ⬅️⬅️⬅️ PRs welcome!
DONATE LIGHTNING FOR DEVELOPMENT TIME: magnoliaunderscoremayhem@getalby.com
- 50K sats pays for me to stay home from the farm for one day
- The VPS is 10K sats per month
""")
for idx, post in enumerate(unposted_posts, start=1):
@@ -775,15 +787,15 @@ class Room:
"""Mark posts as posted in the database"""
if posts is None:
posts = self.posts
conn = sqlite3.connect(self.db_path)
conn = dbutil.connect(self.db_path)
c = conn.cursor()
for post in posts:
c.execute(
"UPDATE suggestions SET is_posted = 1 WHERE user = ? AND message = ? AND timestamp = ?",
(post.poster, post.content, post.time)
)
conn.commit()
dbutil.commit(conn)
conn.close()
async def close(self):
@@ -816,5 +828,3 @@ class Room:
print(f"Failed to ack suggestion: {resp}")
except Exception as e:
print(f"Error sending ✅ reaction: {e}")
+141 -11
View File
@@ -133,6 +133,7 @@ cleanup() {
kill "$pid" 2>/dev/null || true
fi
rm -f "$pidfile"
rm -f "$PID_DIR/$name.cmd"
fi
done
fi
@@ -144,6 +145,8 @@ cleanup() {
for script in scraper post_results get_votes get_user_list; do
pkill -f "python3 $script.py" 2>/dev/null || true
done
rm -rf "${RESTART_STATE_DIR:-}" 2>/dev/null || true
echo -e "${GREEN}All services stopped.${NC}"
exit 0
@@ -157,23 +160,54 @@ start_go_service() {
local name=$1
local dir=$2
local binary=$3
local rebuild=0
echo -e "${BLUE}Building $name...${NC}"
if [ "$OS" = "freebsd" ]; then
(cd "$dir" && GOOS=freebsd go build -o "$binary") || {
echo -e "${RED}Failed to build $name for FreeBSD${NC}"
return 1
}
if [ -f "$dir/$binary" ]; then
echo -e "${GREEN}Found existing $name binary ($dir/$binary), checking freshness...${NC}"
# Only skip the build if the binary matches the current OS target and
# is newer than every Go source file in the project (stale binaries rebuild).
local os_ok=1
case "$OS:$binary" in
freebsd:jepfbd|freebsd:vsfbsd) os_ok=0 ;;
linux:jep|linux:vs) os_ok=0 ;;
esac
if [ "$os_ok" -ne 0 ]; then
echo -e "${YELLOW}$binary is for another platform; rebuilding ${name} for $OS${NC}"
rebuild=1
else
local newest_src
newest_src=$( (cd "$dir" && ls -t *.go go.mod go.sum 2>/dev/null) | head -1 )
if [ -z "$newest_src" ] || [ "$dir/$binary" -nt "$dir/$newest_src" ]; then
echo -e "${GREEN}$name binary is current; skipping build${NC}"
rebuild=0
else
echo -e "${YELLOW}$name binary is older than ${newest_src}; rebuilding${NC}"
rebuild=1
fi
fi
else
(cd "$dir" && go build -o "$binary") || {
echo -e "${RED}Failed to build $name${NC}"
return 1
}
rebuild=1
fi
if [ "$rebuild" = "1" ]; then
echo -e "${BLUE}Building $name...${NC}"
if [ "$OS" = "freebsd" ]; then
(cd "$dir" && GOOS=freebsd go build -o "$binary") || {
echo -e "${RED}Failed to build $name for FreeBSD${NC}"
return 1
}
else
(cd "$dir" && go build -o "$binary") || {
echo -e "${RED}Failed to build $name${NC}"
return 1
}
fi
fi
echo -e "${GREEN}Starting $name...${NC}"
(cd "$dir" && ./"$binary" > "$LOG_DIR/$name.log" 2>&1) &
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
sleep 1
@@ -203,6 +237,7 @@ start_python_service() {
# Activate virtual environment and run script
(source .venv/bin/activate && python3 "$script" $config_arg > "$LOG_DIR/$label.log" 2>&1) &
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
sleep 1
@@ -324,6 +359,100 @@ echo ""
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}"
echo ""
# Minimum free memory (MB) required before an auto-restart is attempted.
# Prevents making an out-of-memory situation worse by launching new processes.
RESTART_MEM_MIN_MB=${RESTART_MEM_MIN_MB:-128}
# Max restart attempts per service within the restart window before backing off.
MAX_RESTARTS_PER_WINDOW=${MAX_RESTARTS_PER_WINDOW:-5}
RESTART_WINDOW_SECS=${RESTART_WINDOW_SECS:-300}
# Track restart attempts per service (name -> timestamp:count)
RESTART_STATE_DIR="$PID_DIR/restarts"
mkdir -p "$RESTART_STATE_DIR"
free_mem_mb() {
# Returns approximate free memory in MB (0 if unavailable -> allow restart)
case "$OS" in
freebsd)
local pages free_kb
pages=$(sysctl -n vm.stats.vm.v_free_count 2>/dev/null) || return 0
free_kb=$(( pages * 4096 / 1024 ))
echo "$(( free_kb / 1024 ))"
;;
linux)
awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || return 0
;;
*)
return 0
;;
esac
}
# Register a restart attempt; returns 0 if allowed, 1 if the service is
# restarting too often (crash loop) and should be left down for now.
check_restart_budget() {
local name=$1 now
now=$( date +%s )
local state_file="$RESTART_STATE_DIR/$name"
# shellcheck disable=SC2086
local count=0 first="$now"
if [ -f "$state_file" ]; then
read -r first count < "$state_file"
fi
# Expire the window once it has passed.
if [ $(( now - first )) -ge "$RESTART_WINDOW_SECS" ]; then
count=0
first=$now
fi
if [ "$count" -ge "$MAX_RESTARTS_PER_WINDOW" ]; then
echo -e "${YELLOW}$name: hit max restarts ($MAX_RESTARTS_PER_WINDOW in ${RESTART_WINDOW_SECS}s), leaving down. Clear $RESTART_STATE_DIR/$name to reset.${NC}"
return 1
fi
count=$(( count + 1 ))
echo "$first $count" > "$state_file"
return 0
}
# 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
if ! check_restart_budget "$name"; then
return 1
fi
# Do not relaunch more processes if the machine is nearly out of memory.
local free_mb
free_mb=$(free_mem_mb)
if [ -n "$free_mb" ] && [ "$free_mb" -lt "$RESTART_MEM_MIN_MB" ]; then
echo -e "${YELLOW}$name: only ${free_mb}MB free (need >= ${RESTART_MEM_MIN_MB}MB), skipping restart to avoid OOM.${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}"
# A restart that survives is a success; reset its budget.
rm -f "$RESTART_STATE_DIR/$name"
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
while true; do
# Check if all processes are still running
@@ -335,12 +464,13 @@ while true; do
name=$(basename "$pidfile" .pid)
echo -e "${RED}Warning: $name (PID: $pid) has stopped unexpectedly${NC}"
all_running=false
restart_service "$name"
fi
fi
done
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
sleep 10
+30 -2
View File
@@ -3,6 +3,34 @@ module vs
go 1.24.9
require (
github.com/mattn/go-sqlite3 v1.14.32 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
github.com/mattn/go-sqlite3 v1.14.32
github.com/nbd-wtf/go-nostr v0.52.3
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/bytedance/sonic v1.13.1 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.31.0 // indirect
)
+168
View File
@@ -1,5 +1,173 @@
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g=
github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nbd-wtf/go-nostr v0.52.3 h1:Xd87pXfJEJRXHpM+fLjQQln8dBNNaoPA10V7BbyP4KI=
github.com/nbd-wtf/go-nostr v0.52.3/go.mod h1:4avYoc9mDGZ9wHsvCOhHH9vPzKucCfuYBtJUSpHTfNk=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+98 -5
View File
@@ -16,13 +16,21 @@ body {
padding: 20px;
}
#display_name {
input[type="text"] {
width: 100%;
padding: 10px;
margin-bottom: 20px;
margin-bottom: 15px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
}
#suggestion_list {
@@ -37,7 +45,7 @@ body {
border-radius: 4px;
}
#submit_vote {
button {
padding: 10px 20px;
font-size: 16px;
background-color: #4CAF50;
@@ -45,9 +53,94 @@ body {
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
margin-right: 5px;
}
button:hover {
background-color: #45a049;
}
button:disabled {
background-color: #999;
cursor: not-allowed;
}
#submit_vote {
margin-top: 20px;
font-size: 20px;
}
#auth_section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fafafa;
}
#vote_section {
margin-top: 20px;
}
#submit_vote:hover {
background-color: #45a049;
#logged_in_as {
padding: 10px;
background: #e8f5e9;
border-radius: 4px;
margin-bottom: 15px;
font-weight: bold;
color: #2e7d32;
}
.info-box {
padding: 8px 12px;
margin-bottom: 15px;
background: #e3f2fd;
border-radius: 4px;
color: #1565c0;
font-size: 14px;
}
.success {
padding: 8px 12px;
margin-top: 10px;
background: #e8f5e9;
border-radius: 4px;
color: #2e7d32;
}
.error {
padding: 8px 12px;
margin-top: 10px;
background: #ffebee;
border-radius: 4px;
color: #c62828;
}
#connect_btn {
background-color: #f57c00;
margin-bottom: 15px;
}
#connect_btn:hover {
background-color: #ef6c00;
}
#auth_btn {
background-color: #1976d2;
}
#auth_btn:hover {
background-color: #1565c0;
}
#link_link {
display: inline-block;
margin-top: 10px;
color: #666;
font-size: 14px;
}
#link_link:hover {
color: #333;
}
+178 -59
View File
@@ -1,17 +1,125 @@
// Fetch submissions from the JSON endpoint and populate the suggestion list
let currentUser = null; // { npub, displayName }
let currentChallenge = null;
// --- Nostr auth ---
function hasNip07() {
return typeof window.nostr !== 'undefined' && window.nostr !== null;
}
async function connectNip07() {
if (!hasNip07()) {
setAuthStatus('No Nostr browser extension detected. Enter your npub manually.', true);
return;
}
try {
const hexKey = await window.nostr.getPublicKey();
const resp = await fetch('encode_npub', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hex: hexKey })
});
if (resp.ok) {
const data = await resp.json();
document.getElementById('npub_input').value = data.npub;
document.getElementById('nip07_badge').style.display = 'block';
setAuthStatus('Connected! Click "Sign In to Vote" to continue.');
} else {
document.getElementById('npub_input').value = hexKey;
setAuthStatus('Connected (using hex key). Click "Sign In to Vote".');
}
} catch (err) {
setAuthStatus('Error: ' + err.message, true);
}
}
async function authenticate() {
const npub = document.getElementById('npub_input').value.trim();
if (!npub) {
setAuthStatus('Enter your npub or connect AlbyHub first.', true);
return;
}
setAuthStatus('Requesting challenge...');
try {
const resp = await fetch('challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ npub: npub })
});
if (resp.status === 404) {
setAuthStatus('Npub not linked. Please <a href="link">link your identity</a> first.', true);
return;
}
if (!resp.ok) {
const text = await resp.text();
setAuthStatus('Error: ' + text, true);
return;
}
const data = await resp.json();
currentChallenge = data.challenge;
if (!hasNip07()) {
setAuthStatus('No Nostr extension. Please install AlbyHub to sign.', true);
return;
}
setAuthStatus('Please sign the challenge in your extension...');
const signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: currentChallenge
});
// Store auth state
currentUser = {
npub: npub,
displayName: data.display_name,
signedEvent: signedEvent
};
document.getElementById('auth_section').style.display = 'none';
document.getElementById('vote_section').style.display = 'block';
document.getElementById('logged_in_as').textContent = 'Signed in as: ' + data.display_name;
loadSubmissions();
} catch (err) {
if (err.message && err.message.includes('denied')) {
setAuthStatus('Signature request denied.', true);
} else {
setAuthStatus('Error: ' + err.message, true);
}
}
}
function setAuthStatus(msg, isError) {
const el = document.getElementById('auth_status');
el.innerHTML = msg;
el.className = isError ? 'error' : 'success';
}
// --- Submissions ---
async function loadSubmissions() {
try {
const response = await fetch('submissions');
const data = await response.json();
const submissionList = document.getElementById('suggestion_list');
submissionList.innerHTML = ''; // Clear existing items
submissionList.innerHTML = '';
if (data.Submissions && data.Submissions.length > 0) {
data.Submissions.forEach((sub, index) => {
data.Submissions.forEach((sub) => {
const li = document.createElement('li');
li.innerHTML = `
<span class="suggestor">${sub.submitter} SUGGESTED</span> :
<span class="suggestor">${sub.submitter} SUGGESTED</span> :
<span class="suggestion">${sub.submission}</span>
<input type="radio" name="vote" value="${sub.submission}" data-submitter="${sub.submitter}" data-time="${sub.submission_time}">
`;
@@ -26,71 +134,82 @@ async function loadSubmissions() {
}
}
// Iterate over all radio inputs and collect selected vote
function getVotes() {
const displayName = document.getElementById('display_name').value.trim();
if (!displayName) {
alert('Please enter your display name');
// --- Voting ---
async function submitVote() {
if (!currentUser) {
alert('Please sign in first');
return;
}
// Get the selected radio button
const selectedRadio = document.querySelector('input[name="vote"]:checked');
if (!selectedRadio) {
alert('Please select a title to vote for');
return;
}
// Collect vote data
const voteData = {
voter_display_name: displayName,
selected_submission: selectedRadio.value,
submitter: selectedRadio.dataset.submitter,
submission_time: parseInt(selectedRadio.dataset.time),
vote_timestamp: Math.floor(Date.now() / 1000)
};
// Send to the /getVotes endpoint
fetch('getVotes', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(voteData)
})
.then(response => {
if (response.ok) {
alert('Vote submitted successfully!');
// Clear the selection
selectedRadio.checked = false;
document.getElementById('display_name').value = '';
} else {
return response.text().then(text => {
throw new Error(text || 'Failed to submit vote');
});
try {
// Request a fresh challenge for this vote
const challengeResp = await fetch('challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ npub: currentUser.npub })
});
if (!challengeResp.ok) {
const text = await challengeResp.text();
alert('Error getting challenge: ' + text);
return;
}
})
.catch(error => {
const challengeData = await challengeResp.json();
const challengeMsg = challengeData.challenge;
// Sign the challenge
const signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: challengeMsg
});
// Submit the vote
const voteResp = await fetch('verifyVote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
npub: currentUser.npub,
challenge: challengeMsg,
selected_submission: selectedRadio.value,
submitter: selectedRadio.dataset.submitter,
submission_time: parseInt(selectedRadio.dataset.time),
signed_event: signedEvent
})
});
if (voteResp.ok) {
alert('Vote submitted successfully!');
selectedRadio.checked = false;
} else {
const text = await voteResp.text();
alert('Error: ' + text);
}
} catch (error) {
console.error('Error submitting vote:', error);
alert('Error submitting vote: ' + error.message);
});
}
}
// Load submissions when page loads
// --- Init ---
document.addEventListener('DOMContentLoaded', () => {
loadSubmissions();
// Add submit button if it doesn't exist
if (!document.getElementById('submit_vote')) {
const submitButton = document.createElement('button');
submitButton.id = 'submit_vote';
submitButton.textContent = '⛵⛵⛵⛵⛵⛵⛵⛵⛵';
submitButton.onclick = getVotes;
document.body.appendChild(submitButton);
// Auto-connect if NIP-07 is available
if (hasNip07()) {
document.getElementById('nip07_badge').style.display = 'block';
}
// Optionally refresh submissions periodically
setInterval(loadSubmissions, 30000); // Refresh every 30 seconds
// Refresh submissions periodically
setInterval(() => {
if (currentUser) loadSubmissions();
}, 30000);
});
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Link Nostr Identity</title>
<link rel="stylesheet" href="css.css">
</head>
<body>
<h1>Link Your Nostr Identity</h1>
<p>Connect your npub to your Matrix display name so you can vote.</p>
<div id="auth_section">
<div id="nip07_status" class="info-box" style="display:none;">
Nostr browser extension detected.
</div>
<label for="display_name">Matrix Display Name:</label>
<input type="text" id="display_name" placeholder="Enter your Matrix display name exactly">
<label for="npub_input">Nostr Public Key (npub):</label>
<input type="text" id="npub_input" placeholder="npub1...">
<button id="connect_btn" onclick="connectNip07()">Connect AlbyHub</button>
<button id="link_btn" onclick="linkNpub()">Link & Sign</button>
<div id="status"></div>
</div>
<script>
let currentNpub = null;
async function connectNip07() {
if (!window.nostr) {
setStatus('No Nostr browser extension detected. Please enter your npub manually.', true);
return;
}
try {
const pubkey = await window.nostr.getPublicKey();
// Convert hex to npub bech32
const resp = await fetch('encode_npub', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hex: pubkey})
});
if (resp.ok) {
const data = await resp.json();
document.getElementById('npub_input').value = data.npub;
currentNpub = data.npub;
setStatus('Connected! Npub loaded from extension.');
} else {
// Fallback: just use hex
document.getElementById('npub_input').value = pubkey;
currentNpub = pubkey;
setStatus('Connected! Paste this as npub if needed: ' + pubkey);
}
} catch (err) {
setStatus('Error connecting to extension: ' + err.message, true);
}
}
async function linkNpub() {
const displayName = document.getElementById('display_name').value.trim();
const npub = document.getElementById('npub_input').value.trim();
if (!displayName) {
setStatus('Please enter your Matrix display name.', true);
return;
}
if (!npub) {
setStatus('Please enter your npub or connect AlbyHub.', true);
return;
}
setStatus('Requesting signature...');
try {
// Build the linking message
// We need to get the hex pubkey first
let npubHex = npub;
// If it looks like a bech32 npub, we need to decode it client-side
// For simplicity, we'll send the npub and let the server decode it
// But we need the hex to build the message for signing
// Try to get hex from npub via server
const decodeResp = await fetch('decode_npub', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({npub: npub})
});
if (!decodeResp.ok) {
if (!window.nostr) {
setStatus('Could not decode npub. Please install a Nostr extension.', true);
return;
}
setStatus('Could not decode npub via server.', true);
return;
}
const decodeData = await decodeResp.json();
npubHex = decodeData.hex;
// Build the expected linking message
const slug = window.location.pathname.split('/')[1];
const linkMsg = `Link npub ${npubHex} to Matrix user ${displayName} for room ${slug}`;
let signedEvent;
if (window.nostr) {
// Sign via NIP-07 extension
signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: linkMsg
});
} else {
setStatus('No Nostr extension available. Please install AlbyHub or another NIP-07 signer.', true);
return;
}
setStatus('Sending link request...');
const resp = await fetch('linkNpub', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
display_name: displayName,
npub: npub,
signed_event: signedEvent
})
});
if (resp.ok) {
setStatus('Successfully linked! You can now <a href="/">go vote</a>.');
document.getElementById('link_btn').disabled = true;
} else {
const text = await resp.text();
setStatus('Error: ' + text, true);
}
} catch (err) {
setStatus('Error: ' + err.message, true);
}
}
function setStatus(msg, isError) {
const el = document.getElementById('status');
el.innerHTML = msg;
el.className = isError ? 'error' : 'success';
}
</script>
</body>
</html>
+29 -9
View File
@@ -8,15 +8,35 @@
</head>
<body>
<h1>Choose Your Favorite Title</h1>
<label for="display_name">Matrix Display Name (Not Matrix Base Username):</label>
<input type="text" id="display_name" placeholder="Enter your display name">
<h2>Suggestions:</h2>
<ul id="suggestion_list">
<!-- Populated Dynamically Via JS -->
</ul>
<div id="auth_section">
<div id="nip07_badge" class="info-box" style="display:none;">
Nostr extension connected
</div>
<div id="auth_npub_row">
<label for="npub_input">Nostr Public Key:</label>
<input type="text" id="npub_input" placeholder="npub1... or connect extension">
<button id="connect_btn" onclick="connectNip07()">Connect AlbyHub</button>
</div>
<button id="auth_btn" onclick="authenticate()">Sign In to Vote</button>
<a id="link_link" href="link">First time? Link your npub</a>
<div id="auth_status"></div>
</div>
<div id="vote_section" style="display:none;">
<div id="logged_in_as"></div>
<h2>Suggestions:</h2>
<ul id="suggestion_list">
<!-- Populated Dynamically Via JS -->
</ul>
<button id="submit_vote" onclick="submitVote()">&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;&#x26F5;</button>
</div>
<script src="js.js"></script>
</body>
</html>
</html>
+447 -46
View File
@@ -2,6 +2,7 @@ package main
import (
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -14,6 +15,8 @@ import (
"sync"
"time"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
_ "github.com/mattn/go-sqlite3"
"gopkg.in/yaml.v2"
)
@@ -71,6 +74,27 @@ type Room struct {
UserMap *UserMap
}
// --- Nostr auth types ---
type SignedLinkRequest struct {
DisplayName string `json:"display_name"`
Npub string `json:"npub"`
SignedEvent json.RawMessage `json:"signed_event"` // full signed Nostr event
}
type ChallengeRequest struct {
Npub string `json:"npub"` // bech32 npub or hex pubkey
}
type SignedVoteRequest struct {
Npub string `json:"npub"`
Challenge string `json:"challenge"`
SelectedSubmission string `json:"selected_submission"`
Submitter string `json:"submitter"`
SubmissionTime int64 `json:"submission_time"`
SignedEvent json.RawMessage `json:"signed_event"` // full signed Nostr event
}
func loadConfig(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
@@ -83,12 +107,10 @@ func loadConfig(configPath string) (*Config, error) {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Set default value for VoteActivityHours if not specified
if config.VoteActivityHours == 0 {
config.VoteActivityHours = 72
}
// Set default value for VoteHost if not specified
if config.VoteHost == "" {
config.VoteHost = "localhost"
}
@@ -96,8 +118,6 @@ func loadConfig(configPath string) (*Config, error) {
return &config, nil
}
// Load one or more room configs. If a configs/ directory exists, load all
// yaml files from it; otherwise fall back to the single ../config.yaml.
func loadConfigs() ([]*Config, error) {
var configs []*Config
@@ -127,7 +147,6 @@ func loadConfigs() ([]*Config, error) {
return configs, nil
}
// Load the slug map written by the bot (room_id -> slug).
func loadRoomSlugs() map[string]string {
data, err := os.ReadFile(filepath.Join("..", "room_slugs.json"))
if err != nil {
@@ -179,7 +198,6 @@ func initDB(dbPath string) (*sql.DB, error) {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Create votes table
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -195,9 +213,160 @@ func initDB(dbPath string) (*sql.DB, error) {
return nil, fmt.Errorf("failed to create votes table: %w", err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS nostr_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
npub_hex TEXT NOT NULL,
display_name TEXT NOT NULL,
linked_at INTEGER NOT NULL,
UNIQUE(npub_hex),
UNIQUE(display_name)
)
`)
if err != nil {
return nil, fmt.Errorf("failed to create nostr_links table: %w", err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS nostr_challenges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
npub_hex TEXT NOT NULL,
challenge TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used INTEGER DEFAULT 0
)
`)
if err != nil {
return nil, fmt.Errorf("failed to create nostr_challenges table: %w", err)
}
return db, nil
}
// --- Nostr helpers ---
func decodeNpub(npub string) (string, error) {
prefix, extracted, err := nip19.Decode(npub)
if err != nil {
return "", fmt.Errorf("failed to decode npub: %w", err)
}
if prefix != "npub" {
return "", fmt.Errorf("expected npub prefix, got %s", prefix)
}
hexKey, ok := extracted.(string)
if !ok {
return "", fmt.Errorf("unexpected type from npub decode")
}
return hexKey, nil
}
func parsePubkey(input string) (string, error) {
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "npub1") {
return decodeNpub(input)
}
// Assume hex
cleaned := strings.TrimPrefix(input, "0x")
if len(cleaned) != 64 {
return "", fmt.Errorf("invalid pubkey: expected 64 hex characters, got %d", len(cleaned))
}
if _, err := hex.DecodeString(cleaned); err != nil {
return "", fmt.Errorf("invalid hex pubkey: %w", err)
}
return cleaned, nil
}
func verifySignedEvent(signedEventJSON json.RawMessage, expectedPubkey string) (*nostr.Event, error) {
var evt nostr.Event
if err := json.Unmarshal(signedEventJSON, &evt); err != nil {
return nil, fmt.Errorf("failed to parse signed event: %w", err)
}
if evt.PubKey != expectedPubkey {
return nil, fmt.Errorf("event pubkey %s does not match expected %s", evt.PubKey, expectedPubkey)
}
ok, err := evt.CheckSignature()
if err != nil {
return nil, fmt.Errorf("signature verification error: %w", err)
}
if !ok {
return nil, fmt.Errorf("invalid signature")
}
return &evt, nil
}
func decodeNpubEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
Npub string `json:"npub"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
hexKey, err := decodeNpub(req.Npub)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"hex": hexKey})
}
func encodeNpubEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
Hex string `json:"hex"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
npub, err := nip19.EncodePublicKey(req.Hex)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to encode: %v", err), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"npub": npub})
}
func cleanExpiredChallenges(db *sql.DB) {
_, err := db.Exec(`DELETE FROM nostr_challenges WHERE expires_at < ?`, time.Now().Unix())
if err != nil {
log.Printf("Error cleaning expired challenges: %v", err)
}
}
// --- Routes ---
func main() {
configs, err := loadConfigs()
if err != nil {
@@ -236,6 +405,7 @@ func main() {
go collectSubmissions(room)
go collectUserList(room)
go cleanExpiredChallengesLoop(room)
base := "/" + room.Slug
http.HandleFunc(base+"/", serveHTML)
@@ -243,14 +413,25 @@ func main() {
http.HandleFunc(base+"/js.js", serveJS)
http.HandleFunc(base+"/submissions", serveSubmissions(room.SubBuffer))
http.HandleFunc(base+"/getVotes", getVotes(room))
http.HandleFunc(base+"/link", serveLinkHTML)
http.HandleFunc(base+"/linkNpub", linkNpub(room))
http.HandleFunc(base+"/challenge", challenge(room))
http.HandleFunc(base+"/verifyVote", verifyVote(room))
http.HandleFunc(base+"/decode_npub", decodeNpubEndpoint)
http.HandleFunc(base+"/encode_npub", encodeNpubEndpoint)
log.Printf("Vote server serving room '%s' under %s (db %s)", config.RoomID, base, dbPath)
}
// Backward compatibility: a single room is also served at the root paths.
if len(rooms) == 1 {
http.HandleFunc("/submissions", serveSubmissions(rooms[0].SubBuffer))
http.HandleFunc("/getVotes", getVotes(rooms[0]))
http.HandleFunc("/link", serveLinkHTML)
http.HandleFunc("/linkNpub", linkNpub(rooms[0]))
http.HandleFunc("/challenge", challenge(rooms[0]))
http.HandleFunc("/verifyVote", verifyVote(rooms[0]))
http.HandleFunc("/decode_npub", decodeNpubEndpoint)
http.HandleFunc("/encode_npub", encodeNpubEndpoint)
}
http.HandleFunc("/", indexHandler(rooms))
@@ -259,7 +440,14 @@ func main() {
log.Fatal(http.ListenAndServe(":9081", nil))
}
// Index page listing every room being served
func cleanExpiredChallengesLoop(room *Room) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
cleanExpiredChallenges(room.DB)
}
}
func indexHandler(rooms []*Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if len(rooms) == 1 {
@@ -277,27 +465,28 @@ func indexHandler(rooms []*Room) func(w http.ResponseWriter, r *http.Request) {
}
}
// Serve the HTML page
func serveHTML(w http.ResponseWriter, r *http.Request) {
htmlPath := filepath.Join("htm", "ndx.html")
http.ServeFile(w, r, htmlPath)
}
// Serve the CSS file
func serveCSS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
cssPath := filepath.Join("htm", "css.css")
http.ServeFile(w, r, cssPath)
}
// Serve the JavaScript file
func serveJS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
jsPath := filepath.Join("htm", "js.js")
http.ServeFile(w, r, jsPath)
}
// Serve the submissions data as JSON
func serveLinkHTML(w http.ResponseWriter, r *http.Request) {
htmlPath := filepath.Join("htm", "link.html")
http.ServeFile(w, r, htmlPath)
}
func serveSubmissions(subBuffer *SubmissionBuffer) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
subBuffer.Mtx.Lock()
@@ -313,15 +502,12 @@ func serveSubmissions(subBuffer *SubmissionBuffer) func(w http.ResponseWriter, r
}
}
// Pull the data from the json endpoint so that we can vote on them
func collectSubmissions(room *Room) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Initial fetch
fetchSubmissions(room)
// Periodic fetches
for range ticker.C {
fetchSubmissions(room)
}
@@ -350,15 +536,12 @@ func fetchSubmissions(room *Room) {
log.Printf("Fetched %d submissions", len(fetchedBuffer.Submissions))
}
// Pull the user list from the json endpoint so we can confirm voters
func collectUserList(room *Room) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Initial fetch
fetchUserList(room)
// Periodic fetches
for range ticker.C {
fetchUserList(room)
}
@@ -380,7 +563,6 @@ func fetchUserList(room *Room) {
return
}
// Update the user map with write lock
room.UserMap.Mtx.Lock()
room.UserMap.Users = userData.Users
room.UserMap.MostRecentPost = userData.MostRecentPost
@@ -389,7 +571,8 @@ func fetchUserList(room *Room) {
log.Printf("Fetched %d users (most recent post: %d)", len(userData.Users), userData.MostRecentPost)
}
// Receive the votes via POST when they come in and save to database
// --- Old getVotes endpoint (kept for backward compat) ---
func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -397,7 +580,6 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) {
return
}
// Read the request body
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading request body: %v", err)
@@ -406,7 +588,6 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) {
}
defer r.Body.Close()
// Parse the vote
var vote Vote
err = json.Unmarshal(body, &vote)
if err != nil {
@@ -415,7 +596,6 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) {
return
}
// Validate the vote
if vote.VoterDisplayName == "" {
http.Error(w, "Voter display name is required", http.StatusBadRequest)
return
@@ -425,15 +605,11 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) {
return
}
// Validate that the voter has been active within the configured time window
room.UserMap.Mtx.RLock()
var userFound bool
var lastActive int64
mostRecentPost := room.UserMap.MostRecentPost
for _, user := range room.UserMap.Users {
if user.DisplayName == vote.VoterDisplayName {
userFound = true
lastActive = user.LastActive
break
}
}
@@ -445,22 +621,8 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) {
return
}
// Check if user was active within configured hours of the most recent post in the room
config := room.Config
activityWindowSeconds := int64(config.VoteActivityHours * 60 * 60)
activityThreshold := mostRecentPost - activityWindowSeconds
if lastActive < activityThreshold {
log.Printf("Vote rejected: User '%s' last active at %d, most recent post %d, threshold %d", vote.VoterDisplayName, lastActive, mostRecentPost, activityThreshold)
lastActiveTime := time.Unix(lastActive, 0).Format("2006-01-02 15:04:05")
mostRecentTime := time.Unix(mostRecentPost, 0).Format("2006-01-02 15:04:05")
http.Error(w, fmt.Sprintf("You must have been active within %d hours of the most recent room activity to vote. Your last activity was at %s. Most recent room activity: %s.", config.VoteActivityHours, lastActiveTime, mostRecentTime), http.StatusForbidden)
return
}
// Set vote timestamp
vote.VoteTimestamp = time.Now().Unix()
// Save vote to database (replaces any previous vote from this user)
err = saveVote(room.DB, vote)
if err != nil {
log.Printf("Error saving vote: %v", err)
@@ -468,18 +630,257 @@ func getVotes(room *Room) func(w http.ResponseWriter, r *http.Request) {
return
}
lastActiveTime := time.Unix(lastActive, 0).Format("2006-01-02 15:04:05")
log.Printf("Vote accepted: %s (last active %s) voted for '%s'", vote.VoterDisplayName, lastActiveTime, vote.SelectedSubmission)
log.Printf("Vote accepted (legacy): %s voted for '%s'", vote.VoterDisplayName, vote.SelectedSubmission)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Vote recorded successfully"))
}
}
// --- Nostr auth endpoints ---
func linkNpub(room *Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req SignedLinkRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request format", http.StatusBadRequest)
return
}
if req.DisplayName == "" {
http.Error(w, "Display name is required", http.StatusBadRequest)
return
}
npubHex, err := parsePubkey(req.Npub)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid npub: %v", err), http.StatusBadRequest)
return
}
evt, err := verifySignedEvent(req.SignedEvent, npubHex)
if err != nil {
http.Error(w, fmt.Sprintf("Signature verification failed: %v", err), http.StatusForbidden)
return
}
expectedMsg := buildLinkMessage(npubHex, req.DisplayName, room.Slug)
if evt.Content != expectedMsg {
http.Error(w, "Signed message does not match expected linking message", http.StatusForbidden)
return
}
// Check that the Matrix display name exists in the user database
room.UserMap.Mtx.RLock()
var userFound bool
for _, user := range room.UserMap.Users {
if user.DisplayName == req.DisplayName {
userFound = true
break
}
}
room.UserMap.Mtx.RUnlock()
if !userFound {
http.Error(w, "Matrix display name not found in this room's user list", http.StatusForbidden)
return
}
// Store the link
_, err = room.DB.Exec(`
INSERT OR REPLACE INTO nostr_links (npub_hex, display_name, linked_at)
VALUES (?, ?, ?)
`, npubHex, req.DisplayName, time.Now().Unix())
if err != nil {
log.Printf("Error storing npub link: %v", err)
http.Error(w, "Failed to store link", http.StatusInternalServerError)
return
}
log.Printf("Npub linked: %s -> Matrix user '%s'", npubHex[:16]+"...", req.DisplayName)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Linked successfully"))
}
}
func buildLinkMessage(npubHex, displayName, slug string) string {
return fmt.Sprintf("Link npub %s to Matrix user %s for room %s", npubHex, displayName, slug)
}
func challenge(room *Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req ChallengeRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request format", http.StatusBadRequest)
return
}
npubHex, err := parsePubkey(req.Npub)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid npub: %v", err), http.StatusBadRequest)
return
}
// Check that this npub is linked
var displayName string
err = room.DB.QueryRow(`SELECT display_name FROM nostr_links WHERE npub_hex = ?`, npubHex).Scan(&displayName)
if err == sql.ErrNoRows {
http.Error(w, "Npub not linked. Please link your npub first.", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Generate challenge message (this is what the user will sign)
now := time.Now().Unix()
challengeMsg := fmt.Sprintf("Vote at %d in room %s", now, room.Slug)
_, err = room.DB.Exec(`
INSERT INTO nostr_challenges (npub_hex, challenge, created_at, expires_at)
VALUES (?, ?, ?, ?)
`, npubHex, challengeMsg, now, now+300) // 5 minute expiry
if err != nil {
log.Printf("Error storing challenge: %v", err)
http.Error(w, "Failed to store challenge", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"challenge": challengeMsg,
"display_name": displayName,
})
}
}
func verifyVote(room *Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req SignedVoteRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid request format", http.StatusBadRequest)
return
}
if req.SelectedSubmission == "" {
http.Error(w, "Selected submission is required", http.StatusBadRequest)
return
}
npubHex, err := parsePubkey(req.Npub)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid npub: %v", err), http.StatusBadRequest)
return
}
// Verify the signed event
evt, err := verifySignedEvent(req.SignedEvent, npubHex)
if err != nil {
http.Error(w, fmt.Sprintf("Signature verification failed: %v", err), http.StatusForbidden)
return
}
// Look up the challenge and linked display name
var displayName string
err = room.DB.QueryRow(`
SELECT display_name FROM nostr_links WHERE npub_hex = ?
`, npubHex).Scan(&displayName)
if err == sql.ErrNoRows {
http.Error(w, "Npub not linked", http.StatusForbidden)
return
}
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Verify the challenge exists, is unused, and hasn't expired
var challengeCount int
err = room.DB.QueryRow(`
SELECT COUNT(*) FROM nostr_challenges
WHERE npub_hex = ? AND challenge = ? AND used = 0 AND expires_at > ?
`, npubHex, req.Challenge, time.Now().Unix()).Scan(&challengeCount)
if err != nil || challengeCount == 0 {
http.Error(w, "Invalid or expired challenge", http.StatusForbidden)
return
}
// Verify the signed event content is the challenge message
if evt.Content != req.Challenge {
http.Error(w, "Signed message does not match the challenge", http.StatusForbidden)
return
}
// Mark challenge as used
_, err = room.DB.Exec(`
UPDATE nostr_challenges SET used = 1
WHERE npub_hex = ? AND challenge = ? AND used = 0
`, npubHex, req.Challenge)
if err != nil {
log.Printf("Error marking challenge used: %v", err)
}
// Record the vote
vote := Vote{
VoterDisplayName: displayName,
SelectedSubmission: req.SelectedSubmission,
Submitter: req.Submitter,
SubmissionTime: req.SubmissionTime,
VoteTimestamp: time.Now().Unix(),
}
err = saveVote(room.DB, vote)
if err != nil {
log.Printf("Error saving vote: %v", err)
http.Error(w, "Failed to save vote", http.StatusInternalServerError)
return
}
log.Printf("Vote accepted: %s (npub %s...) voted for '%s'", displayName, npubHex[:16], req.SelectedSubmission)
// Send success response
w.WriteHeader(http.StatusOK)
w.Write([]byte("Vote recorded successfully"))
}
}
// Save a vote to the database (replaces any previous vote from this user)
func saveVote(db *sql.DB, vote Vote) error {
// Use INSERT OR REPLACE to automatically handle updating existing votes
_, err := db.Exec(`
INSERT OR REPLACE INTO votes (
voter_display_name,
Binary file not shown.