Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca44cf73df | ||
|
|
5dde44e98e | ||
|
|
f6f6721f3a | ||
|
|
c71fec44d6 | ||
|
|
fffb9c50b4 | ||
|
|
268938abb1 | ||
|
|
9cc7093c48 | ||
|
|
7e3b3dff6d | ||
|
|
78f5297383 | ||
|
|
a0dbf3be92 |
@@ -0,0 +1,3 @@
|
||||
config/*
|
||||
.venv/
|
||||
__pycache__/
|
||||
@@ -1,101 +1,76 @@
|
||||
# titlebot-ng
|
||||
A Matrix chat bot for Jupiter Broadcasting's <s>"The Lunch"</s> shows "The Lunch" and "Linux Unplugged" chat room that collects and posts title suggestions.
|
||||
A Matrix chat bot for Jupiter Broadcasting's "The Lunch" and "Linux Unplugged" chat rooms that collects and posts title suggestions with web-based voting.
|
||||
|
||||
### From the Owner
|
||||
This has gotten out of hand. I've been woking 7 days a week for a couple years now and this has been my most recent mortar between the bricks of time that I've been squeezing out of my life between sleep. Simplification needs to happen whenever possible.
|
||||
This has gotten out of hand. I've been working 7 days a week for a couple years now and this has been my most recent mortar between the bricks of time that I've been squeezing out of my life between sleep. Simplification needs to happen whenever possible.
|
||||
|
||||
# Technical Outline
|
||||
|
||||
At the moment, there are 6 processes that make up the whole project and must be run simultaneously:
|
||||
|
||||
- scraper.py
|
||||
- post_results.py
|
||||
- get_user_list.py
|
||||
- get_votes.py
|
||||
- jsonEndpoint/jep
|
||||
- voteServer/vs
|
||||
|
||||
### scraper.py
|
||||
What it says on the tin. Reads configured target matrix room and collects suggestions
|
||||
|
||||
### post_results.py
|
||||
Listens for the !gettitles command and readies the votes
|
||||
|
||||
### get_user_list.py
|
||||
Gets a list of users in the room who have more than n votes and puts them in a database so that the voting can have some degree of validation beyond random voters being allowed in.
|
||||
|
||||
### get_votes.py
|
||||
Listens for the !getvotes command in the chat room, reads the output of the http voting, and writes the results back to the room
|
||||
|
||||
### jsonEndpoint/jep
|
||||
Provides a json endpoint for a list of all provided votes and who voted for them
|
||||
|
||||
### voteServer/vs
|
||||
Provides a webpage where voters can vote
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
The project is a mix of Python (Matrix bot services) and Go (voting backend). Each room gets its own instance of every Python service, so the number of running processes scales with the number of configured rooms.
|
||||
|
||||
Services:
|
||||
- **scraper.py** - Reads configured target Matrix room and collects suggestions
|
||||
- **post_results.py** - Listens for the `!gettitles` command and posts suggestions for voting
|
||||
- **get_user_list.py** - Collects the list of users in the room (with post counts and last activity) for vote validation
|
||||
- **get_votes.py** - Listens for the `!getvotes` command and posts vote results to the room
|
||||
- **jsonEndpoint/** - Go service (port 9080) exposing JSON endpoints for submissions and users
|
||||
- **voteServer/** - Go service (port 9081) serving the web voting interface
|
||||
|
||||
Everything is launched by `run_all.sh`, which auto-installs Go, creates the Python venv, discovers room configs, builds the Go services, and starts one instance of each Python service per room.
|
||||
|
||||
## Features
|
||||
- **Suggestion Collection**: Monitors chat for messages starting with `!suggest`, `!Suggest`, `!suggestion`, `!Suggestion`, `SUGGEST`, or `!sug`
|
||||
- **Interactive Voting**: Posts suggestions individually with 👍 reactions for voting
|
||||
- **Command System**:
|
||||
- **Web Voting Interface**: Dedicated voting server with validation (exact display-name match + activity window)
|
||||
- **Multi-Room Support**: One config file per room under `configs/`, with URL slugs for routing
|
||||
- **Command System**:
|
||||
- `!gettitles` - Triggers posting of collected suggestions
|
||||
- `!mayhem` - Generates chaotic text using Ollama AI
|
||||
- `!countvotes` - Counts upvotes on posted suggestions
|
||||
- `!getvotes` - Posts current vote results from the voting server
|
||||
- `!countvotes` - Counts 👍 upvotes on posted suggestions
|
||||
- `!getvotes` - Posts current results from the voting server
|
||||
- `!voteurl` - Shares the voting URL where users can cast their votes
|
||||
- **Persistent Storage**: SQLite database per room with duplicate prevention
|
||||
- **Persistent Storage**: Per-room SQLite databases with duplicate prevention
|
||||
- **Acknowledgment System**: Reacts with ✅ to confirm suggestion collection
|
||||
- **Web Voting Interface**: Dedicated voting server with vote validation
|
||||
- **Command Deduplication**: All commands are logged to prevent duplicate executions
|
||||
|
||||
## Quick Start
|
||||
1. Create and activate a Python virtual environment:
|
||||
1. Clone the repo and copy the example config for each room you want to monitor:
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
cp configs/config_example.yaml configs/config.yaml
|
||||
# Edit configs/config.yaml with your Matrix credentials and room ID
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
2. Run everything with a single command (installs Go and creates the venv if missing):
|
||||
```bash
|
||||
./run_all.sh
|
||||
```
|
||||
|
||||
Or run the components individually:
|
||||
```bash
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install matrix-nio pyyaml requests
|
||||
```
|
||||
|
||||
3. Configure the bot:
|
||||
```bash
|
||||
cp config_example.yaml config.yaml
|
||||
# Edit config.yaml with your Matrix credentials and room ID
|
||||
```
|
||||
|
||||
4. Run the bot components:
|
||||
```bash
|
||||
# Terminal 1: Collect suggestions
|
||||
python3 scraper.py
|
||||
|
||||
# Terminal 2: Post suggestions when requested
|
||||
python3 post_results.py
|
||||
cd jsonEndpoint && go build -o jep . && ./jep
|
||||
cd voteServer && go build -o vs . && ./vs
|
||||
```
|
||||
|
||||
## Configuration
|
||||
Create `config.yaml` based on `config_example.yaml`:
|
||||
One YAML file per room in `configs/`. Example (`configs/config_example.yaml`):
|
||||
|
||||
```yaml
|
||||
username: "your_bot_username"
|
||||
username: "magbot"
|
||||
password: "your_bot_password"
|
||||
room_id: "!your_room_id:homeserver.com"
|
||||
homeserver: "https://matrix.org" # Optional, defaults to matrix.org
|
||||
lookback_limit: 7200 # Seconds to look back for suggestions (2 hours)
|
||||
batch_size: 100 # Messages to fetch per batch
|
||||
dbName: "suggestions.db" # Optional, auto-generated if not set
|
||||
vote_host: "localhost" # Hostname for voting interface (e.g., "localhost", "example.com", "vote.mysite.xyz")
|
||||
room_id: "!your_room_id:homeserver.com" # Matrix room to monitor
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400 # Max messages to look back when scraping
|
||||
dump_mode: False # Dump raw messages when scraping (debugging)
|
||||
dbName: "my_db_name" # Optional, auto-derived from room_id if empty
|
||||
vote_host: "localhost" # Hostname for voting interface (e.g., "example.com", "vote.mysite.xyz")
|
||||
# vote_path: "my-room-slug" # Optional URL slug; auto-derived from room name if unset
|
||||
vote_activity_hours: 72 # Hours a user must have been active within to be eligible to vote
|
||||
```
|
||||
|
||||
The bot resolves each room's voting page to a URL slug (`vote_path` config override, otherwise derived from the room name/alias) and persists the mapping to `room_slugs.json`, which the Go services read for routing.
|
||||
|
||||
## Architecture
|
||||
- **`room.py`**: Core `Room` class handling Matrix connection, database operations, and command processing
|
||||
- **`post.py`**: `Post` class representing individual suggestions
|
||||
@@ -109,16 +84,18 @@ vote_host: "localhost" # Hostname for voting interface (e.g., "localhost", "exa
|
||||
- **`run_all.sh`**: Master script to start all services
|
||||
|
||||
## Database Schema
|
||||
- **suggestions**: Stores collected suggestions with metadata
|
||||
- **gettitles/mayhem/countvotes/getvotes/voteurl**: Command execution logs to prevent duplicates
|
||||
- **last_run/last_post**: Timestamps for state management
|
||||
- **users**: User information for vote validation
|
||||
Per room (suffixed with a sanitized room ID):
|
||||
- **suggestions.db**: Collected suggestions with metadata (`is_posted`, `event_id`)
|
||||
- **users.db**: User info (`display_name`, `num_posts`, `last_active`) for vote validation, plus a `fetch_log` of collection runs
|
||||
- **votes.db**: Web votes cast via the voting interface
|
||||
|
||||
Shared:
|
||||
- **last_run.db / last_post.db**: Timestamps for state management
|
||||
- Command log tables (`gettitles`, `mayhem`, `countvotes`, `getvotes`, `voteurl`) live inside each room's suggestions database and prevent duplicate executions
|
||||
|
||||
## Dependencies
|
||||
- **matrix-nio**: Matrix client library
|
||||
- **pyyaml**: Configuration file parsing
|
||||
- **requests**: HTTP requests for Ollama integration
|
||||
- **sqlite3**: Built-in database support
|
||||
- **Python**: matrix-nio, pyyaml, requests, sqlite3 (built-in)
|
||||
- **Go**: github.com/mattn/go-sqlite3, gopkg.in/yaml.v2
|
||||
|
||||
## Optional: Ollama Integration
|
||||
For `!mayhem` command functionality:
|
||||
@@ -129,10 +106,10 @@ For `!mayhem` command functionality:
|
||||
## Notes
|
||||
- Each room gets its own SQLite database for suggestions
|
||||
- The bot acknowledges collected suggestions with ✅ reactions
|
||||
- Voting uses 👍 reactions on individual suggestion posts
|
||||
- Voting uses 👍 reactions on individual suggestion posts and a web voting interface
|
||||
- Command deduplication prevents spam and duplicate executions
|
||||
- Safe database naming handles special characters in room IDs
|
||||
|
||||
- Web votes are tied to a user's exact Matrix display name; voters must have been active in the room within `vote_activity_hours` of the most recent room activity
|
||||
|
||||
# TODO:
|
||||
- Rewrite in Rust
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
-2
@@ -1,13 +1,29 @@
|
||||
username: "magbot"
|
||||
password: "zC1b9qCKT8Cv0D"
|
||||
#password: "zC1b9qCKT8Cv0D"
|
||||
password: cathandle
|
||||
homeserver: "https://matrix.dinguscloud.com"
|
||||
room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
|
||||
#room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
# Da Lunch and The Main Chat each have their own config (daLunch.yaml, mainChat.yaml).
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
dbName: ""
|
||||
# vote_host: "agmninex.online" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
|
||||
vote_host: "agmninex.online"
|
||||
vote_host: "workstation"
|
||||
vote_path: "random" # URL slug for this room's voting page (http://<vote_host>:9081/<vote_path>)
|
||||
vote_activity_hours: 6672 # Hours a user must have been active within to be eligible to vote
|
||||
|
||||
# --- Wait times (seconds) ---
|
||||
sync_timeout: 3000 # Initial sync timeout for scraper/user-list (ms)
|
||||
poll_interval: 10 # How often the scraper polls for new messages (s)
|
||||
command_sync_timeout: 30000 # Sync timeout when listening for chat commands (ms)
|
||||
post_interval: 0.2 # Delay between posting each suggestion (s)
|
||||
initial_sync_timeout: 300 # Initial sync timeout for post_results (ms)
|
||||
post_loop_interval: 1 # Delay between post_results loop iterations (s)
|
||||
user_list_interval: 300 # How often the user-list service refreshes (s)
|
||||
user_list_retry_interval: 60 # Retry delay on user-list error (s)
|
||||
|
||||
# --- Feature toggles ---
|
||||
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
username: "magbot"
|
||||
password: "Dont tek psswrd"
|
||||
# room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # The Lunch
|
||||
homeserver: "https://matrix.dinguscloud.com" # Matrix homeserver the bot connects to
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # The Lunch
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400 # 400 messages
|
||||
dump_mode: False
|
||||
@@ -11,3 +12,16 @@ dbName: "my_db_name"
|
||||
vote_host: "localhost" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
|
||||
# vote_path: "my-room-slug" # Optional URL path for this room's voting page (auto-derived from the room name if unset)
|
||||
vote_activity_hours: 72 # Hours a user must have been active within to be eligible to vote
|
||||
|
||||
# --- Wait times (seconds) ---
|
||||
sync_timeout: 3000 # Initial sync timeout for scraper/user-list (ms)
|
||||
poll_interval: 30 # How often the scraper polls for new messages (s)
|
||||
command_sync_timeout: 30000 # Sync timeout when listening for chat commands (ms)
|
||||
post_interval: 0.5 # Delay between posting each suggestion (s)
|
||||
initial_sync_timeout: 300 # Initial sync timeout for post_results (ms)
|
||||
post_loop_interval: 1 # Delay between post_results loop iterations (s)
|
||||
user_list_interval: 300 # How often the user-list service refreshes (s)
|
||||
user_list_retry_interval: 60 # Retry delay on user-list error (s)
|
||||
|
||||
# --- Feature toggles ---
|
||||
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
username: "magbot"
|
||||
password: "zC1b9qCKT8Cv0D"
|
||||
homeserver: "https://jupiterbroadcasting.com"
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
|
||||
#room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
dbName: ""
|
||||
# vote_host: "agmninex.online" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
|
||||
vote_host: "agmninex.online"
|
||||
vote_path: "lunch" # URL slug for this room's voting page (http://<vote_host>:9081/<vote_path>)
|
||||
vote_activity_hours: 6672 # Hours a user must have been active within to be eligible to vote
|
||||
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
|
||||
@@ -1,8 +1,9 @@
|
||||
username: "magbot"
|
||||
password: "zC1b9qCKT8Cv0D"
|
||||
room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
homeserver: "https://jupiterbroadcasting.com"
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
|
||||
#room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
@@ -11,3 +12,4 @@ dbName: ""
|
||||
vote_host: "agmninex.online"
|
||||
vote_path: "jb-chat" # URL slug for this room's voting page (http://<vote_host>:9081/<vote_path>)
|
||||
vote_activity_hours: 6672 # Hours a user must have been active within to be eligible to vote
|
||||
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
|
||||
@@ -0,0 +1,31 @@
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
|
||||
LOCKED_MSGS = ("database is locked", "database is busy")
|
||||
|
||||
|
||||
def connect(db_path, timeout=30):
|
||||
"""Open a SQLite connection hardened against cross-process contention.
|
||||
|
||||
Enables WAL mode and a busy timeout so that the multiple bot processes
|
||||
sharing a database file wait for each other instead of raising
|
||||
``OperationalError: database is locked`` immediately.
|
||||
"""
|
||||
conn = sqlite3.connect(db_path, timeout=timeout)
|
||||
conn.execute("PRAGMA busy_timeout=%d" % (timeout * 1000))
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def commit(conn, retries=5, backoff=0.2):
|
||||
"""Commit, retrying briefly when the database is locked by another writer."""
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
conn.commit()
|
||||
return
|
||||
except sqlite3.OperationalError as e:
|
||||
if str(e).lower() in LOCKED_MSGS and attempt < retries - 1:
|
||||
time.sleep(backoff * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
+22
-18
@@ -9,8 +9,9 @@ import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
import sys
|
||||
from nio import AsyncClient, LoginResponse
|
||||
from nio import AsyncClient, LoginResponse, RoomMessageText, RoomMessagesResponse, SyncResponse
|
||||
from room import Room
|
||||
import dbutil
|
||||
|
||||
|
||||
class UserListManager:
|
||||
@@ -29,7 +30,7 @@ class UserListManager:
|
||||
|
||||
def init_user_db(self):
|
||||
"""Initialize the user list database."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -67,7 +68,7 @@ class UserListManager:
|
||||
if 'most_recent_post' not in cols:
|
||||
c.execute('ALTER TABLE fetch_log ADD COLUMN most_recent_post INTEGER DEFAULT 0')
|
||||
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
async def get_room_members(self):
|
||||
@@ -78,7 +79,10 @@ class UserListManager:
|
||||
return []
|
||||
|
||||
# Sync to get room state
|
||||
await self.room.client.sync(timeout=3000)
|
||||
assert self.room.client is not None
|
||||
sync_resp = await self.room.client.sync(timeout=self.room.config.sync_timeout)
|
||||
if not isinstance(sync_resp, SyncResponse):
|
||||
print(f"Sync failed: {sync_resp}")
|
||||
|
||||
# Get the room object
|
||||
room_obj = self.room.client.rooms.get(self.room.config.room_id)
|
||||
@@ -116,6 +120,7 @@ class UserListManager:
|
||||
fetched = 0
|
||||
|
||||
# Fetch messages in batches to count posts per user
|
||||
assert self.room.client is not None
|
||||
while fetched < lookback_limit:
|
||||
try:
|
||||
response = await self.room.client.room_messages(
|
||||
@@ -124,24 +129,23 @@ class UserListManager:
|
||||
limit=batch_size
|
||||
)
|
||||
|
||||
if not response.chunk:
|
||||
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
|
||||
break
|
||||
|
||||
fetched += len(response.chunk)
|
||||
|
||||
for event in response.chunk:
|
||||
if hasattr(event, 'sender') and hasattr(event, 'body'):
|
||||
if isinstance(event, RoomMessageText):
|
||||
sender = event.sender
|
||||
post_counts[sender] = post_counts.get(sender, 0) + 1
|
||||
|
||||
# Track the most recent message timestamp for each user
|
||||
if hasattr(event, 'server_timestamp'):
|
||||
event_time = int(event.server_timestamp // 1000)
|
||||
if sender not in last_activity or event_time > last_activity[sender]:
|
||||
last_activity[sender] = event_time
|
||||
# Track the absolute most recent post in the room
|
||||
if event_time > most_recent_post:
|
||||
most_recent_post = event_time
|
||||
event_time = int(event.server_timestamp // 1000)
|
||||
if sender not in last_activity or event_time > last_activity[sender]:
|
||||
last_activity[sender] = event_time
|
||||
# Track the absolute most recent post in the room
|
||||
if event_time > most_recent_post:
|
||||
most_recent_post = event_time
|
||||
|
||||
next_batch = response.end
|
||||
|
||||
@@ -161,7 +165,7 @@ class UserListManager:
|
||||
print("No users to write to database")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
current_time = int(time.time())
|
||||
@@ -207,13 +211,13 @@ class UserListManager:
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (current_time, len(self.users), datetime.now().isoformat(), getattr(self, 'most_recent_post', 0)))
|
||||
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
print(f"Wrote {len(self.users)} users to {self.db_path}")
|
||||
|
||||
def read_users_from_db(self):
|
||||
"""Read all users from the database."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT user_id, display_name, last_seen, last_updated, num_posts, last_active FROM users ORDER BY num_posts DESC, display_name')
|
||||
users = []
|
||||
@@ -265,12 +269,12 @@ async def main():
|
||||
|
||||
# Wait 5 minutes before next update
|
||||
print("\nWaiting 5 minutes before next update...")
|
||||
await asyncio.sleep(300) # 5 minutes
|
||||
await asyncio.sleep(manager.room.config.user_list_interval)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during user list update: {e}")
|
||||
print("Retrying in 1 minute...")
|
||||
await asyncio.sleep(60) # Wait 1 minute on error before retry
|
||||
await asyncio.sleep(manager.room.config.user_list_retry_interval) # Wait 1 minute on error before retry
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down user list service...")
|
||||
|
||||
+15
-12
@@ -39,7 +39,10 @@ async def main():
|
||||
|
||||
if command:
|
||||
print("!getvotes command detected, posting vote results...")
|
||||
|
||||
|
||||
# Clear the web poll by marking all pending suggestions as posted
|
||||
room.mark_submissions_posted()
|
||||
|
||||
# Read and format vote results
|
||||
vote_message = format_vote_results(room)
|
||||
|
||||
@@ -56,8 +59,8 @@ async def main():
|
||||
|
||||
def init_getvotes_table(room):
|
||||
"""Initialize the getvotes tracking table in the database."""
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(room.db_path)
|
||||
import dbutil
|
||||
conn = dbutil.connect(room.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS getvotes (
|
||||
@@ -70,14 +73,14 @@ def init_getvotes_table(room):
|
||||
UNIQUE(sender, timestamp)
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_last_getvotes_timestamp(room):
|
||||
"""Get the timestamp of the last !getvotes command for this room."""
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(room.db_path)
|
||||
import dbutil
|
||||
conn = dbutil.connect(room.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
SELECT MAX(timestamp) FROM getvotes
|
||||
@@ -91,10 +94,10 @@ def get_last_getvotes_timestamp(room):
|
||||
|
||||
def record_getvotes(room, sender, timestamp, event_id=None):
|
||||
"""Record a !getvotes detection; returns True if newly recorded (not duplicate)."""
|
||||
import sqlite3
|
||||
import dbutil
|
||||
import datetime
|
||||
|
||||
conn = sqlite3.connect(room.db_path)
|
||||
conn = dbutil.connect(room.db_path)
|
||||
c = conn.cursor()
|
||||
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
||||
c.execute('''
|
||||
@@ -102,7 +105,7 @@ def record_getvotes(room, sender, timestamp, event_id=None):
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (event_id, sender, int(timestamp), dt_str))
|
||||
inserted = c.rowcount == 1
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
return inserted
|
||||
|
||||
@@ -111,7 +114,7 @@ async def wait_for_getvotes(room, since_ms):
|
||||
"""Wait for !getvotes command in the chat."""
|
||||
from nio import SyncResponse, RoomMessageText
|
||||
|
||||
sync_response = await room.client.sync(timeout=30000)
|
||||
sync_response = await room.client.sync(timeout=room.config.command_sync_timeout)
|
||||
if not isinstance(sync_response, SyncResponse):
|
||||
return None
|
||||
|
||||
@@ -154,10 +157,10 @@ def format_vote_results(room):
|
||||
return "❌ No vote data available yet. The voting system may not have received any votes."
|
||||
|
||||
try:
|
||||
import sqlite3
|
||||
import dbutil
|
||||
|
||||
# Read votes from database
|
||||
conn = sqlite3.connect(vote_db)
|
||||
conn = dbutil.connect(vote_db)
|
||||
c = conn.cursor()
|
||||
|
||||
# Get all votes grouped by submission
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
+64
-9783
File diff suppressed because it is too large
Load Diff
-1318672
File diff suppressed because it is too large
Load Diff
+18
-30363
File diff suppressed because it is too large
Load Diff
-2649977
File diff suppressed because it is too large
Load Diff
+38
-7663
File diff suppressed because it is too large
Load Diff
+18
-30363
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,17 +1,17 @@
|
||||
import yaml
|
||||
import re
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import sys
|
||||
from nio import AsyncClient, LoginResponse, RoomSendResponse, RoomMessageText, SyncResponse
|
||||
import time
|
||||
from post import Post
|
||||
from room import Room
|
||||
import dbutil
|
||||
|
||||
|
||||
def get_last_getvotes_timestamp(room):
|
||||
"""Get the timestamp of the last !getvotes command for this room."""
|
||||
conn = sqlite3.connect(room.db_path)
|
||||
conn = dbutil.connect(room.db_path)
|
||||
c = conn.cursor()
|
||||
# Check if getvotes table exists
|
||||
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='getvotes'")
|
||||
@@ -35,7 +35,8 @@ async def main():
|
||||
return
|
||||
|
||||
# Initial sync establishes the next-batch; after this, listen from "now"
|
||||
await room.client.sync(timeout=300)
|
||||
assert room.client is not None
|
||||
await room.client.sync(timeout=room.config.initial_sync_timeout)
|
||||
room.write_room_slugs()
|
||||
|
||||
try:
|
||||
@@ -59,7 +60,7 @@ async def main():
|
||||
room.update_last_post()
|
||||
else:
|
||||
await room.alert_post("No new unposted suggestions found.")
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(room.config.post_loop_interval)
|
||||
finally:
|
||||
await room.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.14",
|
||||
"typeCheckingMode": "basic"
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import yaml
|
||||
import sqlite3
|
||||
import re
|
||||
import asyncio
|
||||
from nio import AsyncClient, LoginResponse, RoomMessageText, SyncResponse, RoomSendResponse
|
||||
from nio import AsyncClient, LoginResponse, MessageDirection, ProfileGetDisplayNameResponse, RoomMessageText, RoomMessagesResponse, RoomSendResponse, SyncResponse
|
||||
from post import Post
|
||||
import requests
|
||||
import dbutil
|
||||
|
||||
default_search_terms = ["!suggest", "!Suggest", "!suggestion", "!Suggestion", "SUGGEST", "!sug"]
|
||||
|
||||
@@ -34,36 +35,50 @@ class Config:
|
||||
self.vote_host = config.get("vote_host", "localhost")
|
||||
self.vote_activity_hours = config.get("vote_activity_hours", 72)
|
||||
self.vote_path = config.get("vote_path", "")
|
||||
self.sync_timeout = config.get("sync_timeout", 3000)
|
||||
self.poll_interval = config.get("poll_interval", 30)
|
||||
self.command_sync_timeout = config.get("command_sync_timeout", 30000)
|
||||
self.post_interval = config.get("post_interval", 0.5)
|
||||
self.initial_sync_timeout = config.get("initial_sync_timeout", 300)
|
||||
self.post_loop_interval = config.get("post_loop_interval", 1)
|
||||
self.user_list_interval = config.get("user_list_interval", 300)
|
||||
self.user_list_retry_interval = config.get("user_list_retry_interval", 60)
|
||||
self.suggestion_announce_interval = config.get("suggestion_announce_interval", 0)
|
||||
|
||||
class Room:
|
||||
class Room:
|
||||
def __init__(self, config_path="configs/config.yaml", search_terms=None, homeserver=None):
|
||||
self.config = Config(config_path)
|
||||
self.search_terms = search_terms or default_search_terms
|
||||
self.formatted_sugs = ""
|
||||
self.posts = []
|
||||
self.newly_inserted_posts = []
|
||||
self.rejected_banned_posts = []
|
||||
self.client = None
|
||||
self.room = None
|
||||
self.user_id = None
|
||||
self.member = None
|
||||
self.display_name = None
|
||||
self.last_run = 0
|
||||
|
||||
self._display_name_cache = {}
|
||||
|
||||
if homeserver:
|
||||
self.config.homeserver = homeserver
|
||||
|
||||
|
||||
self.db_path = self.safe_db_name(self.config.room_id)
|
||||
self.banned_db_path = "banned_users.db"
|
||||
self.init_db()
|
||||
|
||||
self.init_banned_db()
|
||||
self.init_suggestion_counter_db()
|
||||
|
||||
|
||||
def safe_db_name(self, room_id):
|
||||
safe = re.sub(r'[^a-zA-Z0-9_-]', '_', room_id)
|
||||
return f"suggestions_{safe}.db"
|
||||
|
||||
def init_db(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS suggestions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user TEXT,
|
||||
@@ -77,17 +92,17 @@ class Room:
|
||||
)
|
||||
''')
|
||||
# Add is_posted column if it doesn't exist
|
||||
c.execute("PRAGMA table_info(suggestions)")
|
||||
_ = c.execute("PRAGMA table_info(suggestions)")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
if 'is_posted' not in cols:
|
||||
c.execute('ALTER TABLE suggestions ADD COLUMN is_posted INTEGER DEFAULT 0')
|
||||
_ = c.execute('ALTER TABLE suggestions ADD COLUMN is_posted INTEGER DEFAULT 0')
|
||||
if 'event_id' not in cols:
|
||||
c.execute('ALTER TABLE suggestions ADD COLUMN event_id TEXT')
|
||||
_ = c.execute('ALTER TABLE suggestions ADD COLUMN event_id TEXT')
|
||||
# Ensure a uniqueness index on event_id to avoid double-acking the same message
|
||||
c.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_suggestions_event_id ON suggestions(event_id)')
|
||||
|
||||
_ = c.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_suggestions_event_id ON suggestions(event_id)')
|
||||
|
||||
# New: table to log all !gettitles detections (prevents duplicates)
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS gettitles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT,
|
||||
@@ -98,9 +113,9 @@ class Room:
|
||||
UNIQUE(sender, timestamp)
|
||||
)
|
||||
''')
|
||||
|
||||
|
||||
# New: table to log all !mayhem detections (prevents duplicates)
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS mayhem (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT,
|
||||
@@ -111,9 +126,9 @@ class Room:
|
||||
UNIQUE(sender, timestamp)
|
||||
)
|
||||
''')
|
||||
|
||||
|
||||
# New: table to log all !countvotes detections (prevents duplicates)
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS countvotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT,
|
||||
@@ -124,9 +139,9 @@ class Room:
|
||||
UNIQUE(sender, timestamp)
|
||||
)
|
||||
''')
|
||||
|
||||
|
||||
# New: table to log all !voteurl detections (prevents duplicates)
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS voteurl (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT,
|
||||
@@ -137,9 +152,94 @@ class Room:
|
||||
UNIQUE(sender, timestamp)
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def init_banned_db(self):
|
||||
"""Initialize the shared banned-users database (primary key + user string)."""
|
||||
conn = dbutil.connect(self.banned_db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS banned_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user TEXT UNIQUE
|
||||
)
|
||||
''')
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def is_banned(self, user):
|
||||
"""Return True if the given Matrix account/homeserver string is banned."""
|
||||
if not user:
|
||||
return False
|
||||
conn = dbutil.connect(self.banned_db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute("SELECT 1 FROM banned_users WHERE user = ?", (user,))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
return row is not None
|
||||
|
||||
def ban_user(self, user):
|
||||
"""Add a Matrix account/homeserver string to the banned list."""
|
||||
conn = dbutil.connect(self.banned_db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute("INSERT OR IGNORE INTO banned_users (user) VALUES (?)", (user,))
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def unban_user(self, user):
|
||||
"""Remove a Matrix account/homeserver string from the banned list."""
|
||||
conn = dbutil.connect(self.banned_db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute("DELETE FROM banned_users WHERE user = ?", (user,))
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def init_suggestion_counter_db(self):
|
||||
"""Initialize the suggestion counter table for periodic announcements."""
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS suggestion_counter (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
count INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
_ = c.execute('INSERT OR IGNORE INTO suggestion_counter (id, count) VALUES (1, 0)')
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def get_suggestion_counter(self):
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute('SELECT count FROM suggestion_counter WHERE id = 1')
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
return row[0] if row else 0
|
||||
|
||||
def increment_suggestion_counter(self, amount=1):
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute('UPDATE suggestion_counter SET count = count + ? WHERE id = 1', (amount,))
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
return self.get_suggestion_counter()
|
||||
|
||||
async def check_and_announce_vote_beta(self, new_count):
|
||||
"""Post a beta announcement if the suggestion count hits the configured interval."""
|
||||
interval = self.config.suggestion_announce_interval
|
||||
if interval <= 0:
|
||||
return
|
||||
if new_count > 0 and new_count % interval == 0:
|
||||
url = self.vote_url()
|
||||
await self.alert_post(
|
||||
f"Psst! HTTP voting is now in beta! You can vote on titles at:\n{url}"
|
||||
)
|
||||
|
||||
async def reject_banned_suggestion(self, post):
|
||||
"""Reply to a banned user's suggestion and do not insert it."""
|
||||
await self.alert_post(f"{post.display_name}: this user has been banned from submitting titles")
|
||||
|
||||
async def login_to_matrix(self):
|
||||
# Initialize client if needed
|
||||
if self.client is None:
|
||||
@@ -160,15 +260,21 @@ class Room:
|
||||
return False
|
||||
|
||||
async def get_display_name(self, user_id):
|
||||
if user_id in self._display_name_cache:
|
||||
return self._display_name_cache[user_id]
|
||||
assert self.client is not None
|
||||
name = user_id
|
||||
room = self.client.rooms.get(self.config.room_id)
|
||||
if room and user_id in room.users:
|
||||
member = room.users[user_id]
|
||||
if hasattr(member, "display_name") and member.display_name:
|
||||
return member.display_name
|
||||
resp = await self.client.get_displayname(user_id)
|
||||
if hasattr(resp, "displayname") and resp.displayname:
|
||||
return resp.displayname
|
||||
return user_id
|
||||
name = member.display_name
|
||||
if name == user_id:
|
||||
resp = await self.client.get_displayname(user_id)
|
||||
if isinstance(resp, ProfileGetDisplayNameResponse) and resp.displayname:
|
||||
name = resp.displayname
|
||||
self._display_name_cache[user_id] = name
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def slugify(text):
|
||||
@@ -213,33 +319,39 @@ class Room:
|
||||
async def get_convos(self, limit=None):
|
||||
if limit is None:
|
||||
limit = self.config.lookback_limit
|
||||
|
||||
|
||||
self.posts = []
|
||||
batch_size = self.config.batch_size
|
||||
next_batch = None
|
||||
fetched = 0
|
||||
|
||||
self.get_last_run()
|
||||
assert self.client is not None
|
||||
|
||||
while fetched < limit:
|
||||
fetch_amount = min(batch_size, limit - fetched)
|
||||
response = await self.client.room_messages(
|
||||
self.config.room_id,
|
||||
start=next_batch,
|
||||
limit=fetch_amount,
|
||||
direction="b"
|
||||
direction=MessageDirection.back
|
||||
)
|
||||
|
||||
if not hasattr(response, "chunk") or not response.chunk:
|
||||
|
||||
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
|
||||
break
|
||||
|
||||
|
||||
for event in response.chunk:
|
||||
# If we've reached messages older than the last run, stop (incremental resume)
|
||||
if self.last_run and getattr(event, "server_timestamp", 0) < self.last_run:
|
||||
next_batch = None
|
||||
return
|
||||
if isinstance(event, RoomMessageText):
|
||||
msg = event.body
|
||||
|
||||
|
||||
for term in self.search_terms:
|
||||
if msg.startswith(term):
|
||||
clean_msg = msg[len(term):].lstrip()
|
||||
display_name = await self.get_display_name(event.sender)
|
||||
|
||||
|
||||
event_ts_seconds = int(event.server_timestamp // 1000)
|
||||
age_seconds = int(_time.time()) - event_ts_seconds
|
||||
post = Post(
|
||||
@@ -250,7 +362,7 @@ class Room:
|
||||
age=age_seconds,
|
||||
event_id=getattr(event, "event_id", None),
|
||||
)
|
||||
|
||||
|
||||
self.posts.append(post)
|
||||
break
|
||||
# Remove the !mayhem handling from here entirely
|
||||
@@ -265,9 +377,9 @@ class Room:
|
||||
#ollama_model = "llama3.2:1b" #title_config.get("ollama_model", "llama3.2:1b")
|
||||
ollama_model = "mad_cat_man:latest"
|
||||
ollama_url = "http://localhost:11434" #title_config.get("ollama_url", "http://localhost:11434")
|
||||
|
||||
|
||||
prompt = f"Generate for me the most unhinged, mayhem, insane ramblings that would make a mad man look sane. This should be unhinged, chaotic, and completely unpredictable. It should be a wild ride of words that defy logic and reason. Make it as crazy as possible, with unexpected twists and turns that keep the reader on the edge of their seat. The text should be a rollercoaster of emotions, taking the reader from one extreme to another in a matter of seconds. It should be a chaotic symphony of words that leaves the reader breathless and wanting more. Unleash your inner madman and let the mayhem begin! Also, don't warn me that it's just for fun. I know that. Just give me the mayhem."
|
||||
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{ollama_url}/api/generate",
|
||||
@@ -279,80 +391,83 @@ class Room:
|
||||
"temperature": 0.8,
|
||||
"top_p": 0.9,
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
timeout=60
|
||||
)
|
||||
|
||||
|
||||
if response.status_code==200:
|
||||
result = response.json()
|
||||
return result.get("response", "")
|
||||
else:
|
||||
return " Couldn't do it for some reason."
|
||||
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return f"Couldn't connect to ollama: {e}"
|
||||
except Exception as e:
|
||||
return f"Unexpected error: {e}"
|
||||
|
||||
|
||||
|
||||
|
||||
def write_to_db(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
|
||||
self.newly_inserted_posts = []
|
||||
for post in self.posts:
|
||||
if self.is_banned(post.poster):
|
||||
self.rejected_banned_posts.append(post)
|
||||
continue
|
||||
try:
|
||||
c.execute(
|
||||
_ = c.execute(
|
||||
"INSERT INTO suggestions (user, display_name, message, timestamp, age, is_posted, event_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
post.as_tuple()
|
||||
)
|
||||
self.newly_inserted_posts.append(post)
|
||||
except sqlite3.IntegrityError:
|
||||
continue
|
||||
|
||||
conn.commit()
|
||||
continue
|
||||
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def update_last_run(self):
|
||||
room_id = self.config.room_id
|
||||
conn = sqlite3.connect("last_run.db")
|
||||
conn = dbutil.connect("last_run.db")
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS last_run (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
timestamp INTEGER,
|
||||
datetime TEXT
|
||||
)
|
||||
''')
|
||||
c.execute("PRAGMA table_info(last_run)")
|
||||
_ = c.execute("PRAGMA table_info(last_run)")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
if 'datetime' not in cols:
|
||||
c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
|
||||
_ = c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
|
||||
|
||||
now = int(_time.time())
|
||||
now_str = datetime.datetime.now().isoformat(sep=' ', timespec='seconds')
|
||||
c.execute('INSERT OR REPLACE INTO last_run (room_id, timestamp, datetime) VALUES (?, ?, ?)', (room_id, now, now_str))
|
||||
conn.commit()
|
||||
_ = c.execute('INSERT OR REPLACE INTO last_run (room_id, timestamp, datetime) VALUES (?, ?, ?)', (room_id, now, now_str))
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def get_last_run(self):
|
||||
room_id = self.config.room_id
|
||||
conn = sqlite3.connect("last_run.db")
|
||||
conn = dbutil.connect("last_run.db")
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS last_run (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
timestamp INTEGER,
|
||||
datetime TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
c.execute("PRAGMA table_info(last_run)")
|
||||
|
||||
_ = c.execute("PRAGMA table_info(last_run)")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
if 'datetime' not in cols:
|
||||
c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
|
||||
c.execute('SELECT timestamp FROM last_run WHERE room_id = ?', (room_id,))
|
||||
_ = c.execute('ALTER TABLE last_run ADD COLUMN datetime TEXT')
|
||||
_ = c.execute('SELECT timestamp FROM last_run WHERE room_id = ?', (room_id,))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
@@ -362,60 +477,60 @@ class Room:
|
||||
|
||||
# New: record a !gettitles detection; returns True if newly recorded (not duplicate)
|
||||
def record_gettitles(self, sender, timestamp, event_id=None):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
INSERT OR IGNORE INTO gettitles (event_id, sender, timestamp, datetime)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (event_id, sender, int(timestamp), dt_str))
|
||||
inserted = c.rowcount == 1
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
return inserted
|
||||
|
||||
# New: record a !mayhem detection; returns True if newly recorded (not duplicate)
|
||||
def record_mayhem(self, sender, timestamp, event_id=None):
|
||||
"""Record a !mayhem detection; returns True if newly recorded (not duplicate)"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
INSERT OR IGNORE INTO mayhem (event_id, sender, timestamp, datetime)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (event_id, sender, int(timestamp), dt_str))
|
||||
inserted = c.rowcount == 1
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
return inserted
|
||||
|
||||
# New: record a !countvotes detection; returns True if newly recorded (not duplicate)
|
||||
def record_countvotes(self, sender, timestamp, event_id=None):
|
||||
"""Record a !countvotes detection; returns True if newly recorded (not duplicate)"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
INSERT OR IGNORE INTO countvotes (event_id, sender, timestamp, datetime)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (event_id, sender, int(timestamp), dt_str))
|
||||
inserted = c.rowcount == 1
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
return inserted
|
||||
|
||||
# New: record a !voteurl detection; returns True if newly recorded (not duplicate)
|
||||
def record_voteurl(self, sender, timestamp, event_id=None):
|
||||
"""Record a !voteurl detection; returns True if newly recorded (not duplicate)"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
INSERT OR IGNORE INTO voteurl (event_id, sender, timestamp, datetime)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (event_id, sender, int(timestamp), dt_str))
|
||||
inserted = c.rowcount == 1
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
return inserted
|
||||
|
||||
@@ -426,30 +541,31 @@ class Room:
|
||||
next_batch = None
|
||||
suggestion_votes = {}
|
||||
all_events = []
|
||||
|
||||
assert self.client is not None
|
||||
|
||||
# First pass: collect all recent events (both messages and reactions)
|
||||
for _ in range(15): # Look back further to catch more events
|
||||
response = await self.client.room_messages(
|
||||
self.config.room_id,
|
||||
start=next_batch,
|
||||
limit=batch_size,
|
||||
direction="b"
|
||||
direction=MessageDirection.back
|
||||
)
|
||||
|
||||
if not hasattr(response, "chunk") or not response.chunk:
|
||||
|
||||
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
|
||||
break
|
||||
|
||||
|
||||
all_events.extend(response.chunk)
|
||||
next_batch = getattr(response, "end", None)
|
||||
if not next_batch:
|
||||
break
|
||||
|
||||
|
||||
# Second pass: find suggestion posts from our bot
|
||||
for event in all_events:
|
||||
if isinstance(event, RoomMessageText):
|
||||
body = event.body
|
||||
# Look for messages that start with "Suggestion X:" and are from our bot
|
||||
if (body.startswith("Suggestion ") and ":" in body and
|
||||
if (body.startswith("Suggestion ") and ":" in body and
|
||||
event.sender == self.user_id):
|
||||
try:
|
||||
# Extract suggestion number and content
|
||||
@@ -458,7 +574,7 @@ class Room:
|
||||
suggestion_num = parts[0].replace("Suggestion ", "").strip()
|
||||
content = ":".join(parts[1:]).strip()
|
||||
event_id = getattr(event, "event_id", None)
|
||||
|
||||
|
||||
if event_id:
|
||||
suggestion_votes[suggestion_num] = {
|
||||
'content': content,
|
||||
@@ -469,40 +585,40 @@ class Room:
|
||||
except Exception as e:
|
||||
print(f"Error parsing suggestion: {e}")
|
||||
continue
|
||||
|
||||
|
||||
if not suggestion_votes:
|
||||
return "No suggestion posts found to count votes for."
|
||||
|
||||
|
||||
# Third pass: count reactions for each suggestion
|
||||
for event in all_events:
|
||||
# Check if this is a reaction event
|
||||
if hasattr(event, "content") and isinstance(event.content, dict):
|
||||
relates_to = event.content.get("m.relates_to", {})
|
||||
if (relates_to.get("rel_type") == "m.annotation" and
|
||||
if (relates_to.get("rel_type") == "m.annotation" and
|
||||
relates_to.get("key") == "👍"):
|
||||
|
||||
|
||||
target_event_id = relates_to.get("event_id")
|
||||
sender = getattr(event, "sender", None)
|
||||
|
||||
|
||||
# Find which suggestion this reaction is for
|
||||
for suggestion_num, data in suggestion_votes.items():
|
||||
if (data['event_id'] == target_event_id and
|
||||
sender and sender not in data['voters'] and
|
||||
if (data['event_id'] == target_event_id and
|
||||
sender and sender not in data['voters'] and
|
||||
sender != self.user_id): # Don't count bot's own reactions
|
||||
data['voters'].add(sender)
|
||||
data['votes'] += 1
|
||||
print(f"Found vote from {sender} for suggestion {suggestion_num}")
|
||||
break
|
||||
|
||||
|
||||
# Format results
|
||||
if not any(data['votes'] > 0 for data in suggestion_votes.values()):
|
||||
return "No votes found for any suggestions."
|
||||
|
||||
|
||||
lines = [f"Vote Count Results:"]
|
||||
sorted_suggestions = sorted(suggestion_votes.items(),
|
||||
key=lambda x: x[1]['votes'],
|
||||
sorted_suggestions = sorted(suggestion_votes.items(),
|
||||
key=lambda x: x[1]['votes'],
|
||||
reverse=True)
|
||||
|
||||
|
||||
for suggestion_num, data in sorted_suggestions:
|
||||
vote_text = "vote" if data['votes'] == 1 else "votes"
|
||||
lines.append(f"Suggestion {suggestion_num}: {data['votes']} {vote_text}")
|
||||
@@ -511,7 +627,7 @@ class Room:
|
||||
if len(content) > 80:
|
||||
content = content[:77] + "..."
|
||||
lines.append(f" └─ {content}")
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# Renamed and expanded to handle both !gettitles and !mayhem commands
|
||||
@@ -521,14 +637,15 @@ class Room:
|
||||
if since_ms is None:
|
||||
since_ms = int(_time.time() * 1000)
|
||||
|
||||
assert self.client is not None
|
||||
while True:
|
||||
sync_response = await self.client.sync(timeout=30000)
|
||||
sync_response = await self.client.sync(timeout=self.config.command_sync_timeout)
|
||||
if not isinstance(sync_response, SyncResponse):
|
||||
print("Sync failed, retrying...")
|
||||
continue
|
||||
continue
|
||||
room_info = sync_response.rooms.join.get(self.config.room_id)
|
||||
if not room_info or not hasattr(room_info, "timeline") or not hasattr(room_info.timeline, "events"):
|
||||
continue
|
||||
continue
|
||||
for event in reversed(room_info.timeline.events):
|
||||
if isinstance(event, RoomMessageText):
|
||||
# Ignore old events (pre-listen timestamp)
|
||||
@@ -538,7 +655,7 @@ class Room:
|
||||
if event.sender == self.user_id:
|
||||
continue
|
||||
body = event.body.strip()
|
||||
|
||||
|
||||
# Handle !gettitles
|
||||
if body == "!gettitles":
|
||||
event_id = getattr(event, "event_id", None)
|
||||
@@ -547,7 +664,7 @@ class Room:
|
||||
continue
|
||||
print("!gettitles detected")
|
||||
return "gettitles"
|
||||
|
||||
|
||||
# Handle !mayhem
|
||||
elif body.startswith("!mayhem"):
|
||||
event_id = getattr(event, "event_id", None)
|
||||
@@ -555,7 +672,7 @@ class Room:
|
||||
if self.record_mayhem(event.sender, ts_seconds, event_id):
|
||||
await self.alert_post("Standby. Ollama is being instructed to generate mayhem. This may take a moment...")
|
||||
await self.alert_post(self.generate_mayhem())
|
||||
|
||||
|
||||
# Handle !countvotes
|
||||
elif body == "!countvotes":
|
||||
event_id = getattr(event, "event_id", None)
|
||||
@@ -566,7 +683,7 @@ class Room:
|
||||
await self.alert_post("Counting votes... This may take a moment...")
|
||||
vote_results = await self.count_votes()
|
||||
await self.alert_post(vote_results)
|
||||
|
||||
|
||||
# Handle !voteurl
|
||||
elif body == "!voteurl":
|
||||
event_id = getattr(event, "event_id", None)
|
||||
@@ -584,17 +701,18 @@ class Room:
|
||||
return result == "gettitles"
|
||||
|
||||
async def alert_post(self, post):
|
||||
assert self.client is not None
|
||||
content = {
|
||||
"msgtype": "m.text",
|
||||
"body": post
|
||||
"body": post
|
||||
}
|
||||
|
||||
|
||||
resp = await self.client.room_send(
|
||||
self.config.room_id,
|
||||
message_type="m.room.message",
|
||||
content=content
|
||||
)
|
||||
|
||||
|
||||
if isinstance(resp, RoomSendResponse):
|
||||
print("Alert posted")
|
||||
else:
|
||||
@@ -604,14 +722,14 @@ class Room:
|
||||
lines = ["Suggestions:"]
|
||||
for post in self.posts:
|
||||
clean_msg = "".join(post.content.strip().splitlines())
|
||||
user = post.display_name
|
||||
user = post.display_name
|
||||
if len(user) > 20:
|
||||
user = user[:20] + "..."
|
||||
lines.append(f"- {user}: {clean_msg}")
|
||||
self.formatted_sugs = "\n".join(lines)
|
||||
|
||||
def read_db(self, since_timestamp=None, min_timestamp=None, only_unposted=False):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
query = "SELECT user, display_name, message, timestamp, age, is_posted FROM suggestions"
|
||||
params = []
|
||||
@@ -626,27 +744,27 @@ class Room:
|
||||
conditions.append("is_posted = 0")
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
query += " ORDER BY timestamp ASC"
|
||||
c.execute(query, tuple(params))
|
||||
query += " ORDER BY timestamp ASC"
|
||||
_ = c.execute(query, tuple(params))
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
self.posts = [Post(poster=row[0], display_name=row[1], content=row[2], time=row[3], age=row[4], is_posted=bool(row[5])) for row in rows]
|
||||
|
||||
def get_last_post(self):
|
||||
conn = sqlite3.connect("last_post.db")
|
||||
conn = dbutil.connect("last_post.db")
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS last_post (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
timestamp INTEGER,
|
||||
datetime TEXT
|
||||
)
|
||||
''')
|
||||
c.execute("PRAGMA table_info(last_post)")
|
||||
_ = c.execute("PRAGMA table_info(last_post)")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
if 'datetime' not in cols:
|
||||
c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
|
||||
c.execute('SELECT timestamp FROM last_post WHERE room_id = ?', (self.config.room_id,))
|
||||
_ = c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
|
||||
_ = c.execute('SELECT timestamp FROM last_post WHERE room_id = ?', (self.config.room_id,))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
@@ -654,51 +772,51 @@ class Room:
|
||||
return None
|
||||
|
||||
def update_last_post(self):
|
||||
conn = sqlite3.connect("last_post.db")
|
||||
conn = dbutil.connect("last_post.db")
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS last_post (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
timestamp INTEGER,
|
||||
datetime TEXT
|
||||
)
|
||||
''')
|
||||
c.execute("PRAGMA table_info(last_post)")
|
||||
_ = c.execute("PRAGMA table_info(last_post)")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
if 'datetime' not in cols:
|
||||
c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
|
||||
|
||||
_ = c.execute('ALTER TABLE last_post ADD COLUMN datetime TEXT')
|
||||
|
||||
now = int(_time.time())
|
||||
now_str = datetime.datetime.now().isoformat(sep=' ', timespec='seconds')
|
||||
c.execute('INSERT OR REPLACE INTO last_post (room_id, timestamp, datetime) VALUES (?, ?, ?)',
|
||||
_ = c.execute('INSERT OR REPLACE INTO last_post (room_id, timestamp, datetime) VALUES (?, ?, ?)',
|
||||
(self.config.room_id, now, now_str))
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
async def post_suggestions(self):
|
||||
assert self.client is not None
|
||||
# Filter out already posted suggestions
|
||||
unposted_posts = [post for post in self.posts if not post.is_posted]
|
||||
|
||||
if not unposted_posts:
|
||||
await self.alert_post("No new unposted suggestions found.")
|
||||
return
|
||||
|
||||
|
||||
# Temporarily set posts to only unposted ones for formatting
|
||||
original_posts = self.posts
|
||||
self.posts = unposted_posts
|
||||
|
||||
|
||||
self.format_suggestions_block()
|
||||
content = {
|
||||
"msgtype": "m.text",
|
||||
"body": self.formatted_sugs
|
||||
}
|
||||
|
||||
|
||||
resp = await self.client.room_send(
|
||||
self.config.room_id,
|
||||
message_type="m.room.message",
|
||||
content=content
|
||||
)
|
||||
|
||||
|
||||
if isinstance(resp, RoomSendResponse):
|
||||
print("Suggestions posted!")
|
||||
# Mark posts as posted in both objects and database
|
||||
@@ -707,19 +825,20 @@ class Room:
|
||||
self.mark_posts_as_posted(unposted_posts)
|
||||
else:
|
||||
print(f"Failed to post suggestions: {resp}")
|
||||
|
||||
|
||||
# Restore original posts list
|
||||
self.posts = original_posts
|
||||
|
||||
async def post_suggestions_individually(self):
|
||||
"""Post each unposted suggestion as its own message with a 👍 reaction for voting."""
|
||||
assert self.client is not None
|
||||
unposted_posts = [post for post in self.posts if not post.is_posted]
|
||||
if not unposted_posts:
|
||||
await self.alert_post("No new unposted suggestions found.")
|
||||
return
|
||||
|
||||
await self.alert_post(f"""
|
||||
Time to vote! 🚢🚢🚢
|
||||
Time to vote! 🚢🚢🚢
|
||||
|
||||
Each title is a separate post below. Tap the 👍 reaction on the one you like to cast your vote!
|
||||
|
||||
@@ -727,6 +846,17 @@ class Room:
|
||||
|
||||
NOTE: HTTP VOTING IS IN PRE-ALPHA. IF YOU WOULD LIKE TO SEE THE EARLY PRE-RELEASE, GO TO {self.vote_url()}
|
||||
|
||||
MAKE MAGBOT BETTER:
|
||||
GIT REPO: https://magbot.online ⬅️⬅️⬅️ PRs welcome!
|
||||
|
||||
|
||||
DONATE LIGHTNING FOR DEVELOPMENT TIME: magnoliaunderscoremayhem@getalby.com
|
||||
- 50K sats pays for me to stay home from the farm for one day
|
||||
- The VPS is 10K sats per month
|
||||
|
||||
|
||||
|
||||
|
||||
""")
|
||||
|
||||
for idx, post in enumerate(unposted_posts, start=1):
|
||||
@@ -751,8 +881,6 @@ class Room:
|
||||
print(f"Failed to post suggestion {idx}: {resp}")
|
||||
continue
|
||||
|
||||
post.is_posted = True
|
||||
|
||||
reaction = {
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
@@ -766,32 +894,46 @@ class Room:
|
||||
content=reaction,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
await asyncio.sleep(self.config.post_interval)
|
||||
|
||||
self.mark_posts_as_posted([p for p in unposted_posts if p.is_posted])
|
||||
print("Suggestions posted!")
|
||||
|
||||
def mark_posts_as_posted(self, posts=None):
|
||||
"""Mark posts as posted in the database"""
|
||||
if posts is None:
|
||||
posts = self.posts
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
for post in posts:
|
||||
c.execute(
|
||||
_ = c.execute(
|
||||
"UPDATE suggestions SET is_posted = 1 WHERE user = ? AND message = ? AND timestamp = ?",
|
||||
(post.poster, post.content, post.time)
|
||||
)
|
||||
conn.commit()
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def mark_submissions_posted(self):
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute("UPDATE suggestions SET is_posted = 1 WHERE is_posted = 0")
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
async def close(self):
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
|
||||
async def acknowledge_rejected_suggestions(self):
|
||||
"""Reply to banned users that they cannot submit titles."""
|
||||
assert self.client is not None
|
||||
for post in self.rejected_banned_posts:
|
||||
await self.reject_banned_suggestion(post)
|
||||
self.rejected_banned_posts = []
|
||||
|
||||
async def acknowledge_new_suggestions(self):
|
||||
"""React ✅ to newly recorded suggestions to confirm they were saved."""
|
||||
assert self.client is not None
|
||||
if not self.newly_inserted_posts:
|
||||
return
|
||||
for post in self.newly_inserted_posts:
|
||||
@@ -816,5 +958,3 @@ class Room:
|
||||
print(f"Failed to ack suggestion: {resp}")
|
||||
except Exception as e:
|
||||
print(f"Error sending ✅ reaction: {e}")
|
||||
|
||||
|
||||
|
||||
+141
-11
@@ -133,6 +133,7 @@ cleanup() {
|
||||
kill "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pidfile"
|
||||
rm -f "$PID_DIR/$name.cmd"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
@@ -144,6 +145,8 @@ cleanup() {
|
||||
for script in scraper post_results get_votes get_user_list; do
|
||||
pkill -f "python3 $script.py" 2>/dev/null || true
|
||||
done
|
||||
|
||||
rm -rf "${RESTART_STATE_DIR:-}" 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}All services stopped.${NC}"
|
||||
exit 0
|
||||
@@ -157,23 +160,54 @@ start_go_service() {
|
||||
local name=$1
|
||||
local dir=$2
|
||||
local binary=$3
|
||||
local rebuild=0
|
||||
|
||||
echo -e "${BLUE}Building $name...${NC}"
|
||||
if [ "$OS" = "freebsd" ]; then
|
||||
(cd "$dir" && GOOS=freebsd go build -o "$binary") || {
|
||||
echo -e "${RED}Failed to build $name for FreeBSD${NC}"
|
||||
return 1
|
||||
}
|
||||
if [ -f "$dir/$binary" ]; then
|
||||
echo -e "${GREEN}Found existing $name binary ($dir/$binary), checking freshness...${NC}"
|
||||
# Only skip the build if the binary matches the current OS target and
|
||||
# is newer than every Go source file in the project (stale binaries rebuild).
|
||||
local os_ok=1
|
||||
case "$OS:$binary" in
|
||||
freebsd:jepfbd|freebsd:vsfbsd) os_ok=0 ;;
|
||||
linux:jep|linux:vs) os_ok=0 ;;
|
||||
esac
|
||||
if [ "$os_ok" -ne 0 ]; then
|
||||
echo -e "${YELLOW}$binary is for another platform; rebuilding ${name} for $OS${NC}"
|
||||
rebuild=1
|
||||
else
|
||||
local newest_src
|
||||
newest_src=$( (cd "$dir" && ls -t *.go go.mod go.sum 2>/dev/null) | head -1 )
|
||||
if [ -z "$newest_src" ] || [ "$dir/$binary" -nt "$dir/$newest_src" ]; then
|
||||
echo -e "${GREEN}✓ $name binary is current; skipping build${NC}"
|
||||
rebuild=0
|
||||
else
|
||||
echo -e "${YELLOW}$name binary is older than ${newest_src}; rebuilding${NC}"
|
||||
rebuild=1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
(cd "$dir" && go build -o "$binary") || {
|
||||
echo -e "${RED}Failed to build $name${NC}"
|
||||
return 1
|
||||
}
|
||||
rebuild=1
|
||||
fi
|
||||
|
||||
if [ "$rebuild" = "1" ]; then
|
||||
echo -e "${BLUE}Building $name...${NC}"
|
||||
if [ "$OS" = "freebsd" ]; then
|
||||
(cd "$dir" && GOOS=freebsd go build -o "$binary") || {
|
||||
echo -e "${RED}Failed to build $name for FreeBSD${NC}"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
(cd "$dir" && go build -o "$binary") || {
|
||||
echo -e "${RED}Failed to build $name${NC}"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Starting $name...${NC}"
|
||||
(cd "$dir" && ./"$binary" > "$LOG_DIR/$name.log" 2>&1) &
|
||||
echo $! > "$PID_DIR/$name.pid"
|
||||
echo "(cd \"$dir\" && .\"/$binary\" > \"$LOG_DIR/$name.log\" 2>&1) &" > "$PID_DIR/$name.cmd"
|
||||
|
||||
# Brief wait to check if it started successfully
|
||||
sleep 1
|
||||
@@ -203,6 +237,7 @@ start_python_service() {
|
||||
# Activate virtual environment and run script
|
||||
(source .venv/bin/activate && python3 "$script" $config_arg > "$LOG_DIR/$label.log" 2>&1) &
|
||||
echo $! > "$PID_DIR/$label.pid"
|
||||
echo "(source .venv/bin/activate && python3 \"$script\" $config_arg > \"$LOG_DIR/$label.log\" 2>&1) &" > "$PID_DIR/$label.cmd"
|
||||
|
||||
# Brief wait to check if it started successfully
|
||||
sleep 1
|
||||
@@ -324,6 +359,100 @@ echo ""
|
||||
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}"
|
||||
echo ""
|
||||
|
||||
# Minimum free memory (MB) required before an auto-restart is attempted.
|
||||
# Prevents making an out-of-memory situation worse by launching new processes.
|
||||
RESTART_MEM_MIN_MB=${RESTART_MEM_MIN_MB:-128}
|
||||
# Max restart attempts per service within the restart window before backing off.
|
||||
MAX_RESTARTS_PER_WINDOW=${MAX_RESTARTS_PER_WINDOW:-5}
|
||||
RESTART_WINDOW_SECS=${RESTART_WINDOW_SECS:-300}
|
||||
|
||||
# Track restart attempts per service (name -> timestamp:count)
|
||||
RESTART_STATE_DIR="$PID_DIR/restarts"
|
||||
mkdir -p "$RESTART_STATE_DIR"
|
||||
|
||||
free_mem_mb() {
|
||||
# Returns approximate free memory in MB (0 if unavailable -> allow restart)
|
||||
case "$OS" in
|
||||
freebsd)
|
||||
local pages free_kb
|
||||
pages=$(sysctl -n vm.stats.vm.v_free_count 2>/dev/null) || return 0
|
||||
free_kb=$(( pages * 4096 / 1024 ))
|
||||
echo "$(( free_kb / 1024 ))"
|
||||
;;
|
||||
linux)
|
||||
awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || return 0
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Register a restart attempt; returns 0 if allowed, 1 if the service is
|
||||
# restarting too often (crash loop) and should be left down for now.
|
||||
check_restart_budget() {
|
||||
local name=$1 now
|
||||
now=$( date +%s )
|
||||
local state_file="$RESTART_STATE_DIR/$name"
|
||||
# shellcheck disable=SC2086
|
||||
local count=0 first="$now"
|
||||
if [ -f "$state_file" ]; then
|
||||
read -r first count < "$state_file"
|
||||
fi
|
||||
# Expire the window once it has passed.
|
||||
if [ $(( now - first )) -ge "$RESTART_WINDOW_SECS" ]; then
|
||||
count=0
|
||||
first=$now
|
||||
fi
|
||||
if [ "$count" -ge "$MAX_RESTARTS_PER_WINDOW" ]; then
|
||||
echo -e "${YELLOW}✗ $name: hit max restarts ($MAX_RESTARTS_PER_WINDOW in ${RESTART_WINDOW_SECS}s), leaving down. Clear $RESTART_STATE_DIR/$name to reset.${NC}"
|
||||
return 1
|
||||
fi
|
||||
count=$(( count + 1 ))
|
||||
echo "$first $count" > "$state_file"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to restart a dead service from its saved relaunch command
|
||||
restart_service() {
|
||||
local name=$1
|
||||
local cmd_file="$PID_DIR/$name.cmd"
|
||||
|
||||
if [ ! -f "$cmd_file" ]; then
|
||||
echo -e "${RED}✗ $name: no restart command stored, cannot auto-restart${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! check_restart_budget "$name"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Do not relaunch more processes if the machine is nearly out of memory.
|
||||
local free_mb
|
||||
free_mb=$(free_mem_mb)
|
||||
if [ -n "$free_mb" ] && [ "$free_mb" -lt "$RESTART_MEM_MIN_MB" ]; then
|
||||
echo -e "${YELLOW}✗ $name: only ${free_mb}MB free (need >= ${RESTART_MEM_MIN_MB}MB), skipping restart to avoid OOM.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Restarting $name...${NC}"
|
||||
rm -f "$PID_DIR/$name.pid"
|
||||
# shellcheck disable=SC2046
|
||||
eval "$(cat "$cmd_file")"
|
||||
echo $! > "$PID_DIR/$name.pid"
|
||||
|
||||
sleep 1
|
||||
if kill -0 $(cat "$PID_DIR/$name.pid") 2>/dev/null; then
|
||||
echo -e "${GREEN}✓ $name restarted (PID: $(cat "$PID_DIR/$name.pid"))${NC}"
|
||||
# A restart that survives is a success; reset its budget.
|
||||
rm -f "$RESTART_STATE_DIR/$name"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ $name failed to restart (check $LOG_DIR/$name.log)${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Keep script running and monitor processes
|
||||
while true; do
|
||||
# Check if all processes are still running
|
||||
@@ -335,12 +464,13 @@ while true; do
|
||||
name=$(basename "$pidfile" .pid)
|
||||
echo -e "${RED}Warning: $name (PID: $pid) has stopped unexpectedly${NC}"
|
||||
all_running=false
|
||||
restart_service "$name"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$all_running" = false ]; then
|
||||
echo -e "${YELLOW}Some services have stopped. Check logs in $LOG_DIR${NC}"
|
||||
echo -e "${YELLOW}Some services were restarted. Check logs in $LOG_DIR${NC}"
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
|
||||
+8
-2
@@ -21,7 +21,8 @@ async def main():
|
||||
# await room.alert_post("SCRAPER LOGGED IN")
|
||||
|
||||
# Initial sync
|
||||
await room.client.sync(timeout=3000)
|
||||
assert room.client is not None
|
||||
await room.client.sync(timeout=room.config.sync_timeout)
|
||||
room.write_room_slugs()
|
||||
|
||||
try:
|
||||
@@ -34,11 +35,16 @@ async def main():
|
||||
# Scrape and write to database
|
||||
await room.get_convos()
|
||||
room.write_to_db()
|
||||
if room.newly_inserted_posts:
|
||||
count = room.increment_suggestion_counter(len(room.newly_inserted_posts))
|
||||
await room.check_and_announce_vote_beta(count)
|
||||
# Reply to banned users that they cannot submit titles
|
||||
await room.acknowledge_rejected_suggestions()
|
||||
# Ack newly recorded suggestions with a ✅ reaction
|
||||
await room.acknowledge_new_suggestions()
|
||||
room.update_last_run()
|
||||
print(f"Stored {len(room.posts)} suggestions in database.")
|
||||
await asyncio.sleep(30)
|
||||
await asyncio.sleep(room.config.poll_interval)
|
||||
finally:
|
||||
# Close connection
|
||||
await room.close()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
Forgot I had a suggestion simulator
|
||||
simulate suggestion 1
|
||||
simulated suggestion 2
|
||||
:wq
|
||||
@@ -13,3 +14,9 @@ I like trains
|
||||
freaking foamer
|
||||
WHERE DID MY BOOTS GO
|
||||
I have a booty problem
|
||||
super pooper
|
||||
!double bang!
|
||||
!suggest double suggestion
|
||||
they think they can stop my brilliance!
|
||||
They said I couldn't teach a llama to drive
|
||||
voom voom
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+30
-2
@@ -3,6 +3,34 @@ module vs
|
||||
go 1.24.9
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/nbd-wtf/go-nostr v0.52.3
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.13.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,173 @@
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g=
|
||||
github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nbd-wtf/go-nostr v0.52.3 h1:Xd87pXfJEJRXHpM+fLjQQln8dBNNaoPA10V7BbyP4KI=
|
||||
github.com/nbd-wtf/go-nostr v0.52.3/go.mod h1:4avYoc9mDGZ9wHsvCOhHH9vPzKucCfuYBtJUSpHTfNk=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
||||
+163
-5
@@ -16,13 +16,21 @@ body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#display_name {
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#suggestion_list {
|
||||
@@ -37,7 +45,7 @@ body {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#submit_vote {
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
background-color: #4CAF50;
|
||||
@@ -45,9 +53,159 @@ body {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#submit_vote {
|
||||
margin-top: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#auth_section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
#vote_section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#submit_vote:hover {
|
||||
background-color: #45a049;
|
||||
#logged_in_as {
|
||||
padding: 10px;
|
||||
background: #e8f5e9;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
font-weight: bold;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 15px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
color: #1565c0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 8px 12px;
|
||||
margin-top: 10px;
|
||||
background: #e8f5e9;
|
||||
border-radius: 4px;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 8px 12px;
|
||||
margin-top: 10px;
|
||||
background: #ffebee;
|
||||
border-radius: 4px;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
#connect_btn {
|
||||
background-color: #f57c00;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#connect_btn:hover {
|
||||
background-color: #ef6c00;
|
||||
}
|
||||
|
||||
#auth_btn {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
#auth_btn:hover {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
|
||||
#link_link {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#link_link:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#auth_method_select {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#auth_method_select p {
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
#matrix_auth_btn {
|
||||
background-color: #0dbd8b;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#matrix_auth_btn:hover {
|
||||
background-color: #0aa37a;
|
||||
}
|
||||
|
||||
#nostr_auth_btn {
|
||||
background-color: #f57c00;
|
||||
}
|
||||
|
||||
#nostr_auth_btn:hover {
|
||||
background-color: #ef6c00;
|
||||
}
|
||||
|
||||
#login_form {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
#login_form input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#back_link {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#back_link:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#sign_out_btn {
|
||||
background-color: #999;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#sign_out_btn:hover {
|
||||
background-color: #777;
|
||||
}
|
||||
|
||||
+255
-57
@@ -1,19 +1,185 @@
|
||||
// Fetch submissions from the JSON endpoint and populate the suggestion list
|
||||
let currentUser = null;
|
||||
let currentChallenge = null;
|
||||
let authMethod = null;
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- Matrix auth ---
|
||||
|
||||
function goToMatrixLogin() {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
window.location.href = '/' + slug + '/matrix-login';
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
sessionStorage.removeItem('matrix_session_token');
|
||||
currentUser = null;
|
||||
authMethod = null;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function showNostrAuth() {
|
||||
document.getElementById('auth_method_select').style.display = 'none';
|
||||
document.getElementById('nostr_auth_section').style.display = 'block';
|
||||
}
|
||||
|
||||
async function checkMatrixSession() {
|
||||
const sessionToken = sessionStorage.getItem('matrix_session_token');
|
||||
if (!sessionToken) return false;
|
||||
|
||||
try {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
const resp = await fetch('/' + slug + '/matrixVerify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_token: sessionToken })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
currentUser = { displayName: data.display_name, sessionToken: sessionToken };
|
||||
authMethod = 'matrix';
|
||||
|
||||
document.getElementById('auth_section').style.display = 'none';
|
||||
document.getElementById('vote_section').style.display = 'block';
|
||||
document.getElementById('logged_in_as').textContent = 'Signed in as: ' + data.display_name;
|
||||
|
||||
loadSubmissions();
|
||||
return true;
|
||||
} else {
|
||||
sessionStorage.removeItem('matrix_session_token');
|
||||
}
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem('matrix_session_token');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Nostr auth ---
|
||||
|
||||
function hasNip07() {
|
||||
return typeof window.nostr !== 'undefined' && window.nostr !== null;
|
||||
}
|
||||
|
||||
async function connectNip07() {
|
||||
if (!hasNip07()) {
|
||||
setAuthStatus('No Nostr browser extension detected. Enter your npub manually.', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hexKey = await window.nostr.getPublicKey();
|
||||
const resp = await fetch('encode_npub', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hex: hexKey })
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
document.getElementById('npub_input').value = data.npub;
|
||||
document.getElementById('nip07_badge').style.display = 'block';
|
||||
setAuthStatus('Connected! Click "Sign In to Vote" to continue.');
|
||||
} else {
|
||||
document.getElementById('npub_input').value = hexKey;
|
||||
setAuthStatus('Connected (using hex key). Click "Sign In to Vote".');
|
||||
}
|
||||
} catch (err) {
|
||||
setAuthStatus('Error: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const npub = document.getElementById('npub_input').value.trim();
|
||||
if (!npub) {
|
||||
setAuthStatus('Enter your npub or connect AlbyHub first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthStatus('Requesting challenge...');
|
||||
|
||||
try {
|
||||
const resp = await fetch('challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ npub: npub })
|
||||
});
|
||||
|
||||
if (resp.status === 404) {
|
||||
setAuthStatus('Npub not linked. Please <a href="link">link your identity</a> first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
setAuthStatus('Error: ' + text, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
currentChallenge = data.challenge;
|
||||
|
||||
if (!hasNip07()) {
|
||||
setAuthStatus('No Nostr extension. Please install AlbyHub to sign.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthStatus('Please sign the challenge in your extension...');
|
||||
|
||||
const signedEvent = await window.nostr.signEvent({
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: currentChallenge
|
||||
});
|
||||
|
||||
currentUser = {
|
||||
npub: npub,
|
||||
displayName: data.display_name,
|
||||
signedEvent: signedEvent
|
||||
};
|
||||
authMethod = 'nostr';
|
||||
|
||||
document.getElementById('auth_section').style.display = 'none';
|
||||
document.getElementById('vote_section').style.display = 'block';
|
||||
document.getElementById('logged_in_as').textContent = 'Signed in as: ' + data.display_name;
|
||||
|
||||
loadSubmissions();
|
||||
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('denied')) {
|
||||
setAuthStatus('Signature request denied.', true);
|
||||
} else {
|
||||
setAuthStatus('Error: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAuthStatus(msg, isError) {
|
||||
const el = document.getElementById('auth_status');
|
||||
el.innerHTML = msg;
|
||||
el.className = isError ? 'error' : 'success';
|
||||
}
|
||||
|
||||
// --- Submissions ---
|
||||
|
||||
async function loadSubmissions() {
|
||||
try {
|
||||
const response = await fetch('submissions');
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
const submissionList = document.getElementById('suggestion_list');
|
||||
submissionList.innerHTML = ''; // Clear existing items
|
||||
|
||||
submissionList.innerHTML = '';
|
||||
|
||||
if (data.Submissions && data.Submissions.length > 0) {
|
||||
data.Submissions.forEach((sub, index) => {
|
||||
data.Submissions.forEach((sub) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `
|
||||
<span class="suggestor">${sub.submitter} SUGGESTED</span> :
|
||||
<span class="suggestion">${sub.submission}</span>
|
||||
<input type="radio" name="vote" value="${sub.submission}" data-submitter="${sub.submitter}" data-time="${sub.submission_time}">
|
||||
<span class="suggestor">${escapeHtml(sub.submitter)} SUGGESTED</span> :
|
||||
<span class="suggestion">${escapeHtml(sub.submission)}</span>
|
||||
<input type="radio" name="vote" value="${escapeHtml(sub.submission)}" data-submitter="${escapeHtml(sub.submitter)}" data-time="${sub.submission_time}">
|
||||
`;
|
||||
submissionList.appendChild(li);
|
||||
});
|
||||
@@ -26,71 +192,103 @@ async function loadSubmissions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over all radio inputs and collect selected vote
|
||||
function getVotes() {
|
||||
const displayName = document.getElementById('display_name').value.trim();
|
||||
|
||||
if (!displayName) {
|
||||
alert('Please enter your display name');
|
||||
// --- Voting ---
|
||||
|
||||
async function submitVote() {
|
||||
if (!currentUser) {
|
||||
alert('Please sign in first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the selected radio button
|
||||
|
||||
const selectedRadio = document.querySelector('input[name="vote"]:checked');
|
||||
|
||||
if (!selectedRadio) {
|
||||
alert('Please select a title to vote for');
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect vote data
|
||||
|
||||
const voteData = {
|
||||
voter_display_name: displayName,
|
||||
selected_submission: selectedRadio.value,
|
||||
submitter: selectedRadio.dataset.submitter,
|
||||
submission_time: parseInt(selectedRadio.dataset.time),
|
||||
vote_timestamp: Math.floor(Date.now() / 1000)
|
||||
submission_time: parseInt(selectedRadio.dataset.time)
|
||||
};
|
||||
|
||||
// Send to the /getVotes endpoint
|
||||
fetch('getVotes', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(voteData)
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
alert('Vote submitted successfully!');
|
||||
// Clear the selection
|
||||
selectedRadio.checked = false;
|
||||
document.getElementById('display_name').value = '';
|
||||
} else {
|
||||
return response.text().then(text => {
|
||||
throw new Error(text || 'Failed to submit vote');
|
||||
|
||||
try {
|
||||
if (authMethod === 'matrix') {
|
||||
const resp = await fetch('matrixVote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_token: currentUser.sessionToken,
|
||||
...voteData
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
alert('Vote submitted successfully!');
|
||||
selectedRadio.checked = false;
|
||||
} else {
|
||||
const text = await resp.text();
|
||||
alert('Error: ' + text);
|
||||
}
|
||||
} else if (authMethod === 'nostr') {
|
||||
const challengeResp = await fetch('challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ npub: currentUser.npub })
|
||||
});
|
||||
|
||||
if (!challengeResp.ok) {
|
||||
const text = await challengeResp.text();
|
||||
alert('Error getting challenge: ' + text);
|
||||
return;
|
||||
}
|
||||
|
||||
const challengeData = await challengeResp.json();
|
||||
const challengeMsg = challengeData.challenge;
|
||||
|
||||
const signedEvent = await window.nostr.signEvent({
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: challengeMsg
|
||||
});
|
||||
|
||||
const voteResp = await fetch('verifyVote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
npub: currentUser.npub,
|
||||
challenge: challengeMsg,
|
||||
...voteData,
|
||||
signed_event: signedEvent
|
||||
})
|
||||
});
|
||||
|
||||
if (voteResp.ok) {
|
||||
alert('Vote submitted successfully!');
|
||||
selectedRadio.checked = false;
|
||||
} else {
|
||||
const text = await voteResp.text();
|
||||
alert('Error: ' + text);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
} catch (error) {
|
||||
console.error('Error submitting vote:', error);
|
||||
alert('Error submitting vote: ' + error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load submissions when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSubmissions();
|
||||
|
||||
// Add submit button if it doesn't exist
|
||||
if (!document.getElementById('submit_vote')) {
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.id = 'submit_vote';
|
||||
submitButton.textContent = '⛵⛵⛵⛵⛵⛵⛵⛵⛵';
|
||||
submitButton.onclick = getVotes;
|
||||
document.body.appendChild(submitButton);
|
||||
// --- Init ---
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
setInterval(() => {
|
||||
if (currentUser) loadSubmissions();
|
||||
}, 30000);
|
||||
|
||||
const matrixAuthed = await checkMatrixSession();
|
||||
if (matrixAuthed) return;
|
||||
|
||||
if (hasNip07()) {
|
||||
document.getElementById('nip07_badge').style.display = 'block';
|
||||
}
|
||||
|
||||
// Optionally refresh submissions periodically
|
||||
setInterval(loadSubmissions, 30000); // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Link Nostr Identity</title>
|
||||
<link rel="stylesheet" href="css.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Link Your Nostr Identity</h1>
|
||||
<p>Connect your npub to your Matrix display name so you can vote.</p>
|
||||
|
||||
<div id="auth_section">
|
||||
<div id="nip07_status" class="info-box" style="display:none;">
|
||||
Nostr browser extension detected.
|
||||
</div>
|
||||
|
||||
<label for="display_name">Matrix Display Name:</label>
|
||||
<input type="text" id="display_name" placeholder="Enter your Matrix display name exactly">
|
||||
|
||||
<label for="npub_input">Nostr Public Key (npub):</label>
|
||||
<input type="text" id="npub_input" placeholder="npub1...">
|
||||
|
||||
<button id="connect_btn" onclick="connectNip07()">Connect AlbyHub</button>
|
||||
<button id="link_btn" onclick="linkNpub()">Link & Sign</button>
|
||||
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentNpub = null;
|
||||
|
||||
async function connectNip07() {
|
||||
if (!window.nostr) {
|
||||
setStatus('No Nostr browser extension detected. Please enter your npub manually.', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const resp = await fetch('encode_npub', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({hex: pubkey})
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
document.getElementById('npub_input').value = data.npub;
|
||||
currentNpub = data.npub;
|
||||
setStatus('Connected! Npub loaded from extension.');
|
||||
} else {
|
||||
document.getElementById('npub_input').value = pubkey;
|
||||
currentNpub = pubkey;
|
||||
setStatus('Connected! Paste this as npub if needed: ' + pubkey);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error connecting to extension: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function linkNpub() {
|
||||
const displayName = document.getElementById('display_name').value.trim();
|
||||
const npub = document.getElementById('npub_input').value.trim();
|
||||
|
||||
if (!displayName) {
|
||||
setStatus('Please enter your Matrix display name.', true);
|
||||
return;
|
||||
}
|
||||
if (!npub) {
|
||||
setStatus('Please enter your npub or connect AlbyHub.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Requesting signature...');
|
||||
|
||||
try {
|
||||
let npubHex = npub;
|
||||
|
||||
const decodeResp = await fetch('decode_npub', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({npub: npub})
|
||||
});
|
||||
|
||||
if (!decodeResp.ok) {
|
||||
if (!window.nostr) {
|
||||
setStatus('Could not decode npub. Please install a Nostr extension.', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Could not decode npub via server.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const decodeData = await decodeResp.json();
|
||||
npubHex = decodeData.hex;
|
||||
|
||||
const slug = window.location.pathname.split('/')[1];
|
||||
const linkMsg = `Link npub ${npubHex} to Matrix user ${displayName} for room ${slug}`;
|
||||
|
||||
let signedEvent;
|
||||
if (window.nostr) {
|
||||
signedEvent = await window.nostr.signEvent({
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: linkMsg
|
||||
});
|
||||
} else {
|
||||
setStatus('No Nostr extension available. Please install AlbyHub or another NIP-07 signer.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Sending link request...');
|
||||
|
||||
const resp = await fetch('linkNpub', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
display_name: displayName,
|
||||
npub: npub,
|
||||
signed_event: signedEvent
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
setStatus('Successfully linked! You can now <a href="/">go vote</a>.');
|
||||
document.getElementById('link_btn').disabled = true;
|
||||
} else {
|
||||
const text = await resp.text();
|
||||
setStatus('Error: ' + text, true);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
const el = document.getElementById('status');
|
||||
el.innerHTML = msg;
|
||||
el.className = isError ? 'error' : 'success';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in with Matrix</title>
|
||||
<link rel="stylesheet" href="css.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Sign in with Matrix</h1>
|
||||
<p>Log in with your Matrix account to vote.</p>
|
||||
|
||||
<div id="login_form">
|
||||
<label for="homeserver">Homeserver:</label>
|
||||
<input type="text" id="homeserver" placeholder="https://matrix.org">
|
||||
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" id="username" placeholder="@username:matrix.org">
|
||||
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" placeholder="Password">
|
||||
|
||||
<button id="login_btn" onclick="matrixLogin()">Sign In</button>
|
||||
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<a id="back_link" href="../">Back to voting page</a>
|
||||
|
||||
<script>
|
||||
async function init() {
|
||||
const parts = window.location.pathname.split('/').filter(Boolean);
|
||||
const slug = parts[0];
|
||||
|
||||
if (!slug) {
|
||||
setStatus('Invalid URL. Go to the voting page first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/' + slug + '/roomConfig');
|
||||
if (resp.ok) {
|
||||
const config = await resp.json();
|
||||
document.getElementById('homeserver').value = config.homeserver || 'https://matrix.org';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('homeserver').value = 'https://matrix.org';
|
||||
}
|
||||
|
||||
const sessionToken = sessionStorage.getItem('matrix_session_token');
|
||||
if (sessionToken) {
|
||||
window.location.href = '/' + slug + '/';
|
||||
}
|
||||
}
|
||||
|
||||
async function matrixLogin() {
|
||||
const homeserver = document.getElementById('homeserver').value.trim().replace(/\/+$/, '');
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!homeserver || !username || !password) {
|
||||
setStatus('All fields are required.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Checking homeserver...');
|
||||
document.getElementById('login_btn').disabled = true;
|
||||
|
||||
try {
|
||||
const loginTypesResp = await fetch(homeserver + '/_matrix/client/v3/login');
|
||||
if (!loginTypesResp.ok) {
|
||||
setStatus('Could not reach homeserver. Check the URL.', true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const loginTypes = await loginTypesResp.json();
|
||||
const supportsPassword = loginTypes.flows && loginTypes.flows.some(f => f.type === 'm.login.password');
|
||||
|
||||
if (!supportsPassword) {
|
||||
setStatus('This homeserver does not support password login. Try the Nostr option instead.', true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Logging in...');
|
||||
|
||||
const loginResp = await fetch(homeserver + '/_matrix/client/v3/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'm.login.password',
|
||||
identifier: { type: 'm.id.user', user: username },
|
||||
password: password
|
||||
})
|
||||
});
|
||||
|
||||
if (!loginResp.ok) {
|
||||
setStatus('Login failed: ' + (loginResp.status === 403 ? 'Invalid credentials' : 'Homeserver error'), true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const loginData = await loginResp.json();
|
||||
const accessToken = loginData.access_token;
|
||||
|
||||
setStatus('Verifying identity...');
|
||||
|
||||
const parts = window.location.pathname.split('/').filter(Boolean);
|
||||
const slug = parts[0];
|
||||
|
||||
const authResp = await fetch('/' + slug + '/matrixAuth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
homeserver: homeserver
|
||||
})
|
||||
});
|
||||
|
||||
if (!authResp.ok) {
|
||||
const errText = await authResp.text();
|
||||
setStatus('Verification failed: ' + errText, true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const authData = await authResp.json();
|
||||
|
||||
sessionStorage.setItem('matrix_session_token', authData.session_token);
|
||||
|
||||
window.location.href = '/' + slug + '/';
|
||||
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
const el = document.getElementById('status');
|
||||
el.innerHTML = msg;
|
||||
el.className = isError ? 'error' : 'success';
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+38
-9
@@ -8,15 +8,44 @@
|
||||
</head>
|
||||
<body>
|
||||
<h1>Choose Your Favorite Title</h1>
|
||||
|
||||
<label for="display_name">Matrix Display Name (Not Matrix Base Username):</label>
|
||||
<input type="text" id="display_name" placeholder="Enter your display name">
|
||||
|
||||
<h2>Suggestions:</h2>
|
||||
<ul id="suggestion_list">
|
||||
<!-- Populated Dynamically Via JS -->
|
||||
</ul>
|
||||
|
||||
<div id="auth_section">
|
||||
<div id="auth_method_select">
|
||||
<p>How would you like to sign in?</p>
|
||||
<button id="matrix_auth_btn" onclick="goToMatrixLogin()">Sign in with Matrix</button>
|
||||
<button id="nostr_auth_btn" onclick="showNostrAuth()">Sign in with Nostr</button>
|
||||
</div>
|
||||
|
||||
<div id="nostr_auth_section" style="display:none;">
|
||||
<div id="nip07_badge" class="info-box" style="display:none;">
|
||||
Nostr extension connected
|
||||
</div>
|
||||
|
||||
<div id="auth_npub_row">
|
||||
<label for="npub_input">Nostr Public Key:</label>
|
||||
<input type="text" id="npub_input" placeholder="npub1... or connect extension">
|
||||
<button id="connect_btn" onclick="connectNip07()">Connect AlbyHub</button>
|
||||
</div>
|
||||
|
||||
<button id="auth_btn" onclick="authenticate()">Sign In to Vote</button>
|
||||
<a id="link_link" href="link">First time? Link your npub</a>
|
||||
|
||||
<div id="auth_status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vote_section" style="display:none;">
|
||||
<div id="logged_in_as"></div>
|
||||
<button id="sign_out_btn" onclick="signOut()">Sign Out</button>
|
||||
|
||||
<h2>Suggestions:</h2>
|
||||
<ul id="suggestion_list">
|
||||
<!-- Populated Dynamically Via JS -->
|
||||
</ul>
|
||||
|
||||
<button id="submit_vote" onclick="submitVote()">⛵⛵⛵⛵⛵⛵⛵⛵⛵</button>
|
||||
</div>
|
||||
|
||||
<script src="js.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+775
-35
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user