6 Commits
79 changed files with 1378 additions and 3927 deletions
+81 -90
View File
@@ -1,118 +1,109 @@
# titlebot-ng
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.
A Matrix chat bot for Jupiter Broadcasting's “The Lunch” and “Linux Unplugged” chat rooms that collects title suggestions and runs voting through 👍 reactions.
### From the Owner
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
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
- **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 results from the voting server
- `!voteurl` - Shares the voting URL where users can cast their votes
- **Persistent Storage**: Per-room SQLite databases with duplicate prevention
- **Acknowledgment System**: Reacts with ✅ to confirm suggestion collection
- **Command Deduplication**: All commands are logged to prevent duplicate executions
## Quick Start
1. Clone the repo and copy the example config for each room you want to monitor:
1. Create a Python environment and install dependencies:
```bash
cp configs/config_example.yaml configs/config.yaml
# Edit configs/config.yaml with your Matrix credentials and room ID
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
2. Run everything with a single command (installs Go and creates the venv if missing):
2. Copy the example config for each room you want to monitor:
```bash
cp configs/config_example.yaml configs/main-room.yaml
cp configs/config_example.yaml configs/second-room.yaml
```
Edit each file with the appropriate Matrix bot credentials and a different `room_id`.
3. Start the room bots:
```bash
./run_all.sh
```
Or run the components individually:
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install matrix-nio pyyaml requests
cd jsonEndpoint && go build -o jep . && ./jep
cd voteServer && go build -o vs . && ./vs
```
The launcher creates the Python environment and installs dependencies if the environment is missing. It starts two independent processes per room: a suggestion scraper and a command/posting bot. Logs and PID files include the full config filename. A failed room process is retried without stopping the others. Press Ctrl+C to stop the services.
## Commands
| Command | Action |
| --- | --- |
| `!suggest <title>` | Submit a title suggestion. Also accepts `!Suggest`, `!suggestion`, `!Suggestion`, `SUGGEST`, and `!sug`. |
| `!gettitles` | Post pending suggestions as individual messages, each with a 👍 reaction, preceded by voting instructions and the command list. |
| `!countvotes` | Report the latest voting round's current emoji results, ranked most to fewest votes. |
| `!getvotes` | Alias for `!countvotes`. |
| `!clear` | Mark all pending suggestions as posted so they are excluded from future rounds. |
| `!mayhem` | Generate chaotic text using Ollama. |
React with 👍 on a suggestion post to vote. Each person's thumbs-up counts once per title; the bot's starter reaction does not count. Thumbs-up skin-tone variants count as votes too. Removed reactions are excluded. Results include titles with zero votes, and ties appear in suggestion order. Run either results command again to refresh the ranking as people keep voting.
Successfully posted suggestions leave the pending queue. Results commands leave the queue untouched, and `!clear` preserves existing voting posts and their reactions. After clearing, `!gettitles` responds “No new title suggestions posted.” unless new suggestions have been collected.
## Configuration
One YAML file per room in `configs/`. Example (`configs/config_example.yaml`):
Put one `.yaml` or `.yml` file per room in `configs/`, with any filename. `config.yaml` is not required. The bundled `config_example.yaml` template and files ending in `.hold` are ignored. Restart the launcher after adding or removing configs.
Example:
```yaml
username: "magbot"
username: "@your-bot:matrix.org"
password: "your_bot_password"
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
homeserver: "https://matrix.org"
room_id: "!your_room_id:matrix.org"
lookback_limit: 7200
batch_size: 100
poll_interval: 30
sync_timeout: 3000
command_sync_timeout: 30000
initial_sync_timeout: 300
post_interval: 0.5
post_loop_interval: 1
```
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.
Each room has its own suggestion database and command history. The bot acknowledges newly collected suggestions with ✅ and rejects submissions from accounts in the shared banned-users database.
## Architecture
- **`room.py`**: Core `Room` class handling Matrix connection, database operations, and command processing
- **`post.py`**: `Post` class representing individual suggestions
- **`scraper.py`**: Main suggestion collection loop
- **`post_results.py`**: Suggestion posting service that waits for `!gettitles` commands
- **`get_votes.py`**: Vote results posting service that waits for `!getvotes` commands
- **`get_user_list.py`**: User list collection service for vote validation
- **`jsonEndpoint/`**: Go service providing JSON API for submissions and users (port 9080)
- **`voteServer/`**: Go web server for voting interface (port 9081)
- **`customOllama/`**: Custom Ollama model for mayhem text generation
- **`run_all.sh`**: Master script to start all services
## Components and Storage
## Database Schema
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
- **`room.py`**: Matrix connection, suggestion storage, command handling, posting, and live reaction counting.
- **`post.py`**: Suggestion data model.
- **`scraper.py`**: Collects and acknowledges suggestions.
- **`post_results.py`**: Handles commands and posts suggestions when `!gettitles` is received.
- **`run_all.sh`**: Starts and monitors the two Python services for each room.
- **`simulate_suggestions.py`**: Sends suggestions from a text file for manual testing.
- **`dbutil.py`**: SQLite connections and commit handling.
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
Per-room `suggestions_<room>.db` files store suggestions, posted status, original event IDs, and command deduplication records. Shared `last_run.db` and `last_post.db` track collection and posting timestamps; `banned_users.db` stores blocked accounts. Vote totals are read from Matrix reactions whenever results are requested.
## Dependencies
- **Python**: matrix-nio, pyyaml, requests, sqlite3 (built-in)
- **Go**: github.com/mattn/go-sqlite3, gopkg.in/yaml.v2
To run the components individually, use the same config path for both:
```bash
.venv/bin/python scraper.py configs/main-room.yaml
.venv/bin/python post_results.py configs/main-room.yaml
```
## Dependencies and Tests
Python 3, matrix-nio, PyYAML, requests, and Python's built-in SQLite support.
```bash
.venv/bin/python -m unittest discover -v
```
## Optional: Ollama Integration
For `!mayhem` command functionality:
1. Install [Ollama](https://ollama.ai/)
2. Build the custom model: `ollama create mad_cat_man:latest -f customOllama/Modelfile`
3. Ensure Ollama is running on `localhost:11434`
## Notes
- Each room gets its own SQLite database for suggestions
- The bot acknowledges collected suggestions with ✅ reactions
- 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
For `!mayhem`, install [Ollama](https://ollama.ai/), then build the custom model:
# TODO:
- Rewrite in Rust
- Rewrite in Zig
- Rewrite in GO
<s>- Lookback is being a PITA. Needs work </s>
```bash
ollama create mad_cat_man:latest -f customOllama/Modelfile
```
Run Ollama on `localhost:11434`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
# Change name to config.yaml when all info is filled in.
username: "magbot"
password: "Dont tek psswrd"
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
dbName: "my_db_name"
# --- 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)
+10
View File
@@ -0,0 +1,10 @@
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: ""
+10
View File
@@ -0,0 +1,10 @@
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: ""
Executable → Regular
+13 -27
View File
@@ -1,27 +1,13 @@
# Change name to config.yaml when all info is filled in.
username: "magbot"
password: "Dont tek psswrd"
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
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)
# Copy this template to a separate .yaml or .yml file for each Matrix room.
username: "@your-bot:matrix.org"
password: "your_bot_password"
homeserver: "https://matrix.org"
room_id: "!your_room_id:matrix.org"
lookback_limit: 7200
batch_size: 100
poll_interval: 30
sync_timeout: 3000
command_sync_timeout: 30000
initial_sync_timeout: 300
post_interval: 0.5
post_loop_interval: 1
-15
View File
@@ -1,15 +0,0 @@
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)
+20
View File
@@ -0,0 +1,20 @@
username: "magbot"
#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: ""
# --- 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)
-15
View File
@@ -1,15 +0,0 @@
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: "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)
+20
View File
@@ -0,0 +1,20 @@
username: "magbot"
#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: ""
# --- 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)
@@ -10,10 +10,6 @@ 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: "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)
@@ -22,8 +18,3 @@ 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)
-286
View File
@@ -1,286 +0,0 @@
"""
Python script that reads configs/config.yaml and scrapes a list of active users from the Matrix room.
Stores the user list in a SQLite database named after the room.
"""
import yaml
import re
import asyncio
import sqlite3
import time
import sys
from nio import AsyncClient, LoginResponse, RoomMessageText, RoomMessagesResponse, SyncResponse
from room import Room
import dbutil
class UserListManager:
"""Manages collection and storage of active users in a Matrix room."""
def __init__(self, config_path="configs/config.yaml"):
self.room = Room(config_path)
self.users = []
self.db_path = self.get_user_db_path()
self.init_user_db()
def get_user_db_path(self):
"""Generate database path for user list, similar to suggestions database."""
safe = re.sub(r'[^a-zA-Z0-9_-]', '_', self.room.config.room_id)
return f"users_{safe}.db"
def init_user_db(self):
"""Initialize the user list database."""
conn = dbutil.connect(self.db_path)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT UNIQUE,
display_name TEXT,
last_seen INTEGER,
last_updated INTEGER,
num_posts INTEGER DEFAULT 0,
last_active INTEGER DEFAULT 0,
UNIQUE(user_id)
)
''')
# Add num_posts column if it doesn't exist (for existing databases)
c.execute("PRAGMA table_info(users)")
cols = [row[1] for row in c.fetchall()]
if 'num_posts' not in cols:
c.execute('ALTER TABLE users ADD COLUMN num_posts INTEGER DEFAULT 0')
if 'last_active' not in cols:
c.execute('ALTER TABLE users ADD COLUMN last_active INTEGER DEFAULT 0')
# Table to track when user list was last fetched
c.execute('''
CREATE TABLE IF NOT EXISTS fetch_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER,
user_count INTEGER,
datetime TEXT,
most_recent_post INTEGER DEFAULT 0
)
''')
# Add most_recent_post column if it doesn't exist
c.execute("PRAGMA table_info(fetch_log)")
cols = [row[1] for row in c.fetchall()]
if 'most_recent_post' not in cols:
c.execute('ALTER TABLE fetch_log ADD COLUMN most_recent_post INTEGER DEFAULT 0')
dbutil.commit(conn)
conn.close()
async def get_room_members(self):
"""Fetch all members from the Matrix room and count their posts."""
# Ensure we're logged in
if not await self.room.login_to_matrix():
print("Failed to login to Matrix")
return []
# Sync to get room state
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)
if not room_obj:
print(f"Could not find room {self.room.config.room_id}")
return []
# Count posts by fetching message history
print("Counting posts per user (this may take a moment)...")
post_counts, last_activity, most_recent_post = await self.count_user_posts()
users = []
for user_id in room_obj.users:
display_name = await self.room.get_display_name(user_id)
users.append({
'user_id': user_id,
'display_name': display_name,
'last_seen': int(time.time()),
'num_posts': post_counts.get(user_id, 0),
'last_active': last_activity.get(user_id, 0)
})
self.users = users
self.most_recent_post = most_recent_post
return users
async def count_user_posts(self):
"""Count total number of posts by each user in the room and track last activity."""
post_counts = {}
last_activity = {}
most_recent_post = 0
batch_size = self.room.config.batch_size
lookback_limit = self.room.config.user_lookback_limit
next_batch = None
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(
room_id=self.room.config.room_id,
start=next_batch,
limit=batch_size
)
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
break
fetched += len(response.chunk)
for event in response.chunk:
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
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
# Stop if we've reached the end or the lookback limit
if not next_batch or fetched >= lookback_limit:
break
except Exception as e:
print(f"Error fetching messages: {e}")
break
return post_counts, last_activity, most_recent_post
def write_users_to_db(self):
"""Write collected users to the database."""
if not self.users:
print("No users to write to database")
return
conn = dbutil.connect(self.db_path)
c = conn.cursor()
current_time = int(time.time())
for user in self.users:
try:
c.execute('''
INSERT INTO users (user_id, display_name, last_seen, last_updated, num_posts, last_active)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
display_name = excluded.display_name,
last_seen = excluded.last_seen,
last_updated = excluded.last_updated,
num_posts = excluded.num_posts,
last_active = excluded.last_active
''', (
user['user_id'],
user['display_name'],
user['last_seen'],
current_time,
user['num_posts'],
user.get('last_active', 0)
))
except sqlite3.IntegrityError:
# Update existing record
c.execute('''
UPDATE users
SET display_name = ?, last_seen = ?, last_updated = ?, num_posts = ?, last_active = ?
WHERE user_id = ?
''', (
user['display_name'],
user['last_seen'],
current_time,
user['num_posts'],
user.get('last_active', 0),
user['user_id']
))
# Log this fetch
from datetime import datetime
c.execute('''
INSERT INTO fetch_log (timestamp, user_count, datetime, most_recent_post)
VALUES (?, ?, ?, ?)
''', (current_time, len(self.users), datetime.now().isoformat(), getattr(self, 'most_recent_post', 0)))
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 = 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 = []
for row in c.fetchall():
users.append({
'user_id': row[0],
'display_name': row[1],
'last_seen': row[2],
'last_updated': row[3],
'num_posts': row[4],
'last_active': row[5] if len(row) > 5 else 0
})
conn.close()
return users
async def close(self):
"""Close Matrix client connection."""
await self.room.close()
async def main():
"""Main function to fetch and store user list continuously."""
config_path = sys.argv[1] if len(sys.argv) > 1 else "configs/config.yaml"
manager = UserListManager(config_path)
try:
print("Starting user list collection service...")
print("This will run continuously, updating the user list every 5 minutes.")
while True:
try:
print("\nFetching user list from Matrix room...")
users = await manager.get_room_members()
if users:
print(f"Found {len(users)} users in room")
manager.write_users_to_db()
# Display top users by post count
print("\nTop users by post count:")
sorted_users = sorted(users, key=lambda u: u['num_posts'], reverse=True)
for user in sorted_users[:10]: # Show top 10
print(f" - {user['display_name']}: {user['num_posts']} posts")
if len(sorted_users) > 10:
print(f" ... and {len(sorted_users) - 10} more users")
else:
print("No users found or could not connect to room")
# Wait 5 minutes before next update
print("\nWaiting 5 minutes before next update...")
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(manager.room.config.user_list_retry_interval) # Wait 1 minute on error before retry
except KeyboardInterrupt:
print("\nShutting down user list service...")
finally:
await manager.close()
if __name__ == "__main__":
asyncio.run(main())
-213
View File
@@ -1,213 +0,0 @@
#!/usr/bin/env python3
"""
Script to monitor Matrix chat for !getvotes command and post vote results.
Reads from voteServer/vote_summary.json and posts results ordered from least to most votes.
"""
import asyncio
import json
import os
import sys
import time as _time
from room import Room
async def main():
"""Main function to monitor for !getvotes command and post results."""
# Initialize room connection
config_path = sys.argv[1] if len(sys.argv) > 1 else "configs/config.yaml"
room = Room(config_path=config_path)
# Login to Matrix
if not await room.login_to_matrix():
print("Failed to login to Matrix")
sys.exit(1)
print("Logged in and monitoring for !getvotes command...")
print("Press Ctrl+C to exit")
# Add getvotes table to database if it doesn't exist
init_getvotes_table(room)
# Start listening for commands from "now"
since_ms = int(_time.time() * 1000)
try:
while True:
# Wait for !getvotes command
command = await wait_for_getvotes(room, since_ms)
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)
# Post to chat
await room.alert_post(vote_message)
print("Vote results posted successfully")
except KeyboardInterrupt:
print("\nShutting down...")
finally:
await room.close()
def init_getvotes_table(room):
"""Initialize the getvotes tracking table in the database."""
import dbutil
conn = dbutil.connect(room.db_path)
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS getvotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT,
sender TEXT,
timestamp INTEGER,
datetime TEXT,
UNIQUE(event_id),
UNIQUE(sender, timestamp)
)
''')
dbutil.commit(conn)
conn.close()
def get_last_getvotes_timestamp(room):
"""Get the timestamp of the last !getvotes command for this room."""
import dbutil
conn = dbutil.connect(room.db_path)
c = conn.cursor()
c.execute('''
SELECT MAX(timestamp) FROM getvotes
''')
row = c.fetchone()
conn.close()
if row and row[0]:
return row[0]
return None
def record_getvotes(room, sender, timestamp, event_id=None):
"""Record a !getvotes detection; returns True if newly recorded (not duplicate)."""
import dbutil
import datetime
conn = dbutil.connect(room.db_path)
c = conn.cursor()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
c.execute('''
INSERT OR IGNORE INTO getvotes (event_id, sender, timestamp, datetime)
VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1
dbutil.commit(conn)
conn.close()
return inserted
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=room.config.command_sync_timeout)
if not isinstance(sync_response, SyncResponse):
return None
room_info = sync_response.rooms.join.get(room.config.room_id)
if not room_info or not hasattr(room_info, "timeline") or not hasattr(room_info.timeline, "events"):
return None
for event in reversed(room_info.timeline.events):
if isinstance(event, RoomMessageText):
# Ignore old events (pre-listen timestamp)
if getattr(event, "server_timestamp", 0) < since_ms:
continue
# Ignore our own messages
if event.sender == room.user_id:
continue
body = event.body.strip()
# Handle !getvotes
if body == "!getvotes":
event_id = getattr(event, "event_id", None)
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
if not record_getvotes(room, event.sender, ts_seconds, event_id):
continue # Duplicate, skip
return "getvotes"
return None
def format_vote_results(room):
"""Read votes from database and format results ordered from least to most votes."""
import re
# Generate database name based on room ID (same pattern as suggestions db)
safe = re.sub(r'[^a-zA-Z0-9_-]', '_', room.config.room_id)
vote_db = f"votes_{safe}.db"
# Check if database exists
if not os.path.exists(vote_db):
return "❌ No vote data available yet. The voting system may not have received any votes."
try:
import dbutil
# Read votes from database
conn = dbutil.connect(vote_db)
c = conn.cursor()
# Get all votes grouped by submission
c.execute('''
SELECT selected_submission, COUNT(*) as vote_count,
GROUP_CONCAT(voter_display_name, ', ') as voters
FROM votes
GROUP BY selected_submission
ORDER BY vote_count ASC
''')
results = c.fetchall()
# Get total votes
c.execute('SELECT COUNT(*) FROM votes')
total_votes = c.fetchone()[0]
conn.close()
if not results or total_votes == 0:
return "📊 No votes have been cast yet."
# Build the message
lines = ["📊 Vote Results (ordered from least to most votes):", ""]
for submission, count, voters in results:
vote_word = "vote" if count == 1 else "votes"
lines.append(f"• {submission}: {count} {vote_word}")
lines.append("")
lines.append(f"Total votes cast: {total_votes}")
# Find winner(s)
max_votes = results[-1][1] if results else 0
winners = [sub for sub, count, _ in results if count == max_votes]
if max_votes > 0:
if len(winners) > 1:
lines.append(f"🏆 Tie between {len(winners)} submissions with {max_votes} votes each!")
else:
lines.append(f"🏆 Current leader: {winners[0]} ({max_votes} votes)")
return "\n".join(lines)
except Exception as e:
return f"❌ Error reading vote data: {str(e)}"
if __name__ == "__main__":
asyncio.run(main())
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
module jep
go 1.24.9
require (
github.com/mattn/go-sqlite3 v1.14.24
gopkg.in/yaml.v2 v2.4.0
)
-6
View File
@@ -1,6 +0,0 @@
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
BIN
View File
Binary file not shown.
Binary file not shown.
-373
View File
@@ -1,373 +0,0 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
"gopkg.in/yaml.v2"
)
type Submission struct {
Submitter string `json:"submitter"` //Person who submitted this suggestion
SubmissionTime int64 `json:"submission_time"` //Unix timestamp of submission
Submission string `json:"submission"` //The actual suggestion text
}
type SubmissionBuffer struct {
Submissions []Submission
Mtx sync.Mutex
}
type UserMap map[string]User // [username]User struct
type UserData struct {
Users map[string]User `json:"users"`
MostRecentPost int64 `json:"most_recent_post"`
}
type User struct {
UserName string `json:"user_name"`
DisplayName string `json:"display_name"`
LastActive int64 `json:"last_active"`
NumberOfPosts int `json:"number_of_posts"`
LastSeen int64 `json:"last_seen"`
}
type Config struct {
RoomID string `yaml:"room_id"`
VotePath string `yaml:"vote_path"`
}
type Room struct {
Config *Config
Slug string
SubBuffer *SubmissionBuffer
UserBuffer *UserMap
MostRecentPost *int64
}
func loadConfig(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var config Config
err = yaml.Unmarshal(data, &config)
if err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
return &config, nil
}
// Load one or more room configs. If a configs/ directory exists, load all
// yaml files from it; otherwise fall back to the single ../config.yaml.
func loadConfigs() ([]*Config, error) {
var configs []*Config
files, err := filepath.Glob(filepath.Join("..", "configs", "*.yaml"))
if err == nil && len(files) > 0 {
for _, f := range files {
if strings.Contains(filepath.Base(f), "example") {
continue
}
c, err := loadConfig(f)
if err != nil {
log.Printf("Error loading config %s: %v", f, err)
continue
}
configs = append(configs, c)
}
}
if len(configs) == 0 {
c, err := loadConfig(filepath.Join("..", "config.yaml"))
if err != nil {
return nil, err
}
configs = append(configs, c)
}
return configs, nil
}
// Load the slug map written by the bot (room_id -> slug). Falls back to a
// deterministic slug derived from the room_id when a room isn't present.
func loadRoomSlugs() map[string]string {
data, err := os.ReadFile(filepath.Join("..", "room_slugs.json"))
if err != nil {
return nil
}
var slugs map[string]string
if err := json.Unmarshal(data, &slugs); err != nil {
return nil
}
return slugs
}
func slugify(s string) string {
var b strings.Builder
lastDash := false
for _, r := range strings.ToLower(s) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
} else if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func roomSlug(config *Config, slugs map[string]string) string {
if config.VotePath != "" {
return config.VotePath
}
if slugs != nil {
if slug, ok := slugs[config.RoomID]; ok && slug != "" {
return slug
}
}
return slugify(config.RoomID)
}
func safeDBName(roomID string, prefix string) string {
re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
safe := re.ReplaceAllString(roomID, "_")
return fmt.Sprintf("%s_%s.db", prefix, safe)
}
func main() {
configs, err := loadConfigs()
if err != nil {
log.Fatalf("Failed to load configs: %v", err)
}
slugs := loadRoomSlugs()
var rooms []*Room
usedSlugs := make(map[string]bool)
for _, config := range configs {
slug := roomSlug(config, slugs)
if usedSlugs[slug] {
log.Printf("Skipping room %s: slug /%s already in use by another config", config.RoomID, slug)
continue
}
usedSlugs[slug] = true
room := &Room{
Config: config,
Slug: slug,
SubBuffer: &SubmissionBuffer{},
UserBuffer: &UserMap{},
MostRecentPost: new(int64),
}
rooms = append(rooms, room)
dbPath := safeDBName(config.RoomID, "suggestions")
dbFullPath := filepath.Join("..", dbPath)
go collectSubmissions(room, dbFullPath)
go collectUserList(room)
http.HandleFunc("/"+room.Slug+"/subs", serveSubmissionList(room.SubBuffer))
http.HandleFunc("/"+room.Slug+"/users", serveUserList(room))
log.Printf("Serving room '%s' under /%s (db %s)", config.RoomID, room.Slug, dbFullPath)
}
// Backward compatibility: a single room is also served at the root paths.
if len(rooms) == 1 {
http.HandleFunc("/subs", serveSubmissionList(rooms[0].SubBuffer))
http.HandleFunc("/users", serveUserList(rooms[0]))
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "jsonEndpoint running. Rooms: %s\n", strings.Join(roomSlugList(rooms), ", "))
})
log.Println("jsonEndpoint starting on :9080")
log.Fatal(http.ListenAndServe(":9080", nil))
}
func roomSlugList(rooms []*Room) []string {
var out []string
for _, r := range rooms {
out = append(out, r.Slug)
}
return out
}
// Serve a JSON endpoint with a json encoded user list for a room
func serveUserList(room *Room) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
response := UserData{
Users: *room.UserBuffer,
MostRecentPost: *room.MostRecentPost,
}
encErr := json.NewEncoder(w).Encode(response)
if encErr != nil {
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
return
}
}
}
func collectUserList(room *Room) {
usersDBPath := filepath.Join("..", safeDBName(room.Config.RoomID, "users"))
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Initial load
loadUsers(usersDBPath, room)
for range ticker.C {
loadUsers(usersDBPath, room)
}
}
func loadUsers(dbPath string, room *Room) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
log.Printf("Error opening database: %v", err)
return
}
defer db.Close()
// Get the most recent post timestamp from fetch_log
var recentPost int64
err = db.QueryRow(`
SELECT COALESCE(most_recent_post, 0)
FROM fetch_log
ORDER BY timestamp DESC
LIMIT 1
`).Scan(&recentPost)
if err != nil && err != sql.ErrNoRows {
log.Printf("Error querying most recent post: %v", err)
}
*room.MostRecentPost = recentPost
rows, err := db.Query(`
SELECT user_id, display_name, last_seen, num_posts, COALESCE(last_active, 0) as last_active
FROM users
ORDER BY num_posts DESC
`)
if err != nil {
log.Printf("Error querying users: %v", err)
return
}
defer rows.Close()
newUsers := make(UserMap)
for rows.Next() {
var userID, displayName string
var lastSeen, numPosts int
var lastActive int64
err := rows.Scan(&userID, &displayName, &lastSeen, &numPosts, &lastActive)
if err != nil {
log.Printf("Error scanning row: %v", err)
continue
}
newUsers[userID] = User{
UserName: userID,
DisplayName: displayName,
LastActive: lastActive,
NumberOfPosts: numPosts,
LastSeen: int64(lastSeen),
}
}
*room.UserBuffer = newUsers
log.Printf("Loaded %d users from database (most recent post: %d)", len(newUsers), recentPost)
}
// Serve a JSON endpoint with a json encoded submission buffer
func serveSubmissionList(buffer *SubmissionBuffer) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
buffer.Mtx.Lock()
defer buffer.Mtx.Unlock()
w.Header().Set("Content-Type", "application/json")
encErr := json.NewEncoder(w).Encode(buffer)
if encErr != nil {
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
return
}
}
}
func collectSubmissions(room *Room, dbFullPath string) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Initial load
loadSubmissions(dbFullPath, room.SubBuffer)
for range ticker.C {
loadSubmissions(dbFullPath, room.SubBuffer)
}
}
func loadSubmissions(dbPath string, buffer *SubmissionBuffer) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
log.Printf("Error opening database: %v", err)
return
}
defer db.Close()
rows, err := db.Query(`
SELECT user, timestamp, message
FROM suggestions
WHERE is_posted = 0
ORDER BY timestamp DESC
`)
if err != nil {
log.Printf("Error querying submissions: %v", err)
return
}
defer rows.Close()
var submissions []Submission
for rows.Next() {
var submitter, submissionText string
var submissionTime int64
err := rows.Scan(&submitter, &submissionTime, &submissionText)
if err != nil {
log.Printf("Error scanning row: %v", err)
continue
}
submissions = append(submissions, Submission{
Submitter: submitter,
SubmissionTime: submissionTime,
Submission: submissionText,
})
}
buffer.Mtx.Lock()
buffer.Submissions = submissions
buffer.Mtx.Unlock()
log.Printf("Loaded %d submissions from database", len(submissions))
}
-9
View File
@@ -1,9 +0,0 @@
# JSON ENDPOINT
This process provides json endpoints for user counts and vote suggestions.
If running on localhost, the two endpoints will be at localhost:9080/users and localhost:8080/subs. Base url should be altered for production.
/subs is where the submissions are stored
/users is for usernames and post counts
-14
View File
@@ -1,14 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
go
gcc
sqlite
];
shellHook = ''
export CGO_ENABLED=1
echo "Go development environment ready with CGO support"
'';
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+64
View File
@@ -48,6 +48,70 @@ On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
+64
View File
@@ -0,0 +1,64 @@
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
View File
+47
View File
@@ -0,0 +1,47 @@
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
Error validating response: 'displayname' is a required property
+64
View File
@@ -0,0 +1,64 @@
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
Error validating event: 'm.relates_to' is a required property
Failed validating 'required' in schema['properties']['content']:
{'type': 'object',
'properties': {'m.relates_to': {'type': 'object',
'properties': {'rel_type': {'type': 'string',
'const': 'm.annotation'},
'event_id': {'type': 'string'},
'key': {'type': 'string'}},
'required': ['rel_type',
'event_id',
'key']}},
'required': ['m.relates_to']}
On instance['content']:
{}
View File
View File
View File
+16 -18
View File
@@ -1,18 +1,16 @@
2026/09/04 10:18:03 Serving room '!ajyOBbqoCIbBhEqNPU:matrix.org' under /random (db ../suggestions__ajyOBbqoCIbBhEqNPU_matrix_org.db)
2026/09/04 10:18:03 jsonEndpoint starting on :9080
2026/09/04 10:18:03 Loaded 4 users from database (most recent post: 1788534620)
2026/09/04 10:18:03 Loaded 19 submissions from database
2026/09/04 10:18:33 Loaded 19 submissions from database
2026/09/04 10:18:33 Loaded 4 users from database (most recent post: 1788535088)
2026/09/04 10:19:03 Loaded 0 submissions from database
2026/09/04 10:19:03 Loaded 4 users from database (most recent post: 1788535088)
2026/09/04 10:19:33 Loaded 0 submissions from database
2026/09/04 10:19:33 Loaded 4 users from database (most recent post: 1788535088)
2026/09/04 10:20:03 Loaded 0 submissions from database
2026/09/04 10:20:03 Loaded 4 users from database (most recent post: 1788535088)
2026/09/04 10:20:33 Loaded 0 submissions from database
2026/09/04 10:20:33 Loaded 4 users from database (most recent post: 1788535088)
2026/09/04 10:21:03 Loaded 0 submissions from database
2026/09/04 10:21:03 Loaded 4 users from database (most recent post: 1788535088)
2026/09/04 10:21:33 Loaded 0 submissions from database
2026/09/04 10:21:33 Loaded 4 users from database (most recent post: 1788535088)
2026/10/31 01:09:01 Serving room '!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com' under /random (db ../suggestions__HXiyYVEZOMRtkyvarN_jupiterbroadcasting_com.db)
2026/10/31 01:09:01 Skipping room !gJYEKNllaubNlNkFIj:jupiterbroadcasting.com: slug /random already in use by another config
2026/10/31 01:09:01 Skipping room !ajyOBbqoCIbBhEqNPU:matrix.org: slug /random already in use by another config
2026/10/31 01:09:01 jsonEndpoint starting on :9080
2026/10/31 01:09:01 Loaded 6 submissions from database
2026/10/31 01:09:01 Loaded 216 users from database (most recent post: 1793426081)
2026/10/31 01:09:31 Loaded 6 submissions from database
2026/10/31 01:09:31 Loaded 216 users from database (most recent post: 1793426081)
2026/10/31 01:10:01 Loaded 6 submissions from database
2026/10/31 01:10:01 Loaded 216 users from database (most recent post: 1793426081)
2026/10/31 01:10:31 Loaded 6 submissions from database
2026/10/31 01:10:31 Loaded 216 users from database (most recent post: 1793426081)
2026/10/31 01:11:01 Loaded 6 submissions from database
2026/10/31 01:11:01 Loaded 216 users from database (most recent post: 1793426081)
2026/10/31 01:11:31 Loaded 6 submissions from database
2026/10/31 01:11:31 Loaded 216 users from database (most recent post: 1793426081)
View File
View File
View File
+79 -37
View File
@@ -1,39 +1,81 @@
Logged in!
Last run for this room was at UNIX timestamp 1788534952
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Stored 76 suggestions in database.
Last run for this room was at UNIX timestamp 1788535085
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Acked suggestion from magbot with ✅
Stored 79 suggestions in database.
Last run for this room was at UNIX timestamp 1788535116
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
Last run for this room was at UNIX timestamp 1788535147
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
Last run for this room was at UNIX timestamp 1788535178
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
Last run for this room was at UNIX timestamp 1788535208
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
Last run for this room was at UNIX timestamp 1788535240
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
Last run for this room was at UNIX timestamp 1788535270
Last run for this room was at UNIX timestamp 1793423882
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423894
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423904
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423915
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423925
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423936
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423947
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423957
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423968
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423978
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793423989
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424000
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424010
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424021
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424031
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424042
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424053
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424063
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424074
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424084
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424095
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424106
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424116
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424127
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424138
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424148
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424159
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424169
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424180
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424191
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424201
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424212
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424222
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424233
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424244
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424254
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424265
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424275
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424286
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424297
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
+39
View File
@@ -0,0 +1,39 @@
Logged in!
Last run for this room was at UNIX timestamp 1793424612
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
Last run for this room was at UNIX timestamp 1793424626
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
Last run for this room was at UNIX timestamp 1793424637
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
Last run for this room was at UNIX timestamp 1793424647
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
Last run for this room was at UNIX timestamp 1793424658
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
Last run for this room was at UNIX timestamp 1793424668
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magnolia_mayhem 💀⃤ (Matrix Version) with ✅
Stored 38 suggestions in database.
Last run for this room was at UNIX timestamp 1793424679
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magnolia_mayhem 💀⃤ (Matrix Version) with ✅
Acked suggestion from magnolia_mayhem 💀⃤ (Matrix Version) with ✅
Stored 40 suggestions in database.
Last run for this room was at UNIX timestamp 1793424690
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magnolia_mayhem 💀⃤ (Matrix Version) with ✅
Stored 41 suggestions in database.
Last run for this room was at UNIX timestamp 1793424700
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
Last run for this room was at UNIX timestamp 1793424711
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
Last run for this room was at UNIX timestamp 1793424722
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
Last run for this room was at UNIX timestamp 1793424732
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
Last run for this room was at UNIX timestamp 1793424743
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
Last run for this room was at UNIX timestamp 1793424753
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
Last run for this room was at UNIX timestamp 1793424764
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
Last run for this room was at UNIX timestamp 1793424775
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
Last run for this room was at UNIX timestamp 1793424785
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
+33
View File
@@ -0,0 +1,33 @@
Logged in!
Last run for this room was at UNIX timestamp 1793426929
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793426941
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793426952
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793426962
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793426973
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793426984
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793426994
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793427005
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793427015
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793427026
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793427037
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793427047
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793427058
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793427068
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793427079
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
Last run for this room was at UNIX timestamp 1793427090
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
+33
View File
@@ -0,0 +1,33 @@
Logged in!
Last run for this room was at UNIX timestamp 1793426930
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793426941
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793426952
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793426962
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793426973
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793426984
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793426994
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793427005
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793427016
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793427026
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793427037
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793427048
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793427058
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793427069
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793427079
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
Last run for this room was at UNIX timestamp 1793427090
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
+33
View File
@@ -0,0 +1,33 @@
Logged in!
Last run for this room was at UNIX timestamp 1793426930
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793426942
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793426952
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793426963
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793426973
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793426984
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793426995
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793427005
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793427016
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793427026
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793427037
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793427048
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793427058
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793427069
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793427080
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
Last run for this room was at UNIX timestamp 1793427090
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
+16 -18
View File
@@ -1,18 +1,16 @@
2026/09/04 10:18:06 Vote server serving room '!ajyOBbqoCIbBhEqNPU:matrix.org' under /random (db ../votes__ajyOBbqoCIbBhEqNPU_matrix_org.db)
2026/09/04 10:18:06 Vote server starting on :9081
2026/09/04 10:18:06 Fetched 4 users (most recent post: 1788534620)
2026/09/04 10:18:06 Fetched 19 submissions
2026/09/04 10:18:37 Fetched 4 users (most recent post: 1788535088)
2026/09/04 10:18:37 Fetched 19 submissions
2026/09/04 10:19:07 Fetched 0 submissions
2026/09/04 10:19:07 Fetched 4 users (most recent post: 1788535088)
2026/09/04 10:19:37 Fetched 0 submissions
2026/09/04 10:19:37 Fetched 4 users (most recent post: 1788535088)
2026/09/04 10:20:07 Fetched 0 submissions
2026/09/04 10:20:07 Fetched 4 users (most recent post: 1788535088)
2026/09/04 10:20:37 Fetched 0 submissions
2026/09/04 10:20:37 Fetched 4 users (most recent post: 1788535088)
2026/09/04 10:21:07 Fetched 0 submissions
2026/09/04 10:21:07 Fetched 4 users (most recent post: 1788535088)
2026/09/04 10:21:37 Fetched 4 users (most recent post: 1788535088)
2026/09/04 10:21:37 Fetched 0 submissions
2026/10/31 01:09:04 Vote server serving room '!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com' under /random (db ../votes__HXiyYVEZOMRtkyvarN_jupiterbroadcasting_com.db)
2026/10/31 01:09:04 Skipping room !gJYEKNllaubNlNkFIj:jupiterbroadcasting.com: slug /random already in use by another config
2026/10/31 01:09:04 Skipping room !ajyOBbqoCIbBhEqNPU:matrix.org: slug /random already in use by another config
2026/10/31 01:09:04 Vote server starting on :9081
2026/10/31 01:09:04 Fetched 6 submissions
2026/10/31 01:09:04 Fetched 216 users (most recent post: 1793426081)
2026/10/31 01:09:34 Fetched 6 submissions
2026/10/31 01:09:34 Fetched 216 users (most recent post: 1793426081)
2026/10/31 01:10:04 Fetched 6 submissions
2026/10/31 01:10:04 Fetched 216 users (most recent post: 1793426081)
2026/10/31 01:10:34 Fetched 6 submissions
2026/10/31 01:10:34 Fetched 216 users (most recent post: 1793426081)
2026/10/31 01:11:04 Fetched 6 submissions
2026/10/31 01:11:04 Fetched 216 users (most recent post: 1793426081)
2026/10/31 01:11:34 Fetched 6 submissions
2026/10/31 01:11:34 Fetched 216 users (most recent post: 1793426081)
+4 -33
View File
@@ -1,31 +1,10 @@
import yaml
import re
import asyncio
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 = 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'")
if not c.fetchone():
conn.close()
return None
c.execute('SELECT MAX(timestamp) FROM getvotes')
row = c.fetchone()
conn.close()
if row and row[0]:
return row[0]
return None
async def main():
config_path = sys.argv[1] if len(sys.argv) > 1 else "configs/config.yaml"
room = Room(config_path)
@@ -37,29 +16,21 @@ async def main():
# Initial sync establishes the next-batch; after this, listen from "now"
assert room.client is not None
await room.client.sync(timeout=room.config.initial_sync_timeout)
room.write_room_slugs()
try:
while True:
listen_since_ms = int(time.time() * 1000)
# Get the timestamp of the last !getvotes command
last_getvotes = get_last_getvotes_timestamp(room)
if last_getvotes:
print(f"Last !getvotes was at UNIX timestamp {last_getvotes}")
else:
print("No previous !getvotes found - will show all unposted suggestions")
# Wait for !gettitles before posting
await room.wait_for_gettitles(since_ms=listen_since_ms)
# Then read and post suggestions created since last !getvotes
room.read_db(since_timestamp=last_getvotes, only_unposted=True)
# Results commands leave the pending queue untouched.
room.read_db(only_unposted=True)
if room.posts:
await room.post_suggestions_individually()
room.update_last_post()
else:
await room.alert_post("No new unposted suggestions found.")
await room.alert_post("No new title suggestions posted.")
await asyncio.sleep(room.config.post_loop_interval)
finally:
await room.close()
+2
View File
@@ -1 +1,3 @@
matrix-nio
pyyaml
requests
+1
View File
@@ -1,2 +1,3 @@
matrix-nio
pyyaml
requests
+122 -218
View File
@@ -11,6 +11,7 @@ import sqlite3
import re
import asyncio
from nio import AsyncClient, LoginResponse, MessageDirection, ProfileGetDisplayNameResponse, RoomMessageText, RoomMessagesResponse, RoomSendResponse, SyncResponse
from nio.api import RelationshipType
from post import Post
import requests
import dbutil
@@ -30,20 +31,13 @@ class Config:
self.lookback_limit = config.get("lookback_limit", 3600 * 2)
self.db_name = config.get("dbName", "suggestions.db")
self.batch_size = config.get("batch_size", 100)
self.user_lookback_limit = config.get("user_lookback_limit", 5000)
self.homeserver = config.get("homeserver", "https://matrix.org")
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:
def __init__(self, config_path="configs/config.yaml", search_terms=None, homeserver=None):
@@ -68,7 +62,6 @@ class Room:
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):
@@ -140,21 +133,36 @@ class Room:
)
''')
# New: table to log all !voteurl detections (prevents duplicates)
_ = c.execute('''
CREATE TABLE IF NOT EXISTS voteurl (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT,
c.execute('''
CREATE TABLE IF NOT EXISTS clear_commands (
event_id TEXT UNIQUE,
sender TEXT,
timestamp INTEGER,
datetime TEXT,
UNIQUE(event_id),
UNIQUE(sender, timestamp)
)
''')
dbutil.commit(conn)
conn.close()
def clear_suggestions(self, sender, timestamp, event_id=None):
"""Retire pending suggestions once per command; return their count."""
conn = dbutil.connect(self.db_path)
try:
cursor = conn.execute(
"INSERT OR IGNORE INTO clear_commands (event_id, sender, timestamp) VALUES (?, ?, ?)",
(event_id, sender, timestamp),
)
if cursor.rowcount == 0:
return None
cursor = conn.execute(
"UPDATE suggestions SET is_posted = 1 WHERE is_posted = 0"
)
count = cursor.rowcount
dbutil.commit(conn)
return count
finally:
conn.close()
def init_banned_db(self):
"""Initialize the shared banned-users database (primary key + user string)."""
conn = dbutil.connect(self.banned_db_path)
@@ -195,47 +203,6 @@ class Room:
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")
@@ -276,46 +243,6 @@ class Room:
self._display_name_cache[user_id] = name
return name
@staticmethod
def slugify(text):
text = re.sub(r'[^a-z0-9]+', '-', str(text).lower()).strip('-')
return text
def get_room_slug(self):
"""Resolve a URL-safe slug for this room (config override, then room name, then alias)."""
if self.config.vote_path:
return self.config.vote_path
slug = None
if self.client:
room = self.client.rooms.get(self.config.room_id)
if room:
slug = getattr(room, "name", None) or getattr(room, "canonical_alias", None)
if not slug:
slug = self.config.room_id
self.config.vote_path = self.slugify(slug)
return self.config.vote_path
def write_room_slugs(self):
"""Persist the resolved slug so the Go voting services can route by path."""
import json
import os
slug = self.get_room_slug()
path = "room_slugs.json"
data = {}
if os.path.exists(path):
try:
with open(path, "r") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
data = {}
data[self.config.room_id] = slug
with open(path, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
def vote_url(self):
"""Build the full voting URL for this room."""
return f"http://{self.config.vote_host}:9081/{self.get_room_slug()}"
async def get_convos(self, limit=None):
if limit is None:
limit = self.config.lookback_limit
@@ -519,120 +446,94 @@ class Room:
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 = dbutil.connect(self.db_path)
c = conn.cursor()
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
_ = c.execute('''
INSERT OR IGNORE INTO voteurl (event_id, sender, timestamp, datetime)
VALUES (?, ?, ?, ?)
''', (event_id, sender, int(timestamp), dt_str))
inserted = c.rowcount == 1
dbutil.commit(conn)
conn.close()
return inserted
async def count_votes(self):
"""Count upvotes for each suggestion and return formatted results."""
# Get recent messages to find suggestion posts and their reactions
batch_size = self.config.batch_size
"""Read current thumbs-up votes for the most recent voting round."""
assert self.client is not None
next_batch = None
suggestion_votes = {}
all_events = []
assert self.client is not None
seen_tokens = set()
found_round = False
# First pass: collect all recent events (both messages and reactions)
for _ in range(15): # Look back further to catch more events
# History is newest first. The latest voting announcement bounds the
# round, regardless of how long people have continued reacting to it.
while True:
response = await self.client.room_messages(
self.config.room_id,
start=next_batch,
limit=batch_size,
direction=MessageDirection.back
limit=self.config.batch_size,
direction=MessageDirection.back,
)
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
if not isinstance(response, RoomMessagesResponse):
return "Unable to read voting history. Please try !countvotes again."
if not response.chunk:
break
for event in response.chunk:
if (isinstance(event, RoomMessageText)
and event.sender == self.user_id
and event.body.strip().startswith("Time to vote!")):
found_round = True
break
if isinstance(event, RoomMessageText) and event.sender == self.user_id:
match = re.match(r"^Suggestion (\d+):\s*(.*)", event.body, re.DOTALL)
event_id = getattr(event, "event_id", None)
if match and event_id:
suggestion_votes[event_id] = {
"number": int(match[1]),
"content": match[2].strip(),
"voters": set(),
}
if found_round:
break
all_events.extend(response.chunk)
next_batch = getattr(response, "end", None)
if not next_batch:
if not next_batch or next_batch in seen_tokens:
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
event.sender == self.user_id):
try:
# Extract suggestion number and content
parts = body.split(":", 2)
if len(parts) >= 2:
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,
'event_id': event_id,
'votes': 0,
'voters': set()
}
except Exception as e:
print(f"Error parsing suggestion: {e}")
continue
seen_tokens.add(next_batch)
if not suggestion_votes:
return "No suggestion posts found to count votes for."
return "No suggestion posts found for the latest voting round."
# 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
relates_to.get("key") == "👍"):
target_event_id = relates_to.get("event_id")
# Fetch the live relations for each suggestion rather than assuming
# reactions are included in the room-history pages used above.
try:
for event_id, data in suggestion_votes.items():
async for event in self.client.room_get_event_relations(
self.config.room_id,
event_id,
rel_type=RelationshipType.annotation,
event_type="m.reaction",
limit=self.config.batch_size,
):
source = getattr(event, "source", {})
if source.get("unsigned", {}).get("redacted_because"):
continue
relation = source.get("content", {}).get("m.relates_to", {})
key = relation.get("key", "")
# Treat the usual presentation/skin-tone variants as the
# same thumbs-up vote that a user sees in their client.
key = key.replace("\ufe0f", "")
key = re.sub("[\U0001f3fb-\U0001f3ff]", "", key)
if (relation.get("rel_type") != "m.annotation"
or relation.get("event_id") != event_id or key != "👍"):
continue
sender = getattr(event, "sender", None)
if sender and sender != self.user_id:
data["voters"].add(sender)
except Exception as error:
print(f"Unable to fetch emoji voting reactions: {error}")
return "Unable to read emoji votes. Please try !countvotes again."
# 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
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'],
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}")
# Truncate long suggestions for readability
content = data['content']
if len(content) > 80:
content = content[:77] + "..."
lines.append(f" └─ {content}")
lines = ["Latest voting round — current emoji votes (most to fewest):"]
ranked = sorted(suggestion_votes.values(),
key=lambda data: (-len(data["voters"]), data["number"]))
for data in ranked:
count = len(data["voters"])
vote_text = "vote" if count == 1 else "votes"
lines.append(f"Suggestion {data['number']}: {count} {vote_text}")
lines.append(f" └─ {data['content']}")
return "\n".join(lines)
# Renamed and expanded to handle both !gettitles and !mayhem commands
async def wait_for_commands(self, since_ms=None):
"""Wait for and handle commands like !gettitles and !mayhem"""
"""Handle room commands until !gettitles requests a voting round."""
# If no since_ms provided, start listening from "now"
if since_ms is None:
since_ms = int(_time.time() * 1000)
@@ -646,7 +547,7 @@ class Room:
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
for event in reversed(room_info.timeline.events):
for event in room_info.timeline.events:
if isinstance(event, RoomMessageText):
# Ignore old events (pre-listen timestamp)
if getattr(event, "server_timestamp", 0) < since_ms:
@@ -656,8 +557,19 @@ class Room:
continue
body = event.body.strip()
if body == "!clear":
count = self.clear_suggestions(
event.sender,
int(getattr(event, "server_timestamp", 0) // 1000),
getattr(event, "event_id", None),
)
if count is not None:
await self.alert_post(
f"Cleared {count} pending title suggestions. They will not be posted for voting."
)
# Handle !gettitles
if body == "!gettitles":
elif body == "!gettitles":
event_id = getattr(event, "event_id", None)
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
if not self.record_gettitles(event.sender, ts_seconds, event_id):
@@ -673,30 +585,20 @@ class Room:
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":
# Both result commands read current emoji votes.
elif body in ("!countvotes", "!getvotes"):
event_id = getattr(event, "event_id", None)
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
if not self.record_countvotes(event.sender, ts_seconds, event_id):
continue
print("!countvotes detected")
print(f"{body} detected")
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)
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
if not self.record_voteurl(event.sender, ts_seconds, event_id):
continue
print("!voteurl detected")
vote_message = f"🗳️ Cast your vote here: {self.vote_url()}\n\nYou must have been active in the room within {self.config.vote_activity_hours} hours of the most recent activity to vote. Enter your Matrix display name exactly as it appears in the room."
await self.alert_post(vote_message)
# Keep the old method name for backward compatibility but mark as deprecated
async def wait_for_gettitles(self, since_ms=None, exact=True):
"""DEPRECATED: Use wait_for_commands() instead. This method only handles !gettitles."""
"""Handle room commands until !gettitles requests suggestion posting."""
result = await self.wait_for_commands(since_ms)
return result == "gettitles"
@@ -798,7 +700,7 @@ class Room:
# 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.")
await self.alert_post("No new title suggestions posted.")
return
# Temporarily set posts to only unposted ones for formatting
@@ -834,17 +736,23 @@ class Room:
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.")
await self.alert_post("No new title suggestions posted.")
return
await self.alert_post(f"""
await self.alert_post("""
Time to vote! 🚢🚢🚢
Each title is a separate post below. Tap the 👍 reaction on the one you like to cast your vote!
May the odds be ever in your favor! 🏹🏹🏹
NOTE: HTTP VOTING IS IN PRE-ALPHA. IF YOU WOULD LIKE TO SEE THE EARLY PRE-RELEASE, GO TO {self.vote_url()}
COMMANDS:
!suggest <title> — Submit a title suggestion.
!gettitles — Post pending suggestions for voting.
!countvotes — Show the latest round's current 👍 vote rankings.
!getvotes — Show the same current emoji results as !countvotes.
!clear — Clear pending suggestions from future voting rounds.
!mayhem — Generate chaotic text with Ollama AI.
MAKE MAGBOT BETTER:
GIT REPO: https://magbot.online ⬅️⬅️⬅️ PRs welcome!
@@ -881,6 +789,9 @@ class Room:
print(f"Failed to post suggestion {idx}: {resp}")
continue
self.mark_posts_as_posted([post])
post.is_posted = True
reaction = {
"m.relates_to": {
"rel_type": "m.annotation",
@@ -913,13 +824,6 @@ class Room:
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()
-4
View File
@@ -1,4 +0,0 @@
{
"!ajyOBbqoCIbBhEqNPU:matrix.org": "random",
"!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com": "jb-chat"
}
+54 -235
View File
@@ -2,7 +2,6 @@
# TitleBot-NG - Master Launch Script
# Starts all components of the TitleBot system
# THIS SCRIPT WAS CREATED VIA BLACKBOX AI -- HUMANIZATION REQUIRED
set -e
@@ -25,88 +24,6 @@ detect_os() {
OS=$(detect_os)
echo -e "${BLUE}Detected OS: $OS${NC}"
# Ensure Go is installed, attempting native package manager install if missing
ensure_go() {
if command -v go >/dev/null 2>&1; then
echo -e "${GREEN}Go found: $(go version)${NC}"
return 0
fi
echo -e "${YELLOW}Go not found. Attempting to install via native package manager...${NC}"
local pm=""
if command -v apt-get >/dev/null 2>&1; then
pm="apt"
elif command -v pacman >/dev/null 2>&1; then
pm="pacman"
elif command -v dnf >/dev/null 2>&1; then
pm="dnf"
elif command -v pkg >/dev/null 2>&1 && [ "$OS" = "freebsd" ]; then
pm="pkg"
fi
if [ -z "$pm" ]; then
echo -e "${RED}Error: No supported package manager found (apt/pacman/dnf/pkg).${NC}"
echo -e "${YELLOW}Please install Go manually: https://go.dev/dl/${NC}"
exit 1
fi
local sudo_cmd=""
if [ "$(id -u)" -ne 0 ]; then
if command -v sudo >/dev/null 2>&1; then
sudo_cmd="sudo"
else
echo -e "${RED}Error: Need root privileges to install Go but sudo is unavailable.${NC}"
exit 1
fi
fi
case "$pm" in
apt)
$sudo_cmd apt-get update
$sudo_cmd apt-get install -y golang-go
;;
pacman)
$sudo_cmd pacman -Syu --noconfirm go
;;
dnf)
$sudo_cmd dnf install -y golang
;;
pkg)
$sudo_cmd pkg install -y go
;;
esac
if command -v go >/dev/null 2>&1; then
echo -e "${GREEN}Go installed successfully: $(go version)${NC}"
else
echo -e "${RED}Error: Go installation failed. Please install Go manually: https://go.dev/dl/${NC}"
exit 1
fi
}
ensure_go
# Set OS-specific binary names
get_bin_name() {
local base=$1
case "$OS" in
freebsd)
case "$base" in
jep) echo "jepfbd" ;;
vs) echo "vsfbsd" ;;
*) echo "${base}_${OS}" ;;
esac
;;
*)
echo "$base"
;;
esac
}
JEP_BIN=$(get_bin_name "jep")
VS_BIN=$(get_bin_name "vs")
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
@@ -118,126 +35,63 @@ mkdir -p "$PID_DIR"
LOG_DIR="$SCRIPT_DIR/logs"
mkdir -p "$LOG_DIR"
# Cleanup function
# Track only services launched for this run, so stale restart commands cannot
# introduce services that are no longer configured.
service_labels=()
stop_services() {
for pidfile in "$PID_DIR"/*.pid; do
[ -f "$pidfile" ] || continue
local pid name
pid=$(cat "$pidfile")
name=$(basename "$pidfile" .pid)
if kill -0 "$pid" 2>/dev/null; then
echo -e "${YELLOW}Stopping $name (PID: $pid)${NC}"
kill "$pid" 2>/dev/null || true
fi
rm -f "$pidfile" "$PID_DIR/$name.cmd"
done
}
cleanup() {
local status=$?
trap - EXIT SIGINT SIGTERM
echo -e "\n${YELLOW}Shutting down all services...${NC}"
# Kill all background processes
if [ -d "$PID_DIR" ]; then
for pidfile in "$PID_DIR"/*.pid; do
if [ -f "$pidfile" ]; then
pid=$(cat "$pidfile")
name=$(basename "$pidfile" .pid)
if kill -0 "$pid" 2>/dev/null; then
echo -e "${YELLOW}Stopping $name (PID: $pid)${NC}"
kill "$pid" 2>/dev/null || true
fi
rm -f "$pidfile"
rm -f "$PID_DIR/$name.cmd"
fi
done
stop_services
if [ -n "${RESTART_STATE_DIR:-}" ]; then
rm -rf "$RESTART_STATE_DIR"
fi
# Fallback: kill any lingering service processes even if pid files are stale/missing
for bin in "$JEP_BIN" "$VS_BIN"; do
pkill -x "$bin" 2>/dev/null || true
done
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
exit "$status"
}
# Set up trap for cleanup
trap cleanup SIGINT SIGTERM EXIT
# Function to start a Go service
start_go_service() {
local name=$1
local dir=$2
local binary=$3
local rebuild=0
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
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
if kill -0 $(cat "$PID_DIR/$name.pid") 2>/dev/null; then
echo -e "${GREEN}✓ $name started (PID: $(cat "$PID_DIR/$name.pid"))${NC}"
return 0
else
echo -e "${RED}✗ $name failed to start${NC}"
return 1
fi
}
trap cleanup EXIT
trap 'exit 130' SIGINT
trap 'exit 143' SIGTERM
# Function to start a Python service
start_python_service() {
local name=$1
local script=$2
local config_path=${3:-}
local config_arg=""
local config_args=()
local label="$name"
if [ -n "$config_path" ]; then
config_arg="$config_path"
label="${name}-$(basename "$config_path" .yaml)"
config_args+=("$config_path")
label="${name}-$(basename "$config_path")"
fi
service_labels+=("$label")
echo -e "${GREEN}Starting $label...${NC}"
# Activate virtual environment and run script
(source .venv/bin/activate && python3 "$script" $config_arg > "$LOG_DIR/$label.log" 2>&1) &
(source .venv/bin/activate && exec python3 "$script" "${config_args[@]}" > "$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"
printf '(source .venv/bin/activate && exec python3 %q ' "$script" > "$PID_DIR/$label.cmd"
if [ ${#config_args[@]} -gt 0 ]; then
printf '%q ' "${config_args[@]}" >> "$PID_DIR/$label.cmd"
fi
printf '> %q 2>&1) &\n' "$LOG_DIR/$label.log" >> "$PID_DIR/$label.cmd"
# Brief wait to check if it started successfully
sleep 1
@@ -265,7 +119,7 @@ ensure_venv() {
}
echo -e "${GREEN}Virtual environment created. Installing dependencies...${NC}"
if source .venv/bin/activate && pip install matrix-nio pyyaml requests; then
if source .venv/bin/activate && pip install -r requirements.txt; then
echo -e "${GREEN}Dependencies installed successfully.${NC}"
else
echo -e "${RED}Error: Failed to install Python dependencies.${NC}"
@@ -275,23 +129,18 @@ ensure_venv() {
ensure_venv
# Check if config.yaml exists
if [ ! -f "configs/config.yaml" ]; then
echo -e "${RED}Error: configs/config.yaml not found${NC}"
echo -e "${YELLOW}Please copy configs/config_example.yaml to configs/config.yaml and configure it${NC}"
exit 1
fi
# Discover active room configs (skip example/template files)
room_configs=()
for cfg in configs/*.yaml; do
if [[ "$(basename "$cfg")" == *example* ]]; then
for cfg in configs/*.yaml configs/*.yml; do
[ -f "$cfg" ] || continue
if [[ "$(basename "$cfg")" == "config_example.yaml" ]]; then
continue
fi
room_configs+=("$cfg")
done
if [ ${#room_configs[@]} -eq 0 ]; then
echo -e "${RED}Error: no room configs found in configs/*.yaml${NC}"
echo -e "${RED}Error: no room configs found in configs/*.yaml or configs/*.yml${NC}"
echo -e "${YELLOW}Copy configs/config_example.yaml to configs/your-room.yaml and configure it${NC}"
exit 1
fi
echo -e "${BLUE}Found ${#room_configs[@]} room config(s): ${room_configs[*]}${NC}"
@@ -301,56 +150,25 @@ echo -e "${BLUE} TitleBot-NG - Starting All Services ${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Start services in order
# Stop the processes recorded by the previous run before starting room bots.
echo -e "${YELLOW}Cleaning up any existing processes...${NC}"
for bin in "$JEP_BIN" "$VS_BIN"; do
if pgrep -x "$bin" > /dev/null 2>&1; then
echo -e "${YELLOW}Stopping existing $bin process...${NC}"
pkill -x "$bin" 2>/dev/null || true
fi
done
for script in scraper post_results get_votes get_user_list; do
if pgrep -f "python3 $script.py" > /dev/null 2>&1; then
echo -e "${YELLOW}Stopping existing $script process...${NC}"
pkill -f "python3 $script.py" 2>/dev/null || true
fi
done
sleep 1
stop_services
echo -e "${YELLOW}Step 1: Starting scrapers (write room_slugs.json for voting paths)...${NC}"
echo -e "${YELLOW}Starting Matrix room bots...${NC}"
for cfg in "${room_configs[@]}"; do
start_python_service "scraper" "scraper.py" "$cfg"
done
sleep 2
echo -e "${YELLOW}Step 2: Starting backend services...${NC}"
start_go_service "jsonEndpoint" "jsonEndpoint" "$JEP_BIN"; true
sleep 2 # Give jsonEndpoint time to fully start
start_go_service "voteServer" "voteServer" "$VS_BIN"; true
sleep 2 # Give voteServer time to fully start
echo ""
echo -e "${YELLOW}Step 3: Starting remaining Matrix bots...${NC}"
for cfg in "${room_configs[@]}"; do
start_python_service "post_results" "post_results.py" "$cfg"
sleep 1
start_python_service "get_votes" "get_votes.py" "$cfg"
sleep 1
start_python_service "get_user_list" "get_user_list.py" "$cfg"
start_python_service "scraper" "scraper.py" "$cfg" || true
start_python_service "post_results" "post_results.py" "$cfg" || true
done
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} All services started successfully! ${NC}"
echo -e "${GREEN} Service startup complete ${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "${BLUE}Service Status:${NC}"
echo -e " • JSON Endpoint: http://localhost:9080"
echo -e " • Vote Server: http://localhost:9081"
for cfg in "${room_configs[@]}"; do
label=$(basename "$cfg" .yaml)
echo -e " • Room ($label): scraper / post_results / get_votes / get_user_list"
label=$(basename "$cfg")
echo -e " • Room ($label): scraper / post_results"
done
echo ""
echo -e "${BLUE}Logs are available in: $LOG_DIR${NC}"
@@ -457,14 +275,15 @@ restart_service() {
while true; do
# Check if all processes are still running
all_running=true
for pidfile in "$PID_DIR"/*.pid; do
for name in "${service_labels[@]}"; do
pidfile="$PID_DIR/$name.pid"
if [ -f "$pidfile" ]; then
pid=$(cat "$pidfile")
if ! kill -0 "$pid" 2>/dev/null; then
name=$(basename "$pidfile" .pid)
echo -e "${RED}Warning: $name (PID: $pid) has stopped unexpectedly${NC}"
all_running=false
restart_service "$name"
restart_service "$name" || true
fi
fi
done
-4
View File
@@ -23,7 +23,6 @@ async def main():
# Initial sync
assert room.client is not None
await room.client.sync(timeout=room.config.sync_timeout)
room.write_room_slugs()
try:
while True:
@@ -35,9 +34,6 @@ 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
Binary file not shown.
+108
View File
@@ -0,0 +1,108 @@
import asyncio
from contextlib import closing
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import dbutil
import post_results
from room import Room
class ClearSuggestionsTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.room = Room.__new__(Room)
self.room.db_path = str(Path(self.temp.name) / "suggestions.db")
self.room.init_db()
with closing(dbutil.connect(self.room.db_path)) as conn, conn:
conn.executemany(
"INSERT INTO suggestions (message, is_posted) VALUES (?, ?)",
[("pending one", 0), ("pending two", 0), ("already posted", 1)],
)
def test_clear_preserves_rows_and_excludes_pending_from_voting(self):
self.assertEqual(self.room.clear_suggestions("alice", 100, "$clear"), 2)
self.room.read_db(only_unposted=True)
self.assertEqual(self.room.posts, [])
with closing(dbutil.connect(self.room.db_path)) as conn, conn:
self.assertEqual(conn.execute("SELECT COUNT(*) FROM suggestions").fetchone()[0], 3)
conn.execute("INSERT INTO suggestions (message) VALUES ('new title')")
self.assertIsNone(self.room.clear_suggestions("alice", 100, "$clear"))
with closing(dbutil.connect(self.room.db_path)) as conn, conn:
self.assertEqual(conn.execute("SELECT COUNT(*) FROM suggestions WHERE is_posted = 0").fetchone()[0], 1)
self.assertEqual(self.room.clear_suggestions("alice", 101, "$next"), 1)
self.assertEqual(self.room.clear_suggestions("alice", 102, "$empty"), 0)
async def test_command_confirms_clear_and_keeps_waiting_for_gettitles(self):
class Event(SimpleNamespace):
pass
class Response(SimpleNamespace):
pass
def response(body, event_id, timestamp):
event = Event(body=body, sender="alice", event_id=event_id, server_timestamp=timestamp)
return Response(rooms=SimpleNamespace(join={
"test": SimpleNamespace(timeline=SimpleNamespace(events=[event]))
}))
self.room.config = SimpleNamespace(room_id="test", command_sync_timeout=1)
self.room.user_id = "bot"
self.room.client = SimpleNamespace(sync=AsyncMock(side_effect=[
response("!clear", "$clear", 100000),
response("!clear", "$clear", 100000),
response("!gettitles", "$titles", 101000),
]))
self.room.alert_post = AsyncMock()
with patch("room.RoomMessageText", Event), patch("room.SyncResponse", Response):
self.assertEqual(await self.room.wait_for_commands(since_ms=0), "gettitles")
self.room.alert_post.assert_awaited_once_with(
"Cleared 2 pending title suggestions. They will not be posted for voting."
)
async def test_clear_then_gettitles_in_same_batch_reports_no_titles(self):
class Event(SimpleNamespace):
pass
class Response(SimpleNamespace):
pass
events = [
Event(body="!clear", sender="alice", event_id="$clear", server_timestamp=100000),
Event(body="!gettitles", sender="alice", event_id="$titles", server_timestamp=101000),
]
response = Response(rooms=SimpleNamespace(join={
"test": SimpleNamespace(timeline=SimpleNamespace(events=events))
}))
self.room.config = SimpleNamespace(
room_id="test", command_sync_timeout=1, initial_sync_timeout=1, post_loop_interval=1
)
self.room.user_id = "bot"
self.room.client = SimpleNamespace(sync=AsyncMock(side_effect=[None, response]))
self.room.login_to_matrix = AsyncMock(return_value=True)
self.room.close = AsyncMock()
self.room.alert_post = AsyncMock()
self.room.post_suggestions_individually = AsyncMock()
with (
patch("room.RoomMessageText", Event),
patch("room.SyncResponse", Response),
patch("post_results.Room", return_value=self.room),
patch("post_results.time.time", return_value=99),
patch("post_results.asyncio.sleep", side_effect=asyncio.CancelledError),
):
with self.assertRaises(asyncio.CancelledError):
await post_results.main()
self.assertEqual(
self.room.alert_post.await_args_list[-1].args,
("No new title suggestions posted.",),
)
self.room.post_suggestions_individually.assert_not_awaited()
self.room.close.assert_awaited_once()
if __name__ == "__main__":
unittest.main()
+183
View File
@@ -0,0 +1,183 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from nio import AsyncClient, RoomMessagesResponse
from nio.responses import RoomEventRelationsResponse
from room import Room
class Message(SimpleNamespace):
pass
class Response(SimpleNamespace):
pass
def message(body, event_id, sender="bot"):
return Message(body=body, event_id=event_id, sender=sender, source={})
def reaction(target, sender, event_id, key="👍"):
return SimpleNamespace(sender=sender, event_id=event_id, source={
"type": "m.reaction",
"content": {"m.relates_to": {
"rel_type": "m.annotation", "key": key, "event_id": target,
}},
})
class CountVotesTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.room = Room.__new__(Room)
self.room.config = SimpleNamespace(room_id="test", batch_size=100)
self.room.user_id = "bot"
self.room.client = SimpleNamespace(room_messages=AsyncMock())
self.enterContext(patch("room.RoomMessageText", Message))
self.enterContext(patch("room.RoomMessagesResponse", Response))
async def count(self, *pages, live_reactions=None):
self.room.client.room_messages.side_effect = pages
events = [event for page in pages for event in getattr(page, "chunk", [])]
removed = set()
for event in events:
source = getattr(event, "source", {})
if source.get("type") == "m.room.redaction":
removed.add(source.get("redacts") or source.get("content", {}).get("redacts"))
async def relations(room_id, event_id, **kwargs):
for event in events if live_reactions is None else live_reactions:
relation = event.source.get("content", {}).get("m.relates_to", {})
if relation.get("event_id") == event_id and event.event_id not in removed:
yield event
self.room.client.room_get_event_relations = relations
return await self.room.count_votes()
async def test_votes_absent_from_timeline_are_read_from_live_relations(self):
result = await self.count(Response(chunk=[
message("Suggestion 1: Author: Title", "$one"),
message("Time to vote!", "$start"),
], end=None), live_reactions=[
reaction("$one", "alice", "$vote", key="👍🏽"),
reaction("$one", "bob", "$vote2", key="👍\ufe0f"),
])
self.assertIn("Suggestion 1: 2 votes", result)
async def test_relation_failure_is_not_reported_as_zero_votes(self):
async def broken_relations(*args, **kwargs):
raise RuntimeError("Server unavailable")
yield
self.room.client.room_messages.return_value = Response(chunk=[
message("Suggestion 1: Author: Title", "$one"),
message("Time to vote!", "$start"),
], end=None)
self.room.client.room_get_event_relations = broken_relations
result = await self.room.count_votes()
self.assertIn("Unable to read emoji votes", result)
self.assertNotIn("0 votes", result)
async def test_latest_round_ranking_and_later_votes_refresh(self):
latest = [
reaction("$two", "alice", "$v2"),
reaction("$one", "alice", "$v1"),
reaction("$one", "alice", "$duplicate"),
reaction("$one", "bot", "$starter"),
reaction("$one", "charlie", "$wrong", key="👎"),
reaction("$old", "dana", "$oldvote"),
message("Suggestion 3: Author: Zero votes", "$three"),
message("Suggestion 2: Author: Second title", "$two"),
message("Suggestion 1: Author: First title", "$one"),
message("Time to vote! 🚢", "$announcement"),
message("Suggestion 1: Author: Old title", "$old"),
]
result = await self.count(Response(chunk=latest, end="older"))
self.assertIn("Suggestion 1: 1 vote", result)
self.assertIn("Suggestion 2: 1 vote", result)
self.assertIn("Suggestion 3: 0 votes", result)
self.assertNotIn("Old title", result)
self.assertLess(result.index("Suggestion 1:"), result.index("Suggestion 2:"))
updated = await self.count(Response(chunk=[
reaction("$two", "bob", "$later"), *latest,
], end="older"))
self.assertIn("Suggestion 2: 2 votes", updated)
self.assertLess(updated.index("Suggestion 2:"), updated.index("Suggestion 1:"))
async def test_removed_reactions_and_zero_vote_results(self):
redactions = [SimpleNamespace(source={
"type": "m.room.redaction", **target,
}) for target in ({"redacts": "$removed"}, {"content": {"redacts": "$removedv11"}})]
result = await self.count(Response(chunk=[
*redactions,
reaction("$one", "alice", "$removed"),
reaction("$one", "bob", "$removedv11"),
message("Suggestion 1: Author: Title", "$one"),
message("Time to vote!", "$start"),
], end=None))
self.assertIn("Suggestion 1: 0 votes", result)
async def test_round_older_than_fifteen_pages(self):
pages = [Response(chunk=[reaction("$one", "alice", "$vote")], end="1")]
pages.extend(Response(chunk=[message("Chat", f"$chat{i}", sender="alice")], end=str(i))
for i in range(2, 18))
pages.append(Response(chunk=[
message("Suggestion 1: Author: Title", "$one"),
message("Time to vote!", "$start"),
], end="old"))
result = await self.count(*pages)
self.assertIn("Suggestion 1: 1 vote", result)
self.assertEqual(self.room.client.room_messages.await_count, 18)
async def test_history_failure_does_not_report_partial_counts(self):
result = await self.count(Response(chunk=[
message("Suggestion 1: Author: Title", "$one"),
], end="older"), object())
self.assertIn("Unable to read voting history", result)
async def test_no_round(self):
self.assertIn("No suggestion posts found", await self.count(Response(chunk=[], end=None)))
class MatrixSDKVotesTests(unittest.IsolatedAsyncioTestCase):
async def test_real_sdk_parses_and_paginates_live_reactions(self):
room = Room.__new__(Room)
room.config = SimpleNamespace(room_id="!room:matrix.test", batch_size=2)
room.user_id = "@bot:matrix.test"
room.client = AsyncClient("https://matrix.test", room.user_id)
room.client.access_token = "test-token"
self.addAsyncCleanup(room.client.close)
def event(event_id, sender, event_type, content):
return {"event_id": event_id, "sender": sender, "type": event_type,
"origin_server_ts": 100000, "content": content,
"room_id": room.config.room_id}
history = RoomMessagesResponse.from_dict({
"start": "now", "chunk": [
event("$one", room.user_id, "m.room.message", {
"msgtype": "m.text", "body": "Suggestion 1: Author: Title"}),
event("$start", room.user_id, "m.room.message", {
"msgtype": "m.text", "body": "Time to vote!"}),
],
}, room.config.room_id)
def votes(sender, event_id, next_batch=None):
return RoomEventRelationsResponse.from_dict({
"chunk": [event(event_id, sender, "m.reaction", {"m.relates_to": {
"rel_type": "m.annotation", "key": "👍", "event_id": "$one",
}})], **({"next_batch": next_batch} if next_batch else {}),
}, room.config.room_id, "$one")
room.client._send = AsyncMock(side_effect=[
history, votes("@alice:matrix.test", "$v1", "page2"),
votes("@bob:matrix.test", "$v2"),
])
result = await room.count_votes()
self.assertIn("Suggestion 1: 2 votes", result)
self.assertIn("from=page2", room.client._send.await_args_list[-1].args[2])
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
from contextlib import closing
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
import dbutil
from room import Room
class Event(SimpleNamespace):
pass
class Response(SimpleNamespace):
pass
class GetVotesTests(unittest.IsolatedAsyncioTestCase):
async def test_both_commands_report_current_emoji_votes_without_consuming_titles(self):
with tempfile.TemporaryDirectory() as directory:
room = Room.__new__(Room)
room.db_path = str(Path(directory) / "suggestions.db")
room.init_db()
with closing(dbutil.connect(room.db_path)) as conn, conn:
conn.execute("INSERT INTO suggestions (user, display_name, message, timestamp, age) "
"VALUES ('alice', 'Alice', 'Pending title', 100, 0)")
room.config = SimpleNamespace(room_id="test", command_sync_timeout=1)
room.user_id = "bot"
room.count_votes = AsyncMock(side_effect=[
"Suggestion 1: 1 vote", "Suggestion 1: 2 votes",
])
room.alert_post = AsyncMock()
events = [
Event(body=body, sender="alice", event_id=event_id, server_timestamp=timestamp)
for body, event_id, timestamp in [
("!countvotes", "$count", 100000),
("!countvotes", "$count", 100000), # replay ignored
("!getvotes", "$get", 101000),
("!gettitles", "$titles", 102000),
]
]
room.client = SimpleNamespace(sync=AsyncMock(return_value=Response(
rooms=SimpleNamespace(join={"test": SimpleNamespace(
timeline=SimpleNamespace(events=events)
)})
)))
with patch("room.RoomMessageText", Event), patch("room.SyncResponse", Response):
self.assertEqual(await room.wait_for_commands(since_ms=0), "gettitles")
self.assertEqual(room.count_votes.await_count, 2)
messages = [call.args[0] for call in room.alert_post.await_args_list]
self.assertEqual(messages, [
"Counting votes... This may take a moment...", "Suggestion 1: 1 vote",
"Counting votes... This may take a moment...", "Suggestion 1: 2 votes",
])
room.read_db(only_unposted=True)
self.assertEqual([post.content for post in room.posts], ["Pending title"])
self.assertEqual(sorted(path.name for path in Path(directory).iterdir()),
["suggestions.db"])
+114
View File
@@ -0,0 +1,114 @@
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import unittest
class LauncherTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
shutil.copy(Path(__file__).with_name("run_all.sh"), self.root)
(self.root / "configs").mkdir()
(self.root / ".venv/bin").mkdir(parents=True)
(self.root / ".venv/bin/activate").write_text("")
self.bin = self.root / "bin"
self.bin.mkdir()
self.executable(self.bin / "go", "#!/bin/sh\ntouch unexpected-go-invocation\nexit 1\n")
self.executable(self.bin / "pgrep", "#!/bin/sh\nexit 1\n")
self.executable(self.bin / "pkill", "#!/bin/sh\nexit 0\n")
self.executable(self.bin / "sleep", "#!/bin/sh\n/bin/sleep 0.02\n")
self.executable(self.bin / "python3", f'#!/bin/sh\nexec "{sys.executable}" "$@"\n')
for name in ("scraper", "post_results"):
(self.root / f"{name}.py").write_text(
"import json, pathlib, sys, time\n"
"name = pathlib.Path(sys.argv[0]).stem\n"
"config = pathlib.Path(sys.argv[1]).name\n"
"pathlib.Path(f'started-{name}-{config}.json').write_text(json.dumps(sys.argv[1:]))\n"
"if config == 'broken room.yaml': sys.exit(1)\n"
"time.sleep(300)\n"
)
def executable(self, path, content):
path.write_text(content)
path.chmod(0o755)
def launch(self):
output = open(self.root / "launcher.log", "w+")
self.addCleanup(output.close)
env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ["PATH"])
process = subprocess.Popen(
["bash", str(self.root / "run_all.sh")], env=env,
stdout=output, stderr=subprocess.STDOUT, start_new_session=True,
)
def stop():
# All processes belong to this isolated test session.
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=5)
self.addCleanup(stop)
return process
def test_each_config_launches_independently_without_config_yaml(self):
configs = ("main room.yaml", "example-room.yml", "broken room.yaml")
for name in (*configs, "config_example.yaml", "disabled.yaml.hold"):
(self.root / "configs" / name).write_text("room_id: test\n")
pid_dir = self.root / ".pids"
pid_dir.mkdir()
(pid_dir / "retired.pid").write_text("999999999")
(pid_dir / "retired.cmd").write_text("touch unexpected-service-started")
process = self.launch()
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if "Service startup complete" in (self.root / "launcher.log").read_text():
break
if process.poll() is not None:
self.fail((self.root / "launcher.log").read_text())
time.sleep(0.05)
else:
self.fail((self.root / "launcher.log").read_text())
self.assertEqual(len(list(self.root.glob("started-*.json"))), 6)
for config in configs:
for service in ("scraper", "post_results"):
record = self.root / f"started-{service}-{config}.json"
self.assertEqual(json.loads(record.read_text()), [f"configs/{config}"])
self.assertTrue((self.root / "logs" / f"{service}-{config}.log").is_file())
# A failed restart must also leave the supervisor and other rooms up.
deadline = time.monotonic() + 5
while "failed to restart" not in (self.root / "launcher.log").read_text():
if time.monotonic() >= deadline:
self.fail("Failed room was not retried")
time.sleep(0.05)
self.assertIsNone(process.poll())
self.assertFalse((self.root / "unexpected-go-invocation").exists())
self.assertFalse((self.root / "unexpected-service-started").exists())
self.assertFalse((pid_dir / "retired.cmd").exists())
for config in configs[:2]:
pid = int((self.root / ".pids" / f"scraper-{config}.pid").read_text())
os.kill(pid, 0)
def test_no_active_configs_reports_error(self):
(self.root / "configs/config_example.yaml").write_text("room_id: template\n")
process = self.launch()
process.wait(timeout=5)
self.assertIn("no room configs found", (self.root / "launcher.log").read_text())
self.assertEqual(list(self.root.glob("started-*.json")), [])
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
from contextlib import closing
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock
from nio import RoomSendResponse
import dbutil
from post import Post
from room import Room
class PostSuggestionsTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.room = Room.__new__(Room)
self.room.db_path = str(Path(self.temp.name) / "suggestions.db")
self.room.init_db()
self.room.config = SimpleNamespace(room_id="test", post_interval=0)
self.room.client = SimpleNamespace(room_send=AsyncMock())
self.room.alert_post = AsyncMock()
self.room.is_banned = lambda user: False
def suggestions(self, *titles):
self.room.posts = [Post("alice", "Alice", title, i, age=0)
for i, title in enumerate(titles, start=1)]
self.room.write_to_db()
def sent(self, event_id):
return RoomSendResponse.from_dict({"event_id": event_id}, "test")
async def test_posting_consumes_queue_and_repeat_gettitles_does_not_restart_vote(self):
self.suggestions("Title")
self.room.client.room_send.side_effect = [self.sent("$title"), self.sent("$reaction")]
await self.room.post_suggestions_individually()
banner = self.room.alert_post.await_args.args[0]
self.assertIn("!countvotes", banner)
self.assertIn("!getvotes", banner)
self.assertNotIn("!voteurl", banner)
self.assertNotIn("HTTP VOTING", banner)
self.room.read_db(only_unposted=True)
self.assertEqual(self.room.posts, [])
await self.room.post_suggestions_individually()
self.room.alert_post.assert_awaited_with("No new title suggestions posted.")
self.assertEqual(self.room.client.room_send.await_count, 2)
async def test_failed_title_send_stays_pending(self):
self.suggestions("Posted", "Retry me")
self.room.client.room_send.side_effect = [self.sent("$title"), self.sent("$reaction"), object()]
await self.room.post_suggestions_individually()
self.room.read_db(only_unposted=True)
self.assertEqual([post.content for post in self.room.posts], ["Retry me"])
with closing(dbutil.connect(self.room.db_path)) as conn:
self.assertEqual(conn.execute("SELECT COUNT(*) FROM suggestions").fetchone()[0], 2)
async def test_reaction_failure_does_not_repost_successful_title(self):
self.suggestions("Title")
self.room.client.room_send.side_effect = [self.sent("$title"), RuntimeError("Reaction failed")]
with self.assertRaises(RuntimeError):
await self.room.post_suggestions_individually()
self.room.read_db(only_unposted=True)
self.assertEqual(self.room.posts, [])
if __name__ == "__main__":
unittest.main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
-36
View File
@@ -1,36 +0,0 @@
module vs
go 1.24.9
require (
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
)
-173
View File
@@ -1,173 +0,0 @@
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=
-211
View File
@@ -1,211 +0,0 @@
.suggestor {
font-weight: italic;
color: #222;
}
.suggestion {
font-weight: bold;
color: #844;
text-decoration: underline;
}
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
input[type="text"] {
width: 100%;
padding: 10px;
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 {
list-style-type: none;
padding: 0;
}
#suggestion_list li {
padding: 10px;
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 10px 20px;
font-size: 16px;
background-color: #4CAF50;
color: white;
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;
}
#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;
}
-294
View File
@@ -1,294 +0,0 @@
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 = '';
if (data.Submissions && data.Submissions.length > 0) {
data.Submissions.forEach((sub) => {
const li = document.createElement('li');
li.innerHTML = `
<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);
});
} else {
submissionList.innerHTML = '<li>No submissions available</li>';
}
} catch (error) {
console.error('Error loading submissions:', error);
document.getElementById('suggestion_list').innerHTML = '<li>Error loading submissions</li>';
}
}
// --- Voting ---
async function submitVote() {
if (!currentUser) {
alert('Please sign in first');
return;
}
const selectedRadio = document.querySelector('input[name="vote"]:checked');
if (!selectedRadio) {
alert('Please select a title to vote for');
return;
}
const voteData = {
selected_submission: selectedRadio.value,
submitter: selectedRadio.dataset.submitter,
submission_time: parseInt(selectedRadio.dataset.time)
};
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) {
console.error('Error submitting vote:', error);
alert('Error submitting vote: ' + error.message);
}
}
// --- 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';
}
});
-143
View File
@@ -1,143 +0,0 @@
<!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
@@ -1,149 +0,0 @@
<!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>
-51
View File
@@ -1,51 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pick a Title!</title>
<link rel="stylesheet" href="css.css">
</head>
<body>
<h1>Choose Your Favorite Title</h1>
<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>
-1238
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.