10 Commits
Author SHA1 Message Date
nolan ca44cf73df Add nostr login and periodic vote-beta announcements 2026-09-04 11:56:39 -05:00
nolan 5dde44e98e Tweaked donation request 2026-09-04 11:19:37 -05:00
nolan f6f6721f3a Tweaked voting logic 2026-09-04 10:43:53 -05:00
nolan c71fec44d6 Fixed annoying delay issue. 2026-09-04 10:21:24 -05:00
nolan fffb9c50b4 ??? 2026-08-16 12:02:37 -04: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
48 changed files with 2479 additions and 4047194 deletions
+3
View File
@@ -0,0 +1,3 @@
config/*
.venv/
__pycache__/
+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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+18 -2
View File
@@ -1,13 +1,29 @@
username: "magbot"
password: "zC1b9qCKT8Cv0D"
#password: "zC1b9qCKT8Cv0D"
password: cathandle
homeserver: "https://matrix.dinguscloud.com"
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
dbName: ""
# vote_host: "agmninex.online" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
vote_host: "agmninex.online"
vote_host: "workstation"
vote_path: "random" # 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
# --- Wait times (seconds) ---
sync_timeout: 3000 # Initial sync timeout for scraper/user-list (ms)
poll_interval: 10 # How often the scraper polls for new messages (s)
command_sync_timeout: 30000 # Sync timeout when listening for chat commands (ms)
post_interval: 0.2 # Delay between posting each suggestion (s)
initial_sync_timeout: 300 # Initial sync timeout for post_results (ms)
post_loop_interval: 1 # Delay between post_results loop iterations (s)
user_list_interval: 300 # How often the user-list service refreshes (s)
user_list_retry_interval: 60 # Retry delay on user-list error (s)
# --- Feature toggles ---
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
+16 -2
View File
@@ -2,8 +2,9 @@
username: "magbot"
password: "Dont tek psswrd"
# room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # The Lunch
homeserver: "https://matrix.dinguscloud.com" # Matrix homeserver the bot connects to
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # The Lunch
lookback_seconds: 86400 # 24 hours
lookback_limit: 400 # 400 messages
dump_mode: False
@@ -11,3 +12,16 @@ dbName: "my_db_name"
vote_host: "localhost" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
# vote_path: "my-room-slug" # Optional URL path for this room's voting page (auto-derived from the room name if unset)
vote_activity_hours: 72 # Hours a user must have been active within to be eligible to vote
# --- Wait times (seconds) ---
sync_timeout: 3000 # Initial sync timeout for scraper/user-list (ms)
poll_interval: 30 # How often the scraper polls for new messages (s)
command_sync_timeout: 30000 # Sync timeout when listening for chat commands (ms)
post_interval: 0.5 # Delay between posting each suggestion (s)
initial_sync_timeout: 300 # Initial sync timeout for post_results (ms)
post_loop_interval: 1 # Delay between post_results loop iterations (s)
user_list_interval: 300 # How often the user-list service refreshes (s)
user_list_retry_interval: 60 # Retry delay on user-list error (s)
# --- Feature toggles ---
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
+15
View File
@@ -0,0 +1,15 @@
username: "magbot"
password: "zC1b9qCKT8Cv0D"
homeserver: "https://jupiterbroadcasting.com"
#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
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
@@ -1,8 +1,9 @@
username: "magbot"
password: "zC1b9qCKT8Cv0D"
room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
homeserver: "https://jupiterbroadcasting.com"
#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
@@ -11,3 +12,4 @@ dbName: ""
vote_host: "agmninex.online"
vote_path: "jb-chat" # 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
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
+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
+22 -18
View File
@@ -9,8 +9,9 @@ import asyncio
import sqlite3
import time
import sys
from nio import AsyncClient, LoginResponse
from nio import AsyncClient, LoginResponse, RoomMessageText, 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,10 @@ class UserListManager:
return []
# Sync to get room state
await self.room.client.sync(timeout=3000)
assert self.room.client is not None
sync_resp = await self.room.client.sync(timeout=self.room.config.sync_timeout)
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)
@@ -116,6 +120,7 @@ class UserListManager:
fetched = 0
# Fetch messages in batches to count posts per user
assert self.room.client is not None
while fetched < lookback_limit:
try:
response = await self.room.client.room_messages(
@@ -124,24 +129,23 @@ class UserListManager:
limit=batch_size
)
if not response.chunk:
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
break
fetched += len(response.chunk)
for event in response.chunk:
if hasattr(event, 'sender') and hasattr(event, 'body'):
if isinstance(event, RoomMessageText):
sender = event.sender
post_counts[sender] = post_counts.get(sender, 0) + 1
# Track the most recent message timestamp for each user
if hasattr(event, 'server_timestamp'):
event_time = int(event.server_timestamp // 1000)
if sender not in last_activity or event_time > last_activity[sender]:
last_activity[sender] = event_time
# Track the absolute most recent post in the room
if event_time > most_recent_post:
most_recent_post = event_time
event_time = int(event.server_timestamp // 1000)
if sender not in last_activity or event_time > last_activity[sender]:
last_activity[sender] = event_time
# Track the absolute most recent post in the room
if event_time > most_recent_post:
most_recent_post = event_time
next_batch = response.end
@@ -161,7 +165,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 +211,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 = []
@@ -265,12 +269,12 @@ async def main():
# Wait 5 minutes before next update
print("\nWaiting 5 minutes before next update...")
await asyncio.sleep(300) # 5 minutes
await asyncio.sleep(manager.room.config.user_list_interval)
except Exception as e:
print(f"Error during user list update: {e}")
print("Retrying in 1 minute...")
await asyncio.sleep(60) # Wait 1 minute on error before retry
await asyncio.sleep(manager.room.config.user_list_retry_interval) # Wait 1 minute on error before retry
except KeyboardInterrupt:
print("\nShutting down user list service...")
+15 -12
View File
@@ -39,7 +39,10 @@ async def main():
if command:
print("!getvotes command detected, posting vote results...")
# Clear the web poll by marking all pending suggestions as posted
room.mark_submissions_posted()
# Read and format vote results
vote_message = format_vote_results(room)
@@ -56,8 +59,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 +73,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 +94,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 +105,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
@@ -111,7 +114,7 @@ async def wait_for_getvotes(room, since_ms):
"""Wait for !getvotes command in the chat."""
from nio import SyncResponse, RoomMessageText
sync_response = await room.client.sync(timeout=30000)
sync_response = await room.client.sync(timeout=room.config.command_sync_timeout)
if not isinstance(sync_response, SyncResponse):
return None
@@ -154,10 +157,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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
-1318672
View File
File diff suppressed because it is too large Load Diff
+18 -30363
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+38 -7663
View File
File diff suppressed because it is too large Load Diff
+18 -30363
View File
File diff suppressed because it is too large Load Diff
+5 -4
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'")
@@ -35,7 +35,8 @@ async def main():
return
# Initial sync establishes the next-batch; after this, listen from "now"
await room.client.sync(timeout=300)
assert room.client is not None
await room.client.sync(timeout=room.config.initial_sync_timeout)
room.write_room_slugs()
try:
@@ -59,7 +60,7 @@ async def main():
room.update_last_post()
else:
await room.alert_post("No new unposted suggestions found.")
await asyncio.sleep(1)
await asyncio.sleep(room.config.post_loop_interval)
finally:
await room.close()
+6
View File
@@ -0,0 +1,6 @@
{
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.14",
"typeCheckingMode": "basic"
}
+275 -135
View File
@@ -10,9 +10,10 @@ import yaml
import sqlite3
import re
import asyncio
from nio import AsyncClient, LoginResponse, RoomMessageText, SyncResponse, RoomSendResponse
from nio import AsyncClient, LoginResponse, MessageDirection, ProfileGetDisplayNameResponse, RoomMessageText, RoomMessagesResponse, RoomSendResponse, SyncResponse
from post import Post
import requests
import dbutil
default_search_terms = ["!suggest", "!Suggest", "!suggestion", "!Suggestion", "SUGGEST", "!sug"]
@@ -34,36 +35,50 @@ class Config:
self.vote_host = config.get("vote_host", "localhost")
self.vote_activity_hours = config.get("vote_activity_hours", 72)
self.vote_path = config.get("vote_path", "")
self.sync_timeout = config.get("sync_timeout", 3000)
self.poll_interval = config.get("poll_interval", 30)
self.command_sync_timeout = config.get("command_sync_timeout", 30000)
self.post_interval = config.get("post_interval", 0.5)
self.initial_sync_timeout = config.get("initial_sync_timeout", 300)
self.post_loop_interval = config.get("post_loop_interval", 1)
self.user_list_interval = config.get("user_list_interval", 300)
self.user_list_retry_interval = config.get("user_list_retry_interval", 60)
self.suggestion_announce_interval = config.get("suggestion_announce_interval", 0)
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
self.formatted_sugs = ""
self.posts = []
self.newly_inserted_posts = []
self.rejected_banned_posts = []
self.client = None
self.room = None
self.user_id = None
self.member = None
self.display_name = None
self.last_run = 0
self._display_name_cache = {}
if homeserver:
self.config.homeserver = homeserver
self.db_path = self.safe_db_name(self.config.room_id)
self.banned_db_path = "banned_users.db"
self.init_db()
self.init_banned_db()
self.init_suggestion_counter_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('''
_ = c.execute('''
CREATE TABLE IF NOT EXISTS suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT,
@@ -77,17 +92,17 @@ class Room:
)
''')
# Add is_posted column if it doesn't exist
c.execute("PRAGMA table_info(suggestions)")
_ = c.execute("PRAGMA table_info(suggestions)")
cols = [row[1] for row in c.fetchall()]
if 'is_posted' not in cols:
c.execute('ALTER TABLE suggestions ADD COLUMN is_posted INTEGER DEFAULT 0')
_ = c.execute('ALTER TABLE suggestions ADD COLUMN is_posted INTEGER DEFAULT 0')
if 'event_id' not in cols:
c.execute('ALTER TABLE suggestions ADD COLUMN event_id TEXT')
_ = 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)')
_ = 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('''
_ = c.execute('''
CREATE TABLE IF NOT EXISTS gettitles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT,
@@ -98,9 +113,9 @@ class Room:
UNIQUE(sender, timestamp)
)
''')
# New: table to log all !mayhem detections (prevents duplicates)
c.execute('''
_ = c.execute('''
CREATE TABLE IF NOT EXISTS mayhem (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT,
@@ -111,9 +126,9 @@ class Room:
UNIQUE(sender, timestamp)
)
''')
# New: table to log all !countvotes detections (prevents duplicates)
c.execute('''
_ = c.execute('''
CREATE TABLE IF NOT EXISTS countvotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT,
@@ -124,9 +139,9 @@ class Room:
UNIQUE(sender, timestamp)
)
''')
# New: table to log all !voteurl detections (prevents duplicates)
c.execute('''
_ = c.execute('''
CREATE TABLE IF NOT EXISTS voteurl (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT,
@@ -137,9 +152,94 @@ class Room:
UNIQUE(sender, timestamp)
)
''')
conn.commit()
dbutil.commit(conn)
conn.close()
def init_banned_db(self):
"""Initialize the shared banned-users database (primary key + user string)."""
conn = dbutil.connect(self.banned_db_path)
c = conn.cursor()
_ = c.execute('''
CREATE TABLE IF NOT EXISTS banned_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT UNIQUE
)
''')
dbutil.commit(conn)
conn.close()
def is_banned(self, user):
"""Return True if the given Matrix account/homeserver string is banned."""
if not user:
return False
conn = dbutil.connect(self.banned_db_path)
c = conn.cursor()
_ = c.execute("SELECT 1 FROM banned_users WHERE user = ?", (user,))
row = c.fetchone()
conn.close()
return row is not None
def ban_user(self, user):
"""Add a Matrix account/homeserver string to the banned list."""
conn = dbutil.connect(self.banned_db_path)
c = conn.cursor()
_ = c.execute("INSERT OR IGNORE INTO banned_users (user) VALUES (?)", (user,))
dbutil.commit(conn)
conn.close()
def unban_user(self, user):
"""Remove a Matrix account/homeserver string from the banned list."""
conn = dbutil.connect(self.banned_db_path)
c = conn.cursor()
_ = c.execute("DELETE FROM banned_users WHERE user = ?", (user,))
dbutil.commit(conn)
conn.close()
def init_suggestion_counter_db(self):
"""Initialize the suggestion counter table for periodic announcements."""
conn = dbutil.connect(self.db_path)
c = conn.cursor()
_ = c.execute('''
CREATE TABLE IF NOT EXISTS suggestion_counter (
id INTEGER PRIMARY KEY CHECK (id = 1),
count INTEGER DEFAULT 0
)
''')
_ = c.execute('INSERT OR IGNORE INTO suggestion_counter (id, count) VALUES (1, 0)')
dbutil.commit(conn)
conn.close()
def get_suggestion_counter(self):
conn = dbutil.connect(self.db_path)
c = conn.cursor()
_ = c.execute('SELECT count FROM suggestion_counter WHERE id = 1')
row = c.fetchone()
conn.close()
return row[0] if row else 0
def increment_suggestion_counter(self, amount=1):
conn = dbutil.connect(self.db_path)
c = conn.cursor()
_ = c.execute('UPDATE suggestion_counter SET count = count + ? WHERE id = 1', (amount,))
dbutil.commit(conn)
conn.close()
return self.get_suggestion_counter()
async def check_and_announce_vote_beta(self, new_count):
"""Post a beta announcement if the suggestion count hits the configured interval."""
interval = self.config.suggestion_announce_interval
if interval <= 0:
return
if new_count > 0 and new_count % interval == 0:
url = self.vote_url()
await self.alert_post(
f"Psst! HTTP voting is now in beta! You can vote on titles at:\n{url}"
)
async def reject_banned_suggestion(self, post):
"""Reply to a banned user's suggestion and do not insert it."""
await self.alert_post(f"{post.display_name}: this user has been banned from submitting titles")
async def login_to_matrix(self):
# Initialize client if needed
if self.client is None:
@@ -160,15 +260,21 @@ class Room:
return False
async def get_display_name(self, user_id):
if user_id in self._display_name_cache:
return self._display_name_cache[user_id]
assert self.client is not None
name = user_id
room = self.client.rooms.get(self.config.room_id)
if room and user_id in room.users:
member = room.users[user_id]
if hasattr(member, "display_name") and member.display_name:
return member.display_name
resp = await self.client.get_displayname(user_id)
if hasattr(resp, "displayname") and resp.displayname:
return resp.displayname
return user_id
name = member.display_name
if name == user_id:
resp = await self.client.get_displayname(user_id)
if isinstance(resp, ProfileGetDisplayNameResponse) and resp.displayname:
name = resp.displayname
self._display_name_cache[user_id] = name
return name
@staticmethod
def slugify(text):
@@ -213,33 +319,39 @@ 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
self.get_last_run()
assert self.client is not None
while fetched < limit:
fetch_amount = min(batch_size, limit - fetched)
response = await self.client.room_messages(
self.config.room_id,
start=next_batch,
limit=fetch_amount,
direction="b"
direction=MessageDirection.back
)
if not hasattr(response, "chunk") or not response.chunk:
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
break
for event in response.chunk:
# If we've reached messages older than the last run, stop (incremental resume)
if self.last_run and getattr(event, "server_timestamp", 0) < self.last_run:
next_batch = None
return
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 +362,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 +377,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,80 +391,83 @@ 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:
if self.is_banned(post.poster):
self.rejected_banned_posts.append(post)
continue
try:
c.execute(
_ = c.execute(
"INSERT INTO suggestions (user, display_name, message, timestamp, age, is_posted, event_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
post.as_tuple()
)
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('''
_ = c.execute('''
CREATE TABLE IF NOT EXISTS last_run (
room_id TEXT PRIMARY KEY,
timestamp INTEGER,
datetime TEXT
)
''')
c.execute("PRAGMA table_info(last_run)")
_ = c.execute("PRAGMA table_info(last_run)")
cols = [row[1] for row in c.fetchall()]
if 'datetime' not in cols:
c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
_ = c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
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()
_ = c.execute('INSERT OR REPLACE INTO last_run (room_id, timestamp, datetime) VALUES (?, ?, ?)', (room_id, now, now_str))
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('''
_ = c.execute('''
CREATE TABLE IF NOT EXISTS last_run (
room_id TEXT PRIMARY KEY,
timestamp INTEGER,
datetime TEXT
)
''')
c.execute("PRAGMA table_info(last_run)")
_ = c.execute("PRAGMA table_info(last_run)")
cols = [row[1] for row in c.fetchall()]
if 'datetime' not in cols:
c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
c.execute('SELECT timestamp FROM last_run WHERE room_id = ?', (room_id,))
_ = c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
_ = c.execute('SELECT timestamp FROM last_run WHERE room_id = ?', (room_id,))
row = c.fetchone()
conn.close()
if row:
@@ -362,60 +477,60 @@ 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('''
_ = c.execute('''
INSERT OR IGNORE INTO gettitles (event_id, sender, timestamp, datetime)
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('''
_ = c.execute('''
INSERT OR IGNORE INTO mayhem (event_id, sender, timestamp, datetime)
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('''
_ = c.execute('''
INSERT OR IGNORE INTO countvotes (event_id, sender, timestamp, datetime)
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('''
_ = c.execute('''
INSERT OR IGNORE INTO voteurl (event_id, sender, timestamp, datetime)
VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1
conn.commit()
dbutil.commit(conn)
conn.close()
return inserted
@@ -426,30 +541,31 @@ class Room:
next_batch = None
suggestion_votes = {}
all_events = []
assert self.client is not None
# 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(
self.config.room_id,
start=next_batch,
limit=batch_size,
direction="b"
direction=MessageDirection.back
)
if not hasattr(response, "chunk") or not response.chunk:
if not isinstance(response, RoomMessagesResponse) 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 +574,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 +585,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 +627,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
@@ -521,14 +637,15 @@ class Room:
if since_ms is None:
since_ms = int(_time.time() * 1000)
assert self.client is not None
while True:
sync_response = await self.client.sync(timeout=30000)
sync_response = await self.client.sync(timeout=self.config.command_sync_timeout)
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 +655,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 +664,7 @@ class Room:
continue
print("!gettitles detected")
return "gettitles"
# Handle !mayhem
elif body.startswith("!mayhem"):
event_id = getattr(event, "event_id", None)
@@ -555,7 +672,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 +683,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)
@@ -584,17 +701,18 @@ class Room:
return result == "gettitles"
async def alert_post(self, post):
assert self.client is not None
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 +722,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,27 +744,27 @@ class Room:
conditions.append("is_posted = 0")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY timestamp ASC"
c.execute(query, tuple(params))
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('''
_ = c.execute('''
CREATE TABLE IF NOT EXISTS last_post (
room_id TEXT PRIMARY KEY,
timestamp INTEGER,
datetime TEXT
)
''')
c.execute("PRAGMA table_info(last_post)")
_ = c.execute("PRAGMA table_info(last_post)")
cols = [row[1] for row in c.fetchall()]
if 'datetime' not in cols:
c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
c.execute('SELECT timestamp FROM last_post WHERE room_id = ?', (self.config.room_id,))
_ = c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
_ = c.execute('SELECT timestamp FROM last_post WHERE room_id = ?', (self.config.room_id,))
row = c.fetchone()
conn.close()
if row:
@@ -654,51 +772,51 @@ 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('''
_ = c.execute('''
CREATE TABLE IF NOT EXISTS last_post (
room_id TEXT PRIMARY KEY,
timestamp INTEGER,
datetime TEXT
)
''')
c.execute("PRAGMA table_info(last_post)")
_ = c.execute("PRAGMA table_info(last_post)")
cols = [row[1] for row in c.fetchall()]
if 'datetime' not in cols:
c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
_ = 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):
assert self.client is not None
# 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,19 +825,20 @@ 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
async def post_suggestions_individually(self):
"""Post each unposted suggestion as its own message with a 👍 reaction for voting."""
assert self.client is not None
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
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 +846,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):
@@ -751,8 +881,6 @@ class Room:
print(f"Failed to post suggestion {idx}: {resp}")
continue
post.is_posted = True
reaction = {
"m.relates_to": {
"rel_type": "m.annotation",
@@ -766,32 +894,46 @@ class Room:
content=reaction,
)
await asyncio.sleep(0.5)
await asyncio.sleep(self.config.post_interval)
self.mark_posts_as_posted([p for p in unposted_posts if p.is_posted])
print("Suggestions posted!")
def mark_posts_as_posted(self, posts=None):
"""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(
_ = 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()
def mark_submissions_posted(self):
conn = dbutil.connect(self.db_path)
c = conn.cursor()
_ = c.execute("UPDATE suggestions SET is_posted = 1 WHERE is_posted = 0")
dbutil.commit(conn)
conn.close()
async def close(self):
if self.client:
await self.client.close()
async def acknowledge_rejected_suggestions(self):
"""Reply to banned users that they cannot submit titles."""
assert self.client is not None
for post in self.rejected_banned_posts:
await self.reject_banned_suggestion(post)
self.rejected_banned_posts = []
async def acknowledge_new_suggestions(self):
"""React ✅ to newly recorded suggestions to confirm they were saved."""
assert self.client is not None
if not self.newly_inserted_posts:
return
for post in self.newly_inserted_posts:
@@ -816,5 +958,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
+8 -2
View File
@@ -21,7 +21,8 @@ async def main():
# await room.alert_post("SCRAPER LOGGED IN")
# Initial sync
await room.client.sync(timeout=3000)
assert room.client is not None
await room.client.sync(timeout=room.config.sync_timeout)
room.write_room_slugs()
try:
@@ -34,11 +35,16 @@ async def main():
# Scrape and write to database
await room.get_convos()
room.write_to_db()
if room.newly_inserted_posts:
count = room.increment_suggestion_counter(len(room.newly_inserted_posts))
await room.check_and_announce_vote_beta(count)
# Reply to banned users that they cannot submit titles
await room.acknowledge_rejected_suggestions()
# Ack newly recorded suggestions with a ✅ reaction
await room.acknowledge_new_suggestions()
room.update_last_run()
print(f"Stored {len(room.posts)} suggestions in database.")
await asyncio.sleep(30)
await asyncio.sleep(room.config.poll_interval)
finally:
# Close connection
await room.close()
+7
View File
@@ -1,3 +1,4 @@
Forgot I had a suggestion simulator
simulate suggestion 1
simulated suggestion 2
:wq
@@ -13,3 +14,9 @@ I like trains
freaking foamer
WHERE DID MY BOOTS GO
I have a booty problem
super pooper
!double bang!
!suggest double suggestion
they think they can stop my brilliance!
They said I couldn't teach a llama to drive
voom voom
Binary file not shown.
Binary file not shown.
+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=
+163 -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,159 @@ 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;
}
#auth_method_select {
text-align: center;
margin-bottom: 10px;
}
#auth_method_select p {
margin-bottom: 15px;
font-size: 16px;
color: #555;
}
#matrix_auth_btn {
background-color: #0dbd8b;
margin-right: 10px;
}
#matrix_auth_btn:hover {
background-color: #0aa37a;
}
#nostr_auth_btn {
background-color: #f57c00;
}
#nostr_auth_btn:hover {
background-color: #ef6c00;
}
#login_form {
max-width: 400px;
}
#login_form input[type="password"] {
width: 100%;
padding: 10px;
margin-bottom: 15px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
#back_link {
display: inline-block;
margin-top: 20px;
color: #666;
font-size: 14px;
}
#back_link:hover {
color: #333;
}
#sign_out_btn {
background-color: #999;
font-size: 12px;
padding: 4px 12px;
margin-top: 5px;
margin-bottom: 15px;
}
#sign_out_btn:hover {
background-color: #777;
}
+255 -57
View File
@@ -1,19 +1,185 @@
// Fetch submissions from the JSON endpoint and populate the suggestion list
let currentUser = null;
let currentChallenge = null;
let authMethod = null;
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// --- Matrix auth ---
function goToMatrixLogin() {
const slug = window.location.pathname.split('/').filter(Boolean)[0];
window.location.href = '/' + slug + '/matrix-login';
}
function signOut() {
sessionStorage.removeItem('matrix_session_token');
currentUser = null;
authMethod = null;
window.location.reload();
}
function showNostrAuth() {
document.getElementById('auth_method_select').style.display = 'none';
document.getElementById('nostr_auth_section').style.display = 'block';
}
async function checkMatrixSession() {
const sessionToken = sessionStorage.getItem('matrix_session_token');
if (!sessionToken) return false;
try {
const slug = window.location.pathname.split('/').filter(Boolean)[0];
const resp = await fetch('/' + slug + '/matrixVerify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken })
});
if (resp.ok) {
const data = await resp.json();
currentUser = { displayName: data.display_name, sessionToken: sessionToken };
authMethod = 'matrix';
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();
return true;
} else {
sessionStorage.removeItem('matrix_session_token');
}
} catch (e) {
sessionStorage.removeItem('matrix_session_token');
}
return false;
}
// --- 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
});
currentUser = {
npub: npub,
displayName: data.display_name,
signedEvent: signedEvent
};
authMethod = 'nostr';
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="suggestion">${sub.submission}</span>
<input type="radio" name="vote" value="${sub.submission}" data-submitter="${sub.submitter}" data-time="${sub.submission_time}">
<span class="suggestor">${escapeHtml(sub.submitter)} SUGGESTED</span> :
<span class="suggestion">${escapeHtml(sub.submission)}</span>
<input type="radio" name="vote" value="${escapeHtml(sub.submission)}" data-submitter="${escapeHtml(sub.submitter)}" data-time="${sub.submission_time}">
`;
submissionList.appendChild(li);
});
@@ -26,71 +192,103 @@ 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)
submission_time: parseInt(selectedRadio.dataset.time)
};
// 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 {
if (authMethod === 'matrix') {
const resp = await fetch('matrixVote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_token: currentUser.sessionToken,
...voteData
})
});
if (resp.ok) {
alert('Vote submitted successfully!');
selectedRadio.checked = false;
} else {
const text = await resp.text();
alert('Error: ' + text);
}
} else if (authMethod === 'nostr') {
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;
}
const challengeData = await challengeResp.json();
const challengeMsg = challengeData.challenge;
const signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: challengeMsg
});
const voteResp = await fetch('verifyVote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
npub: currentUser.npub,
challenge: challengeMsg,
...voteData,
signed_event: signedEvent
})
});
if (voteResp.ok) {
alert('Vote submitted successfully!');
selectedRadio.checked = false;
} else {
const text = await voteResp.text();
alert('Error: ' + text);
}
}
})
.catch(error => {
} catch (error) {
console.error('Error submitting vote:', error);
alert('Error submitting vote: ' + error.message);
});
}
}
// Load submissions when page loads
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);
// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
setInterval(() => {
if (currentUser) loadSubmissions();
}, 30000);
const matrixAuthed = await checkMatrixSession();
if (matrixAuthed) return;
if (hasNip07()) {
document.getElementById('nip07_badge').style.display = 'block';
}
// Optionally refresh submissions periodically
setInterval(loadSubmissions, 30000); // Refresh every 30 seconds
});
+143
View File
@@ -0,0 +1,143 @@
<!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();
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 {
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 {
let npubHex = npub;
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;
const slug = window.location.pathname.split('/')[1];
const linkMsg = `Link npub ${npubHex} to Matrix user ${displayName} for room ${slug}`;
let signedEvent;
if (window.nostr) {
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>
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in with Matrix</title>
<link rel="stylesheet" href="css.css">
</head>
<body>
<h1>Sign in with Matrix</h1>
<p>Log in with your Matrix account to vote.</p>
<div id="login_form">
<label for="homeserver">Homeserver:</label>
<input type="text" id="homeserver" placeholder="https://matrix.org">
<label for="username">Username:</label>
<input type="text" id="username" placeholder="@username:matrix.org">
<label for="password">Password:</label>
<input type="password" id="password" placeholder="Password">
<button id="login_btn" onclick="matrixLogin()">Sign In</button>
<div id="status"></div>
</div>
<a id="back_link" href="../">Back to voting page</a>
<script>
async function init() {
const parts = window.location.pathname.split('/').filter(Boolean);
const slug = parts[0];
if (!slug) {
setStatus('Invalid URL. Go to the voting page first.', true);
return;
}
try {
const resp = await fetch('/' + slug + '/roomConfig');
if (resp.ok) {
const config = await resp.json();
document.getElementById('homeserver').value = config.homeserver || 'https://matrix.org';
}
} catch (e) {
document.getElementById('homeserver').value = 'https://matrix.org';
}
const sessionToken = sessionStorage.getItem('matrix_session_token');
if (sessionToken) {
window.location.href = '/' + slug + '/';
}
}
async function matrixLogin() {
const homeserver = document.getElementById('homeserver').value.trim().replace(/\/+$/, '');
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!homeserver || !username || !password) {
setStatus('All fields are required.', true);
return;
}
setStatus('Checking homeserver...');
document.getElementById('login_btn').disabled = true;
try {
const loginTypesResp = await fetch(homeserver + '/_matrix/client/v3/login');
if (!loginTypesResp.ok) {
setStatus('Could not reach homeserver. Check the URL.', true);
document.getElementById('login_btn').disabled = false;
return;
}
const loginTypes = await loginTypesResp.json();
const supportsPassword = loginTypes.flows && loginTypes.flows.some(f => f.type === 'm.login.password');
if (!supportsPassword) {
setStatus('This homeserver does not support password login. Try the Nostr option instead.', true);
document.getElementById('login_btn').disabled = false;
return;
}
setStatus('Logging in...');
const loginResp = await fetch(homeserver + '/_matrix/client/v3/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'm.login.password',
identifier: { type: 'm.id.user', user: username },
password: password
})
});
if (!loginResp.ok) {
setStatus('Login failed: ' + (loginResp.status === 403 ? 'Invalid credentials' : 'Homeserver error'), true);
document.getElementById('login_btn').disabled = false;
return;
}
const loginData = await loginResp.json();
const accessToken = loginData.access_token;
setStatus('Verifying identity...');
const parts = window.location.pathname.split('/').filter(Boolean);
const slug = parts[0];
const authResp = await fetch('/' + slug + '/matrixAuth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_token: accessToken,
homeserver: homeserver
})
});
if (!authResp.ok) {
const errText = await authResp.text();
setStatus('Verification failed: ' + errText, true);
document.getElementById('login_btn').disabled = false;
return;
}
const authData = await authResp.json();
sessionStorage.setItem('matrix_session_token', authData.session_token);
window.location.href = '/' + slug + '/';
} catch (err) {
setStatus('Error: ' + err.message, true);
document.getElementById('login_btn').disabled = false;
}
}
function setStatus(msg, isError) {
const el = document.getElementById('status');
el.innerHTML = msg;
el.className = isError ? 'error' : 'success';
}
init();
</script>
</body>
</html>
+38 -9
View File
@@ -8,15 +8,44 @@
</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="auth_method_select">
<p>How would you like to sign in?</p>
<button id="matrix_auth_btn" onclick="goToMatrixLogin()">Sign in with Matrix</button>
<button id="nostr_auth_btn" onclick="showNostrAuth()">Sign in with Nostr</button>
</div>
<div id="nostr_auth_section" style="display:none;">
<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>
<div id="vote_section" style="display:none;">
<div id="logged_in_as"></div>
<button id="sign_out_btn" onclick="signOut()">Sign Out</button>
<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>
+775 -35
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.