Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b7dafb832 | ||
|
|
ec7bb58c30 | ||
|
|
d06c1dde15 | ||
|
|
95809fde02 | ||
|
|
94e7d50301 | ||
|
|
37d5c29908 |
@@ -1,118 +1,109 @@
|
||||
# titlebot-ng
|
||||
A Matrix chat bot for Jupiter Broadcasting's "The Lunch" and "Linux Unplugged" chat rooms that collects and posts title suggestions with web-based voting.
|
||||
|
||||
A Matrix chat bot for Jupiter Broadcasting's “The Lunch” and “Linux Unplugged” chat rooms that collects title suggestions and runs voting through 👍 reactions.
|
||||
|
||||
### From the Owner
|
||||
|
||||
This has gotten out of hand. I've been working 7 days a week for a couple years now and this has been my most recent mortar between the bricks of time that I've been squeezing out of my life between sleep. Simplification needs to happen whenever possible.
|
||||
|
||||
# Technical Outline
|
||||
|
||||
The project is a mix of Python (Matrix bot services) and Go (voting backend). Each room gets its own instance of every Python service, so the number of running processes scales with the number of configured rooms.
|
||||
|
||||
Services:
|
||||
- **scraper.py** - Reads configured target Matrix room and collects suggestions
|
||||
- **post_results.py** - Listens for the `!gettitles` command and posts suggestions for voting
|
||||
- **get_user_list.py** - Collects the list of users in the room (with post counts and last activity) for vote validation
|
||||
- **get_votes.py** - Listens for the `!getvotes` command and posts vote results to the room
|
||||
- **jsonEndpoint/** - Go service (port 9080) exposing JSON endpoints for submissions and users
|
||||
- **voteServer/** - Go service (port 9081) serving the web voting interface
|
||||
|
||||
Everything is launched by `run_all.sh`, which auto-installs Go, creates the Python venv, discovers room configs, builds the Go services, and starts one instance of each Python service per room.
|
||||
|
||||
## Features
|
||||
- **Suggestion Collection**: Monitors chat for messages starting with `!suggest`, `!Suggest`, `!suggestion`, `!Suggestion`, `SUGGEST`, or `!sug`
|
||||
- **Interactive Voting**: Posts suggestions individually with 👍 reactions for voting
|
||||
- **Web Voting Interface**: Dedicated voting server with validation (exact display-name match + activity window)
|
||||
- **Multi-Room Support**: One config file per room under `configs/`, with URL slugs for routing
|
||||
- **Command System**:
|
||||
- `!gettitles` - Triggers posting of collected suggestions
|
||||
- `!mayhem` - Generates chaotic text using Ollama AI
|
||||
- `!countvotes` - Counts 👍 upvotes on posted suggestions
|
||||
- `!getvotes` - Posts current results from the voting server
|
||||
- `!voteurl` - Shares the voting URL where users can cast their votes
|
||||
- **Persistent Storage**: Per-room SQLite databases with duplicate prevention
|
||||
- **Acknowledgment System**: Reacts with ✅ to confirm suggestion collection
|
||||
- **Command Deduplication**: All commands are logged to prevent duplicate executions
|
||||
|
||||
## Quick Start
|
||||
1. Clone the repo and copy the example config for each room you want to monitor:
|
||||
|
||||
1. Create a Python environment and install dependencies:
|
||||
|
||||
```bash
|
||||
cp configs/config_example.yaml configs/config.yaml
|
||||
# Edit configs/config.yaml with your Matrix credentials and room ID
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Run everything with a single command (installs Go and creates the venv if missing):
|
||||
2. Copy the example config for each room you want to monitor:
|
||||
|
||||
```bash
|
||||
cp configs/config_example.yaml configs/main-room.yaml
|
||||
cp configs/config_example.yaml configs/second-room.yaml
|
||||
```
|
||||
|
||||
Edit each file with the appropriate Matrix bot credentials and a different `room_id`.
|
||||
|
||||
3. Start the room bots:
|
||||
|
||||
```bash
|
||||
./run_all.sh
|
||||
```
|
||||
|
||||
Or run the components individually:
|
||||
```bash
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install matrix-nio pyyaml requests
|
||||
cd jsonEndpoint && go build -o jep . && ./jep
|
||||
cd voteServer && go build -o vs . && ./vs
|
||||
```
|
||||
The launcher creates the Python environment and installs dependencies if the environment is missing. It starts two independent processes per room: a suggestion scraper and a command/posting bot. Logs and PID files include the full config filename. A failed room process is retried without stopping the others. Press Ctrl+C to stop the services.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Action |
|
||||
| --- | --- |
|
||||
| `!suggest <title>` | Submit a title suggestion. Also accepts `!Suggest`, `!suggestion`, `!Suggestion`, `SUGGEST`, and `!sug`. |
|
||||
| `!gettitles` | Post pending suggestions as individual messages, each with a 👍 reaction, preceded by voting instructions and the command list. |
|
||||
| `!countvotes` | Report the latest voting round's current emoji results, ranked most to fewest votes. |
|
||||
| `!getvotes` | Alias for `!countvotes`. |
|
||||
| `!clear` | Mark all pending suggestions as posted so they are excluded from future rounds. |
|
||||
| `!mayhem` | Generate chaotic text using Ollama. |
|
||||
|
||||
React with 👍 on a suggestion post to vote. Each person's thumbs-up counts once per title; the bot's starter reaction does not count. Thumbs-up skin-tone variants count as votes too. Removed reactions are excluded. Results include titles with zero votes, and ties appear in suggestion order. Run either results command again to refresh the ranking as people keep voting.
|
||||
|
||||
Successfully posted suggestions leave the pending queue. Results commands leave the queue untouched, and `!clear` preserves existing voting posts and their reactions. After clearing, `!gettitles` responds “No new title suggestions posted.” unless new suggestions have been collected.
|
||||
|
||||
## Configuration
|
||||
One YAML file per room in `configs/`. Example (`configs/config_example.yaml`):
|
||||
|
||||
Put one `.yaml` or `.yml` file per room in `configs/`, with any filename. `config.yaml` is not required. The bundled `config_example.yaml` template and files ending in `.hold` are ignored. Restart the launcher after adding or removing configs.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
username: "magbot"
|
||||
username: "@your-bot:matrix.org"
|
||||
password: "your_bot_password"
|
||||
room_id: "!your_room_id:homeserver.com" # Matrix room to monitor
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400 # Max messages to look back when scraping
|
||||
dump_mode: False # Dump raw messages when scraping (debugging)
|
||||
dbName: "my_db_name" # Optional, auto-derived from room_id if empty
|
||||
vote_host: "localhost" # Hostname for voting interface (e.g., "example.com", "vote.mysite.xyz")
|
||||
# vote_path: "my-room-slug" # Optional URL slug; auto-derived from room name if unset
|
||||
vote_activity_hours: 72 # Hours a user must have been active within to be eligible to vote
|
||||
homeserver: "https://matrix.org"
|
||||
room_id: "!your_room_id:matrix.org"
|
||||
lookback_limit: 7200
|
||||
batch_size: 100
|
||||
poll_interval: 30
|
||||
sync_timeout: 3000
|
||||
command_sync_timeout: 30000
|
||||
initial_sync_timeout: 300
|
||||
post_interval: 0.5
|
||||
post_loop_interval: 1
|
||||
```
|
||||
|
||||
The bot resolves each room's voting page to a URL slug (`vote_path` config override, otherwise derived from the room name/alias) and persists the mapping to `room_slugs.json`, which the Go services read for routing.
|
||||
Each room has its own suggestion database and command history. The bot acknowledges newly collected suggestions with ✅ and rejects submissions from accounts in the shared banned-users database.
|
||||
|
||||
## Architecture
|
||||
- **`room.py`**: Core `Room` class handling Matrix connection, database operations, and command processing
|
||||
- **`post.py`**: `Post` class representing individual suggestions
|
||||
- **`scraper.py`**: Main suggestion collection loop
|
||||
- **`post_results.py`**: Suggestion posting service that waits for `!gettitles` commands
|
||||
- **`get_votes.py`**: Vote results posting service that waits for `!getvotes` commands
|
||||
- **`get_user_list.py`**: User list collection service for vote validation
|
||||
- **`jsonEndpoint/`**: Go service providing JSON API for submissions and users (port 9080)
|
||||
- **`voteServer/`**: Go web server for voting interface (port 9081)
|
||||
- **`customOllama/`**: Custom Ollama model for mayhem text generation
|
||||
- **`run_all.sh`**: Master script to start all services
|
||||
## Components and Storage
|
||||
|
||||
## Database Schema
|
||||
Per room (suffixed with a sanitized room ID):
|
||||
- **suggestions.db**: Collected suggestions with metadata (`is_posted`, `event_id`)
|
||||
- **users.db**: User info (`display_name`, `num_posts`, `last_active`) for vote validation, plus a `fetch_log` of collection runs
|
||||
- **votes.db**: Web votes cast via the voting interface
|
||||
- **`room.py`**: Matrix connection, suggestion storage, command handling, posting, and live reaction counting.
|
||||
- **`post.py`**: Suggestion data model.
|
||||
- **`scraper.py`**: Collects and acknowledges suggestions.
|
||||
- **`post_results.py`**: Handles commands and posts suggestions when `!gettitles` is received.
|
||||
- **`run_all.sh`**: Starts and monitors the two Python services for each room.
|
||||
- **`simulate_suggestions.py`**: Sends suggestions from a text file for manual testing.
|
||||
- **`dbutil.py`**: SQLite connections and commit handling.
|
||||
|
||||
Shared:
|
||||
- **last_run.db / last_post.db**: Timestamps for state management
|
||||
- Command log tables (`gettitles`, `mayhem`, `countvotes`, `getvotes`, `voteurl`) live inside each room's suggestions database and prevent duplicate executions
|
||||
Per-room `suggestions_<room>.db` files store suggestions, posted status, original event IDs, and command deduplication records. Shared `last_run.db` and `last_post.db` track collection and posting timestamps; `banned_users.db` stores blocked accounts. Vote totals are read from Matrix reactions whenever results are requested.
|
||||
|
||||
## Dependencies
|
||||
- **Python**: matrix-nio, pyyaml, requests, sqlite3 (built-in)
|
||||
- **Go**: github.com/mattn/go-sqlite3, gopkg.in/yaml.v2
|
||||
To run the components individually, use the same config path for both:
|
||||
|
||||
```bash
|
||||
.venv/bin/python scraper.py configs/main-room.yaml
|
||||
.venv/bin/python post_results.py configs/main-room.yaml
|
||||
```
|
||||
|
||||
## Dependencies and Tests
|
||||
|
||||
Python 3, matrix-nio, PyYAML, requests, and Python's built-in SQLite support.
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m unittest discover -v
|
||||
```
|
||||
|
||||
## Optional: Ollama Integration
|
||||
For `!mayhem` command functionality:
|
||||
1. Install [Ollama](https://ollama.ai/)
|
||||
2. Build the custom model: `ollama create mad_cat_man:latest -f customOllama/Modelfile`
|
||||
3. Ensure Ollama is running on `localhost:11434`
|
||||
|
||||
## Notes
|
||||
- Each room gets its own SQLite database for suggestions
|
||||
- The bot acknowledges collected suggestions with ✅ reactions
|
||||
- Voting uses 👍 reactions on individual suggestion posts and a web voting interface
|
||||
- Command deduplication prevents spam and duplicate executions
|
||||
- Safe database naming handles special characters in room IDs
|
||||
- Web votes are tied to a user's exact Matrix display name; voters must have been active in the room within `vote_activity_hours` of the most recent room activity
|
||||
For `!mayhem`, install [Ollama](https://ollama.ai/), then build the custom model:
|
||||
|
||||
# TODO:
|
||||
- Rewrite in Rust
|
||||
- Rewrite in Zig
|
||||
- Rewrite in GO
|
||||
<s>- Lookback is being a PITA. Needs work </s>
|
||||
```bash
|
||||
ollama create mad_cat_man:latest -f customOllama/Modelfile
|
||||
```
|
||||
|
||||
Run Ollama on `localhost:11434`.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+19
@@ -0,0 +1,19 @@
|
||||
# Change name to config.yaml when all info is filled in.
|
||||
|
||||
username: "magbot"
|
||||
password: "Dont tek psswrd"
|
||||
homeserver: "https://matrix.dinguscloud.com" # Matrix homeserver the bot connects to
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # The Lunch
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400 # 400 messages
|
||||
dump_mode: False
|
||||
dbName: "my_db_name"
|
||||
|
||||
# --- Wait times (seconds) ---
|
||||
sync_timeout: 3000 # Initial sync timeout for scraper/user-list (ms)
|
||||
poll_interval: 30 # How often the scraper polls for new messages (s)
|
||||
command_sync_timeout: 30000 # Sync timeout when listening for chat commands (ms)
|
||||
post_interval: 0.5 # Delay between posting each suggestion (s)
|
||||
initial_sync_timeout: 300 # Initial sync timeout for post_results (ms)
|
||||
post_loop_interval: 1 # Delay between post_results loop iterations (s)
|
||||
@@ -0,0 +1,10 @@
|
||||
username: "magbot"
|
||||
password: "zC1b9qCKT8Cv0D"
|
||||
homeserver: "https://jupiterbroadcasting.com"
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
|
||||
#room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
dbName: ""
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
username: "magbot"
|
||||
password: "zC1b9qCKT8Cv0D"
|
||||
homeserver: "https://jupiterbroadcasting.com"
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
|
||||
room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
dbName: ""
|
||||
Executable → Regular
+13
-27
@@ -1,27 +1,13 @@
|
||||
# Change name to config.yaml when all info is filled in.
|
||||
|
||||
username: "magbot"
|
||||
password: "Dont tek psswrd"
|
||||
homeserver: "https://matrix.dinguscloud.com" # Matrix homeserver the bot connects to
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # The Lunch
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400 # 400 messages
|
||||
dump_mode: False
|
||||
dbName: "my_db_name"
|
||||
vote_host: "localhost" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
|
||||
# vote_path: "my-room-slug" # Optional URL path for this room's voting page (auto-derived from the room name if unset)
|
||||
vote_activity_hours: 72 # Hours a user must have been active within to be eligible to vote
|
||||
|
||||
# --- Wait times (seconds) ---
|
||||
sync_timeout: 3000 # Initial sync timeout for scraper/user-list (ms)
|
||||
poll_interval: 30 # How often the scraper polls for new messages (s)
|
||||
command_sync_timeout: 30000 # Sync timeout when listening for chat commands (ms)
|
||||
post_interval: 0.5 # Delay between posting each suggestion (s)
|
||||
initial_sync_timeout: 300 # Initial sync timeout for post_results (ms)
|
||||
post_loop_interval: 1 # Delay between post_results loop iterations (s)
|
||||
user_list_interval: 300 # How often the user-list service refreshes (s)
|
||||
user_list_retry_interval: 60 # Retry delay on user-list error (s)
|
||||
|
||||
# --- Feature toggles ---
|
||||
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
|
||||
# Copy this template to a separate .yaml or .yml file for each Matrix room.
|
||||
username: "@your-bot:matrix.org"
|
||||
password: "your_bot_password"
|
||||
homeserver: "https://matrix.org"
|
||||
room_id: "!your_room_id:matrix.org"
|
||||
lookback_limit: 7200
|
||||
batch_size: 100
|
||||
poll_interval: 30
|
||||
sync_timeout: 3000
|
||||
command_sync_timeout: 30000
|
||||
initial_sync_timeout: 300
|
||||
post_interval: 0.5
|
||||
post_loop_interval: 1
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
username: "magbot"
|
||||
password: "zC1b9qCKT8Cv0D"
|
||||
homeserver: "https://jupiterbroadcasting.com"
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
|
||||
#room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
dbName: ""
|
||||
# vote_host: "agmninex.online" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
|
||||
vote_host: "agmninex.online"
|
||||
vote_path: "lunch" # URL slug for this room's voting page (http://<vote_host>:9081/<vote_path>)
|
||||
vote_activity_hours: 6672 # Hours a user must have been active within to be eligible to vote
|
||||
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
username: "magbot"
|
||||
#password: "zC1b9qCKT8Cv0D"
|
||||
password: cathandle
|
||||
homeserver: "https://matrix.dinguscloud.com"
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
|
||||
#room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
# Da Lunch and The Main Chat each have their own config (daLunch.yaml, mainChat.yaml).
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
dbName: ""
|
||||
|
||||
# --- Wait times (seconds) ---
|
||||
sync_timeout: 3000 # Initial sync timeout for scraper/user-list (ms)
|
||||
poll_interval: 10 # How often the scraper polls for new messages (s)
|
||||
command_sync_timeout: 30000 # Sync timeout when listening for chat commands (ms)
|
||||
post_interval: 0.2 # Delay between posting each suggestion (s)
|
||||
initial_sync_timeout: 300 # Initial sync timeout for post_results (ms)
|
||||
post_loop_interval: 1 # Delay between post_results loop iterations (s)
|
||||
@@ -1,15 +0,0 @@
|
||||
username: "magbot"
|
||||
password: "zC1b9qCKT8Cv0D"
|
||||
homeserver: "https://jupiterbroadcasting.com"
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
|
||||
room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
dbName: ""
|
||||
# vote_host: "agmninex.online" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
|
||||
vote_host: "agmninex.online"
|
||||
vote_path: "jb-chat" # URL slug for this room's voting page (http://<vote_host>:9081/<vote_path>)
|
||||
vote_activity_hours: 6672 # Hours a user must have been active within to be eligible to vote
|
||||
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
username: "magbot"
|
||||
#password: "zC1b9qCKT8Cv0D"
|
||||
password: cathandle
|
||||
homeserver: "https://matrix.dinguscloud.com"
|
||||
#room_id: "!ajyOBbqoCIbBhEqNPU:matrix.org" # Magbot Testing Space
|
||||
#room_id: "!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com" # Da Lunch
|
||||
room_id: "!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com" # The Main Chat
|
||||
# Da Lunch and The Main Chat each have their own config (daLunch.yaml, mainChat.yaml).
|
||||
lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
dbName: ""
|
||||
|
||||
# --- Wait times (seconds) ---
|
||||
sync_timeout: 3000 # Initial sync timeout for scraper/user-list (ms)
|
||||
poll_interval: 10 # How often the scraper polls for new messages (s)
|
||||
command_sync_timeout: 30000 # Sync timeout when listening for chat commands (ms)
|
||||
post_interval: 0.2 # Delay between posting each suggestion (s)
|
||||
initial_sync_timeout: 300 # Initial sync timeout for post_results (ms)
|
||||
post_loop_interval: 1 # Delay between post_results loop iterations (s)
|
||||
@@ -10,10 +10,6 @@ lookback_seconds: 86400 # 24 hours
|
||||
lookback_limit: 400
|
||||
dump_mode: True
|
||||
dbName: ""
|
||||
# vote_host: "agmninex.online" # Hostname where voting interface is accessible (e.g., "localhost", "example.com", "vote.mysite.xyz")
|
||||
vote_host: "workstation"
|
||||
vote_path: "random" # URL slug for this room's voting page (http://<vote_host>:9081/<vote_path>)
|
||||
vote_activity_hours: 6672 # Hours a user must have been active within to be eligible to vote
|
||||
|
||||
# --- Wait times (seconds) ---
|
||||
sync_timeout: 3000 # Initial sync timeout for scraper/user-list (ms)
|
||||
@@ -22,8 +18,3 @@ command_sync_timeout: 30000 # Sync timeout when listening for chat commands (ms
|
||||
post_interval: 0.2 # Delay between posting each suggestion (s)
|
||||
initial_sync_timeout: 300 # Initial sync timeout for post_results (ms)
|
||||
post_loop_interval: 1 # Delay between post_results loop iterations (s)
|
||||
user_list_interval: 300 # How often the user-list service refreshes (s)
|
||||
user_list_retry_interval: 60 # Retry delay on user-list error (s)
|
||||
|
||||
# --- Feature toggles ---
|
||||
suggestion_announce_interval: 0 # Post a vote-beta announcement every N suggestions received (0 = disabled)
|
||||
@@ -1,286 +0,0 @@
|
||||
"""
|
||||
Python script that reads configs/config.yaml and scrapes a list of active users from the Matrix room.
|
||||
Stores the user list in a SQLite database named after the room.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
import re
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
import sys
|
||||
from nio import AsyncClient, LoginResponse, RoomMessageText, RoomMessagesResponse, SyncResponse
|
||||
from room import Room
|
||||
import dbutil
|
||||
|
||||
|
||||
class UserListManager:
|
||||
"""Manages collection and storage of active users in a Matrix room."""
|
||||
|
||||
def __init__(self, config_path="configs/config.yaml"):
|
||||
self.room = Room(config_path)
|
||||
self.users = []
|
||||
self.db_path = self.get_user_db_path()
|
||||
self.init_user_db()
|
||||
|
||||
def get_user_db_path(self):
|
||||
"""Generate database path for user list, similar to suggestions database."""
|
||||
safe = re.sub(r'[^a-zA-Z0-9_-]', '_', self.room.config.room_id)
|
||||
return f"users_{safe}.db"
|
||||
|
||||
def init_user_db(self):
|
||||
"""Initialize the user list database."""
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT UNIQUE,
|
||||
display_name TEXT,
|
||||
last_seen INTEGER,
|
||||
last_updated INTEGER,
|
||||
num_posts INTEGER DEFAULT 0,
|
||||
last_active INTEGER DEFAULT 0,
|
||||
UNIQUE(user_id)
|
||||
)
|
||||
''')
|
||||
# Add num_posts column if it doesn't exist (for existing databases)
|
||||
c.execute("PRAGMA table_info(users)")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
if 'num_posts' not in cols:
|
||||
c.execute('ALTER TABLE users ADD COLUMN num_posts INTEGER DEFAULT 0')
|
||||
if 'last_active' not in cols:
|
||||
c.execute('ALTER TABLE users ADD COLUMN last_active INTEGER DEFAULT 0')
|
||||
|
||||
# Table to track when user list was last fetched
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS fetch_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER,
|
||||
user_count INTEGER,
|
||||
datetime TEXT,
|
||||
most_recent_post INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
# Add most_recent_post column if it doesn't exist
|
||||
c.execute("PRAGMA table_info(fetch_log)")
|
||||
cols = [row[1] for row in c.fetchall()]
|
||||
if 'most_recent_post' not in cols:
|
||||
c.execute('ALTER TABLE fetch_log ADD COLUMN most_recent_post INTEGER DEFAULT 0')
|
||||
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
async def get_room_members(self):
|
||||
"""Fetch all members from the Matrix room and count their posts."""
|
||||
# Ensure we're logged in
|
||||
if not await self.room.login_to_matrix():
|
||||
print("Failed to login to Matrix")
|
||||
return []
|
||||
|
||||
# Sync to get room state
|
||||
assert self.room.client is not None
|
||||
sync_resp = await self.room.client.sync(timeout=self.room.config.sync_timeout)
|
||||
if not isinstance(sync_resp, SyncResponse):
|
||||
print(f"Sync failed: {sync_resp}")
|
||||
|
||||
# Get the room object
|
||||
room_obj = self.room.client.rooms.get(self.room.config.room_id)
|
||||
if not room_obj:
|
||||
print(f"Could not find room {self.room.config.room_id}")
|
||||
return []
|
||||
|
||||
# Count posts by fetching message history
|
||||
print("Counting posts per user (this may take a moment)...")
|
||||
post_counts, last_activity, most_recent_post = await self.count_user_posts()
|
||||
|
||||
users = []
|
||||
for user_id in room_obj.users:
|
||||
display_name = await self.room.get_display_name(user_id)
|
||||
users.append({
|
||||
'user_id': user_id,
|
||||
'display_name': display_name,
|
||||
'last_seen': int(time.time()),
|
||||
'num_posts': post_counts.get(user_id, 0),
|
||||
'last_active': last_activity.get(user_id, 0)
|
||||
})
|
||||
|
||||
self.users = users
|
||||
self.most_recent_post = most_recent_post
|
||||
return users
|
||||
|
||||
async def count_user_posts(self):
|
||||
"""Count total number of posts by each user in the room and track last activity."""
|
||||
post_counts = {}
|
||||
last_activity = {}
|
||||
most_recent_post = 0
|
||||
batch_size = self.room.config.batch_size
|
||||
lookback_limit = self.room.config.user_lookback_limit
|
||||
next_batch = None
|
||||
fetched = 0
|
||||
|
||||
# Fetch messages in batches to count posts per user
|
||||
assert self.room.client is not None
|
||||
while fetched < lookback_limit:
|
||||
try:
|
||||
response = await self.room.client.room_messages(
|
||||
room_id=self.room.config.room_id,
|
||||
start=next_batch,
|
||||
limit=batch_size
|
||||
)
|
||||
|
||||
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
|
||||
break
|
||||
|
||||
fetched += len(response.chunk)
|
||||
|
||||
for event in response.chunk:
|
||||
if isinstance(event, RoomMessageText):
|
||||
sender = event.sender
|
||||
post_counts[sender] = post_counts.get(sender, 0) + 1
|
||||
|
||||
# Track the most recent message timestamp for each user
|
||||
event_time = int(event.server_timestamp // 1000)
|
||||
if sender not in last_activity or event_time > last_activity[sender]:
|
||||
last_activity[sender] = event_time
|
||||
# Track the absolute most recent post in the room
|
||||
if event_time > most_recent_post:
|
||||
most_recent_post = event_time
|
||||
|
||||
next_batch = response.end
|
||||
|
||||
# Stop if we've reached the end or the lookback limit
|
||||
if not next_batch or fetched >= lookback_limit:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching messages: {e}")
|
||||
break
|
||||
|
||||
return post_counts, last_activity, most_recent_post
|
||||
|
||||
def write_users_to_db(self):
|
||||
"""Write collected users to the database."""
|
||||
if not self.users:
|
||||
print("No users to write to database")
|
||||
return
|
||||
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
current_time = int(time.time())
|
||||
|
||||
for user in self.users:
|
||||
try:
|
||||
c.execute('''
|
||||
INSERT INTO users (user_id, display_name, last_seen, last_updated, num_posts, last_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
last_seen = excluded.last_seen,
|
||||
last_updated = excluded.last_updated,
|
||||
num_posts = excluded.num_posts,
|
||||
last_active = excluded.last_active
|
||||
''', (
|
||||
user['user_id'],
|
||||
user['display_name'],
|
||||
user['last_seen'],
|
||||
current_time,
|
||||
user['num_posts'],
|
||||
user.get('last_active', 0)
|
||||
))
|
||||
except sqlite3.IntegrityError:
|
||||
# Update existing record
|
||||
c.execute('''
|
||||
UPDATE users
|
||||
SET display_name = ?, last_seen = ?, last_updated = ?, num_posts = ?, last_active = ?
|
||||
WHERE user_id = ?
|
||||
''', (
|
||||
user['display_name'],
|
||||
user['last_seen'],
|
||||
current_time,
|
||||
user['num_posts'],
|
||||
user.get('last_active', 0),
|
||||
user['user_id']
|
||||
))
|
||||
|
||||
# Log this fetch
|
||||
from datetime import datetime
|
||||
c.execute('''
|
||||
INSERT INTO fetch_log (timestamp, user_count, datetime, most_recent_post)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (current_time, len(self.users), datetime.now().isoformat(), getattr(self, 'most_recent_post', 0)))
|
||||
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
print(f"Wrote {len(self.users)} users to {self.db_path}")
|
||||
|
||||
def read_users_from_db(self):
|
||||
"""Read all users from the database."""
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT user_id, display_name, last_seen, last_updated, num_posts, last_active FROM users ORDER BY num_posts DESC, display_name')
|
||||
users = []
|
||||
for row in c.fetchall():
|
||||
users.append({
|
||||
'user_id': row[0],
|
||||
'display_name': row[1],
|
||||
'last_seen': row[2],
|
||||
'last_updated': row[3],
|
||||
'num_posts': row[4],
|
||||
'last_active': row[5] if len(row) > 5 else 0
|
||||
})
|
||||
conn.close()
|
||||
return users
|
||||
|
||||
async def close(self):
|
||||
"""Close Matrix client connection."""
|
||||
await self.room.close()
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main function to fetch and store user list continuously."""
|
||||
config_path = sys.argv[1] if len(sys.argv) > 1 else "configs/config.yaml"
|
||||
manager = UserListManager(config_path)
|
||||
|
||||
try:
|
||||
print("Starting user list collection service...")
|
||||
print("This will run continuously, updating the user list every 5 minutes.")
|
||||
|
||||
while True:
|
||||
try:
|
||||
print("\nFetching user list from Matrix room...")
|
||||
users = await manager.get_room_members()
|
||||
|
||||
if users:
|
||||
print(f"Found {len(users)} users in room")
|
||||
manager.write_users_to_db()
|
||||
|
||||
# Display top users by post count
|
||||
print("\nTop users by post count:")
|
||||
sorted_users = sorted(users, key=lambda u: u['num_posts'], reverse=True)
|
||||
for user in sorted_users[:10]: # Show top 10
|
||||
print(f" - {user['display_name']}: {user['num_posts']} posts")
|
||||
|
||||
if len(sorted_users) > 10:
|
||||
print(f" ... and {len(sorted_users) - 10} more users")
|
||||
else:
|
||||
print("No users found or could not connect to room")
|
||||
|
||||
# Wait 5 minutes before next update
|
||||
print("\nWaiting 5 minutes before next update...")
|
||||
await asyncio.sleep(manager.room.config.user_list_interval)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during user list update: {e}")
|
||||
print("Retrying in 1 minute...")
|
||||
await asyncio.sleep(manager.room.config.user_list_retry_interval) # Wait 1 minute on error before retry
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down user list service...")
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to monitor Matrix chat for !getvotes command and post vote results.
|
||||
Reads from voteServer/vote_summary.json and posts results ordered from least to most votes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time as _time
|
||||
from room import Room
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main function to monitor for !getvotes command and post results."""
|
||||
# Initialize room connection
|
||||
config_path = sys.argv[1] if len(sys.argv) > 1 else "configs/config.yaml"
|
||||
room = Room(config_path=config_path)
|
||||
|
||||
# Login to Matrix
|
||||
if not await room.login_to_matrix():
|
||||
print("Failed to login to Matrix")
|
||||
sys.exit(1)
|
||||
|
||||
print("Logged in and monitoring for !getvotes command...")
|
||||
print("Press Ctrl+C to exit")
|
||||
|
||||
# Add getvotes table to database if it doesn't exist
|
||||
init_getvotes_table(room)
|
||||
|
||||
# Start listening for commands from "now"
|
||||
since_ms = int(_time.time() * 1000)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for !getvotes command
|
||||
command = await wait_for_getvotes(room, since_ms)
|
||||
|
||||
if command:
|
||||
print("!getvotes command detected, posting vote results...")
|
||||
|
||||
# Clear the web poll by marking all pending suggestions as posted
|
||||
room.mark_submissions_posted()
|
||||
|
||||
# Read and format vote results
|
||||
vote_message = format_vote_results(room)
|
||||
|
||||
# Post to chat
|
||||
await room.alert_post(vote_message)
|
||||
|
||||
print("Vote results posted successfully")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
finally:
|
||||
await room.close()
|
||||
|
||||
|
||||
def init_getvotes_table(room):
|
||||
"""Initialize the getvotes tracking table in the database."""
|
||||
import dbutil
|
||||
conn = dbutil.connect(room.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS getvotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT,
|
||||
sender TEXT,
|
||||
timestamp INTEGER,
|
||||
datetime TEXT,
|
||||
UNIQUE(event_id),
|
||||
UNIQUE(sender, timestamp)
|
||||
)
|
||||
''')
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_last_getvotes_timestamp(room):
|
||||
"""Get the timestamp of the last !getvotes command for this room."""
|
||||
import dbutil
|
||||
conn = dbutil.connect(room.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
SELECT MAX(timestamp) FROM getvotes
|
||||
''')
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
return None
|
||||
|
||||
|
||||
def record_getvotes(room, sender, timestamp, event_id=None):
|
||||
"""Record a !getvotes detection; returns True if newly recorded (not duplicate)."""
|
||||
import dbutil
|
||||
import datetime
|
||||
|
||||
conn = dbutil.connect(room.db_path)
|
||||
c = conn.cursor()
|
||||
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
||||
c.execute('''
|
||||
INSERT OR IGNORE INTO getvotes (event_id, sender, timestamp, datetime)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (event_id, sender, int(timestamp), dt_str))
|
||||
inserted = c.rowcount == 1
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
return inserted
|
||||
|
||||
|
||||
async def wait_for_getvotes(room, since_ms):
|
||||
"""Wait for !getvotes command in the chat."""
|
||||
from nio import SyncResponse, RoomMessageText
|
||||
|
||||
sync_response = await room.client.sync(timeout=room.config.command_sync_timeout)
|
||||
if not isinstance(sync_response, SyncResponse):
|
||||
return None
|
||||
|
||||
room_info = sync_response.rooms.join.get(room.config.room_id)
|
||||
if not room_info or not hasattr(room_info, "timeline") or not hasattr(room_info.timeline, "events"):
|
||||
return None
|
||||
|
||||
for event in reversed(room_info.timeline.events):
|
||||
if isinstance(event, RoomMessageText):
|
||||
# Ignore old events (pre-listen timestamp)
|
||||
if getattr(event, "server_timestamp", 0) < since_ms:
|
||||
continue
|
||||
# Ignore our own messages
|
||||
if event.sender == room.user_id:
|
||||
continue
|
||||
|
||||
body = event.body.strip()
|
||||
|
||||
# Handle !getvotes
|
||||
if body == "!getvotes":
|
||||
event_id = getattr(event, "event_id", None)
|
||||
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
|
||||
if not record_getvotes(room, event.sender, ts_seconds, event_id):
|
||||
continue # Duplicate, skip
|
||||
return "getvotes"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_vote_results(room):
|
||||
"""Read votes from database and format results ordered from least to most votes."""
|
||||
import re
|
||||
|
||||
# Generate database name based on room ID (same pattern as suggestions db)
|
||||
safe = re.sub(r'[^a-zA-Z0-9_-]', '_', room.config.room_id)
|
||||
vote_db = f"votes_{safe}.db"
|
||||
|
||||
# Check if database exists
|
||||
if not os.path.exists(vote_db):
|
||||
return "❌ No vote data available yet. The voting system may not have received any votes."
|
||||
|
||||
try:
|
||||
import dbutil
|
||||
|
||||
# Read votes from database
|
||||
conn = dbutil.connect(vote_db)
|
||||
c = conn.cursor()
|
||||
|
||||
# Get all votes grouped by submission
|
||||
c.execute('''
|
||||
SELECT selected_submission, COUNT(*) as vote_count,
|
||||
GROUP_CONCAT(voter_display_name, ', ') as voters
|
||||
FROM votes
|
||||
GROUP BY selected_submission
|
||||
ORDER BY vote_count ASC
|
||||
''')
|
||||
|
||||
results = c.fetchall()
|
||||
|
||||
# Get total votes
|
||||
c.execute('SELECT COUNT(*) FROM votes')
|
||||
total_votes = c.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
|
||||
if not results or total_votes == 0:
|
||||
return "📊 No votes have been cast yet."
|
||||
|
||||
# Build the message
|
||||
lines = ["📊 Vote Results (ordered from least to most votes):", ""]
|
||||
|
||||
for submission, count, voters in results:
|
||||
vote_word = "vote" if count == 1 else "votes"
|
||||
lines.append(f"• {submission}: {count} {vote_word}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Total votes cast: {total_votes}")
|
||||
|
||||
# Find winner(s)
|
||||
max_votes = results[-1][1] if results else 0
|
||||
winners = [sub for sub, count, _ in results if count == max_votes]
|
||||
|
||||
if max_votes > 0:
|
||||
if len(winners) > 1:
|
||||
lines.append(f"🏆 Tie between {len(winners)} submissions with {max_votes} votes each!")
|
||||
else:
|
||||
lines.append(f"🏆 Current leader: {winners[0]} ({max_votes} votes)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
except Exception as e:
|
||||
return f"❌ Error reading vote data: {str(e)}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Binary file not shown.
@@ -1,8 +0,0 @@
|
||||
module jep
|
||||
|
||||
go 1.24.9
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,373 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type Submission struct {
|
||||
Submitter string `json:"submitter"` //Person who submitted this suggestion
|
||||
SubmissionTime int64 `json:"submission_time"` //Unix timestamp of submission
|
||||
Submission string `json:"submission"` //The actual suggestion text
|
||||
}
|
||||
|
||||
type SubmissionBuffer struct {
|
||||
Submissions []Submission
|
||||
Mtx sync.Mutex
|
||||
}
|
||||
|
||||
type UserMap map[string]User // [username]User struct
|
||||
|
||||
type UserData struct {
|
||||
Users map[string]User `json:"users"`
|
||||
MostRecentPost int64 `json:"most_recent_post"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
UserName string `json:"user_name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LastActive int64 `json:"last_active"`
|
||||
NumberOfPosts int `json:"number_of_posts"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
RoomID string `yaml:"room_id"`
|
||||
VotePath string `yaml:"vote_path"`
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
Config *Config
|
||||
Slug string
|
||||
SubBuffer *SubmissionBuffer
|
||||
UserBuffer *UserMap
|
||||
MostRecentPost *int64
|
||||
}
|
||||
|
||||
func loadConfig(configPath string) (*Config, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
err = yaml.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// Load one or more room configs. If a configs/ directory exists, load all
|
||||
// yaml files from it; otherwise fall back to the single ../config.yaml.
|
||||
func loadConfigs() ([]*Config, error) {
|
||||
var configs []*Config
|
||||
|
||||
files, err := filepath.Glob(filepath.Join("..", "configs", "*.yaml"))
|
||||
if err == nil && len(files) > 0 {
|
||||
for _, f := range files {
|
||||
if strings.Contains(filepath.Base(f), "example") {
|
||||
continue
|
||||
}
|
||||
c, err := loadConfig(f)
|
||||
if err != nil {
|
||||
log.Printf("Error loading config %s: %v", f, err)
|
||||
continue
|
||||
}
|
||||
configs = append(configs, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(configs) == 0 {
|
||||
c, err := loadConfig(filepath.Join("..", "config.yaml"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs = append(configs, c)
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// Load the slug map written by the bot (room_id -> slug). Falls back to a
|
||||
// deterministic slug derived from the room_id when a room isn't present.
|
||||
func loadRoomSlugs() map[string]string {
|
||||
data, err := os.ReadFile(filepath.Join("..", "room_slugs.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var slugs map[string]string
|
||||
if err := json.Unmarshal(data, &slugs); err != nil {
|
||||
return nil
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func slugify(s string) string {
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
} else if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
func roomSlug(config *Config, slugs map[string]string) string {
|
||||
if config.VotePath != "" {
|
||||
return config.VotePath
|
||||
}
|
||||
if slugs != nil {
|
||||
if slug, ok := slugs[config.RoomID]; ok && slug != "" {
|
||||
return slug
|
||||
}
|
||||
}
|
||||
return slugify(config.RoomID)
|
||||
}
|
||||
|
||||
func safeDBName(roomID string, prefix string) string {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||
safe := re.ReplaceAllString(roomID, "_")
|
||||
return fmt.Sprintf("%s_%s.db", prefix, safe)
|
||||
}
|
||||
|
||||
func main() {
|
||||
configs, err := loadConfigs()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configs: %v", err)
|
||||
}
|
||||
|
||||
slugs := loadRoomSlugs()
|
||||
|
||||
var rooms []*Room
|
||||
usedSlugs := make(map[string]bool)
|
||||
for _, config := range configs {
|
||||
slug := roomSlug(config, slugs)
|
||||
if usedSlugs[slug] {
|
||||
log.Printf("Skipping room %s: slug /%s already in use by another config", config.RoomID, slug)
|
||||
continue
|
||||
}
|
||||
usedSlugs[slug] = true
|
||||
|
||||
room := &Room{
|
||||
Config: config,
|
||||
Slug: slug,
|
||||
SubBuffer: &SubmissionBuffer{},
|
||||
UserBuffer: &UserMap{},
|
||||
MostRecentPost: new(int64),
|
||||
}
|
||||
rooms = append(rooms, room)
|
||||
|
||||
dbPath := safeDBName(config.RoomID, "suggestions")
|
||||
dbFullPath := filepath.Join("..", dbPath)
|
||||
go collectSubmissions(room, dbFullPath)
|
||||
go collectUserList(room)
|
||||
|
||||
http.HandleFunc("/"+room.Slug+"/subs", serveSubmissionList(room.SubBuffer))
|
||||
http.HandleFunc("/"+room.Slug+"/users", serveUserList(room))
|
||||
log.Printf("Serving room '%s' under /%s (db %s)", config.RoomID, room.Slug, dbFullPath)
|
||||
}
|
||||
|
||||
// Backward compatibility: a single room is also served at the root paths.
|
||||
if len(rooms) == 1 {
|
||||
http.HandleFunc("/subs", serveSubmissionList(rooms[0].SubBuffer))
|
||||
http.HandleFunc("/users", serveUserList(rooms[0]))
|
||||
}
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprintf(w, "jsonEndpoint running. Rooms: %s\n", strings.Join(roomSlugList(rooms), ", "))
|
||||
})
|
||||
|
||||
log.Println("jsonEndpoint starting on :9080")
|
||||
log.Fatal(http.ListenAndServe(":9080", nil))
|
||||
}
|
||||
|
||||
func roomSlugList(rooms []*Room) []string {
|
||||
var out []string
|
||||
for _, r := range rooms {
|
||||
out = append(out, r.Slug)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Serve a JSON endpoint with a json encoded user list for a room
|
||||
func serveUserList(room *Room) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := UserData{
|
||||
Users: *room.UserBuffer,
|
||||
MostRecentPost: *room.MostRecentPost,
|
||||
}
|
||||
|
||||
encErr := json.NewEncoder(w).Encode(response)
|
||||
if encErr != nil {
|
||||
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectUserList(room *Room) {
|
||||
usersDBPath := filepath.Join("..", safeDBName(room.Config.RoomID, "users"))
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Initial load
|
||||
loadUsers(usersDBPath, room)
|
||||
|
||||
for range ticker.C {
|
||||
loadUsers(usersDBPath, room)
|
||||
}
|
||||
}
|
||||
|
||||
func loadUsers(dbPath string, room *Room) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
log.Printf("Error opening database: %v", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Get the most recent post timestamp from fetch_log
|
||||
var recentPost int64
|
||||
err = db.QueryRow(`
|
||||
SELECT COALESCE(most_recent_post, 0)
|
||||
FROM fetch_log
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`).Scan(&recentPost)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
log.Printf("Error querying most recent post: %v", err)
|
||||
}
|
||||
*room.MostRecentPost = recentPost
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT user_id, display_name, last_seen, num_posts, COALESCE(last_active, 0) as last_active
|
||||
FROM users
|
||||
ORDER BY num_posts DESC
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Error querying users: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
newUsers := make(UserMap)
|
||||
|
||||
for rows.Next() {
|
||||
var userID, displayName string
|
||||
var lastSeen, numPosts int
|
||||
var lastActive int64
|
||||
|
||||
err := rows.Scan(&userID, &displayName, &lastSeen, &numPosts, &lastActive)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
newUsers[userID] = User{
|
||||
UserName: userID,
|
||||
DisplayName: displayName,
|
||||
LastActive: lastActive,
|
||||
NumberOfPosts: numPosts,
|
||||
LastSeen: int64(lastSeen),
|
||||
}
|
||||
}
|
||||
|
||||
*room.UserBuffer = newUsers
|
||||
log.Printf("Loaded %d users from database (most recent post: %d)", len(newUsers), recentPost)
|
||||
}
|
||||
|
||||
// Serve a JSON endpoint with a json encoded submission buffer
|
||||
func serveSubmissionList(buffer *SubmissionBuffer) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
buffer.Mtx.Lock()
|
||||
defer buffer.Mtx.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
encErr := json.NewEncoder(w).Encode(buffer)
|
||||
if encErr != nil {
|
||||
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectSubmissions(room *Room, dbFullPath string) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Initial load
|
||||
loadSubmissions(dbFullPath, room.SubBuffer)
|
||||
|
||||
for range ticker.C {
|
||||
loadSubmissions(dbFullPath, room.SubBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
func loadSubmissions(dbPath string, buffer *SubmissionBuffer) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
log.Printf("Error opening database: %v", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT user, timestamp, message
|
||||
FROM suggestions
|
||||
WHERE is_posted = 0
|
||||
ORDER BY timestamp DESC
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Error querying submissions: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var submissions []Submission
|
||||
|
||||
for rows.Next() {
|
||||
var submitter, submissionText string
|
||||
var submissionTime int64
|
||||
|
||||
err := rows.Scan(&submitter, &submissionTime, &submissionText)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
submissions = append(submissions, Submission{
|
||||
Submitter: submitter,
|
||||
SubmissionTime: submissionTime,
|
||||
Submission: submissionText,
|
||||
})
|
||||
}
|
||||
|
||||
buffer.Mtx.Lock()
|
||||
buffer.Submissions = submissions
|
||||
buffer.Mtx.Unlock()
|
||||
|
||||
log.Printf("Loaded %d submissions from database", len(submissions))
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# JSON ENDPOINT
|
||||
|
||||
This process provides json endpoints for user counts and vote suggestions.
|
||||
|
||||
If running on localhost, the two endpoints will be at localhost:9080/users and localhost:8080/subs. Base url should be altered for production.
|
||||
|
||||
/subs is where the submissions are stored
|
||||
/users is for usernames and post counts
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
go
|
||||
gcc
|
||||
sqlite
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export CGO_ENABLED=1
|
||||
echo "Go development environment ready with CGO support"
|
||||
'';
|
||||
}
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -48,6 +48,70 @@ On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
@@ -0,0 +1,47 @@
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
Error validating response: 'displayname' is a required property
|
||||
@@ -0,0 +1,64 @@
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
Error validating event: 'm.relates_to' is a required property
|
||||
|
||||
Failed validating 'required' in schema['properties']['content']:
|
||||
{'type': 'object',
|
||||
'properties': {'m.relates_to': {'type': 'object',
|
||||
'properties': {'rel_type': {'type': 'string',
|
||||
'const': 'm.annotation'},
|
||||
'event_id': {'type': 'string'},
|
||||
'key': {'type': 'string'}},
|
||||
'required': ['rel_type',
|
||||
'event_id',
|
||||
'key']}},
|
||||
'required': ['m.relates_to']}
|
||||
|
||||
On instance['content']:
|
||||
{}
|
||||
+16
-18
@@ -1,18 +1,16 @@
|
||||
2026/09/04 10:18:03 Serving room '!ajyOBbqoCIbBhEqNPU:matrix.org' under /random (db ../suggestions__ajyOBbqoCIbBhEqNPU_matrix_org.db)
|
||||
2026/09/04 10:18:03 jsonEndpoint starting on :9080
|
||||
2026/09/04 10:18:03 Loaded 4 users from database (most recent post: 1788534620)
|
||||
2026/09/04 10:18:03 Loaded 19 submissions from database
|
||||
2026/09/04 10:18:33 Loaded 19 submissions from database
|
||||
2026/09/04 10:18:33 Loaded 4 users from database (most recent post: 1788535088)
|
||||
2026/09/04 10:19:03 Loaded 0 submissions from database
|
||||
2026/09/04 10:19:03 Loaded 4 users from database (most recent post: 1788535088)
|
||||
2026/09/04 10:19:33 Loaded 0 submissions from database
|
||||
2026/09/04 10:19:33 Loaded 4 users from database (most recent post: 1788535088)
|
||||
2026/09/04 10:20:03 Loaded 0 submissions from database
|
||||
2026/09/04 10:20:03 Loaded 4 users from database (most recent post: 1788535088)
|
||||
2026/09/04 10:20:33 Loaded 0 submissions from database
|
||||
2026/09/04 10:20:33 Loaded 4 users from database (most recent post: 1788535088)
|
||||
2026/09/04 10:21:03 Loaded 0 submissions from database
|
||||
2026/09/04 10:21:03 Loaded 4 users from database (most recent post: 1788535088)
|
||||
2026/09/04 10:21:33 Loaded 0 submissions from database
|
||||
2026/09/04 10:21:33 Loaded 4 users from database (most recent post: 1788535088)
|
||||
2026/10/31 01:09:01 Serving room '!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com' under /random (db ../suggestions__HXiyYVEZOMRtkyvarN_jupiterbroadcasting_com.db)
|
||||
2026/10/31 01:09:01 Skipping room !gJYEKNllaubNlNkFIj:jupiterbroadcasting.com: slug /random already in use by another config
|
||||
2026/10/31 01:09:01 Skipping room !ajyOBbqoCIbBhEqNPU:matrix.org: slug /random already in use by another config
|
||||
2026/10/31 01:09:01 jsonEndpoint starting on :9080
|
||||
2026/10/31 01:09:01 Loaded 6 submissions from database
|
||||
2026/10/31 01:09:01 Loaded 216 users from database (most recent post: 1793426081)
|
||||
2026/10/31 01:09:31 Loaded 6 submissions from database
|
||||
2026/10/31 01:09:31 Loaded 216 users from database (most recent post: 1793426081)
|
||||
2026/10/31 01:10:01 Loaded 6 submissions from database
|
||||
2026/10/31 01:10:01 Loaded 216 users from database (most recent post: 1793426081)
|
||||
2026/10/31 01:10:31 Loaded 6 submissions from database
|
||||
2026/10/31 01:10:31 Loaded 216 users from database (most recent post: 1793426081)
|
||||
2026/10/31 01:11:01 Loaded 6 submissions from database
|
||||
2026/10/31 01:11:01 Loaded 216 users from database (most recent post: 1793426081)
|
||||
2026/10/31 01:11:31 Loaded 6 submissions from database
|
||||
2026/10/31 01:11:31 Loaded 216 users from database (most recent post: 1793426081)
|
||||
|
||||
+79
-37
@@ -1,39 +1,81 @@
|
||||
Logged in!
|
||||
Last run for this room was at UNIX timestamp 1788534952
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Stored 76 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1788535085
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Acked suggestion from magbot with ✅
|
||||
Stored 79 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1788535116
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1788535147
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1788535178
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1788535208
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1788535240
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 70 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1788535270
|
||||
Last run for this room was at UNIX timestamp 1793423882
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423894
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423904
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423915
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423925
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423936
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423947
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423957
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423968
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423978
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793423989
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424000
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424010
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424021
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424031
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424042
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424053
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424063
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424074
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424084
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424095
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424106
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424116
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424127
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424138
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424148
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424159
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424169
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424180
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424191
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424201
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424212
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424222
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424233
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424244
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424254
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424265
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424275
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424286
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424297
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
|
||||
@@ -0,0 +1,39 @@
|
||||
Logged in!
|
||||
Last run for this room was at UNIX timestamp 1793424612
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424626
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424637
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424647
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424658
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 37 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424668
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magnolia_mayhem 💀⃤ (Matrix Version) with ✅
|
||||
Stored 38 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424679
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magnolia_mayhem 💀⃤ (Matrix Version) with ✅
|
||||
Acked suggestion from magnolia_mayhem 💀⃤ (Matrix Version) with ✅
|
||||
Stored 40 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424690
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Acked suggestion from magnolia_mayhem 💀⃤ (Matrix Version) with ✅
|
||||
Stored 41 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424700
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424711
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424722
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424732
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424743
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424753
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424764
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424775
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 41 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793424785
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
|
||||
@@ -0,0 +1,33 @@
|
||||
Logged in!
|
||||
Last run for this room was at UNIX timestamp 1793426929
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426941
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426952
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426962
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426973
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426984
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426994
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427005
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427015
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427026
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427037
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427047
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427058
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427068
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427079
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 22 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427090
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
|
||||
@@ -0,0 +1,33 @@
|
||||
Logged in!
|
||||
Last run for this room was at UNIX timestamp 1793426930
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426941
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426952
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426962
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426973
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426984
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426994
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427005
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427016
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427026
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427037
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427048
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427058
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427069
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427079
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 1 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427090
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
|
||||
@@ -0,0 +1,33 @@
|
||||
Logged in!
|
||||
Last run for this room was at UNIX timestamp 1793426930
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426942
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426952
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426963
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426973
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426984
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793426995
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427005
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427016
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427026
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427037
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427048
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427058
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427069
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427080
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍Stored 25 suggestions in database.
|
||||
Last run for this room was at UNIX timestamp 1793427090
|
||||
Looked back on 100 messages...␍Looked back on 200 messages...␍Looked back on 300 messages...␍Looked back on 400 messages...␍
|
||||
+16
-18
@@ -1,18 +1,16 @@
|
||||
2026/09/04 10:18:06 Vote server serving room '!ajyOBbqoCIbBhEqNPU:matrix.org' under /random (db ../votes__ajyOBbqoCIbBhEqNPU_matrix_org.db)
|
||||
2026/09/04 10:18:06 Vote server starting on :9081
|
||||
2026/09/04 10:18:06 Fetched 4 users (most recent post: 1788534620)
|
||||
2026/09/04 10:18:06 Fetched 19 submissions
|
||||
2026/09/04 10:18:37 Fetched 4 users (most recent post: 1788535088)
|
||||
2026/09/04 10:18:37 Fetched 19 submissions
|
||||
2026/09/04 10:19:07 Fetched 0 submissions
|
||||
2026/09/04 10:19:07 Fetched 4 users (most recent post: 1788535088)
|
||||
2026/09/04 10:19:37 Fetched 0 submissions
|
||||
2026/09/04 10:19:37 Fetched 4 users (most recent post: 1788535088)
|
||||
2026/09/04 10:20:07 Fetched 0 submissions
|
||||
2026/09/04 10:20:07 Fetched 4 users (most recent post: 1788535088)
|
||||
2026/09/04 10:20:37 Fetched 0 submissions
|
||||
2026/09/04 10:20:37 Fetched 4 users (most recent post: 1788535088)
|
||||
2026/09/04 10:21:07 Fetched 0 submissions
|
||||
2026/09/04 10:21:07 Fetched 4 users (most recent post: 1788535088)
|
||||
2026/09/04 10:21:37 Fetched 4 users (most recent post: 1788535088)
|
||||
2026/09/04 10:21:37 Fetched 0 submissions
|
||||
2026/10/31 01:09:04 Vote server serving room '!HXiyYVEZOMRtkyvarN:jupiterbroadcasting.com' under /random (db ../votes__HXiyYVEZOMRtkyvarN_jupiterbroadcasting_com.db)
|
||||
2026/10/31 01:09:04 Skipping room !gJYEKNllaubNlNkFIj:jupiterbroadcasting.com: slug /random already in use by another config
|
||||
2026/10/31 01:09:04 Skipping room !ajyOBbqoCIbBhEqNPU:matrix.org: slug /random already in use by another config
|
||||
2026/10/31 01:09:04 Vote server starting on :9081
|
||||
2026/10/31 01:09:04 Fetched 6 submissions
|
||||
2026/10/31 01:09:04 Fetched 216 users (most recent post: 1793426081)
|
||||
2026/10/31 01:09:34 Fetched 6 submissions
|
||||
2026/10/31 01:09:34 Fetched 216 users (most recent post: 1793426081)
|
||||
2026/10/31 01:10:04 Fetched 6 submissions
|
||||
2026/10/31 01:10:04 Fetched 216 users (most recent post: 1793426081)
|
||||
2026/10/31 01:10:34 Fetched 6 submissions
|
||||
2026/10/31 01:10:34 Fetched 216 users (most recent post: 1793426081)
|
||||
2026/10/31 01:11:04 Fetched 6 submissions
|
||||
2026/10/31 01:11:04 Fetched 216 users (most recent post: 1793426081)
|
||||
2026/10/31 01:11:34 Fetched 6 submissions
|
||||
2026/10/31 01:11:34 Fetched 216 users (most recent post: 1793426081)
|
||||
|
||||
+4
-33
@@ -1,31 +1,10 @@
|
||||
import yaml
|
||||
import re
|
||||
import asyncio
|
||||
import sys
|
||||
from nio import AsyncClient, LoginResponse, RoomSendResponse, RoomMessageText, SyncResponse
|
||||
import time
|
||||
from post import Post
|
||||
|
||||
from room import Room
|
||||
import dbutil
|
||||
|
||||
|
||||
def get_last_getvotes_timestamp(room):
|
||||
"""Get the timestamp of the last !getvotes command for this room."""
|
||||
conn = dbutil.connect(room.db_path)
|
||||
c = conn.cursor()
|
||||
# Check if getvotes table exists
|
||||
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='getvotes'")
|
||||
if not c.fetchone():
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
c.execute('SELECT MAX(timestamp) FROM getvotes')
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
return None
|
||||
|
||||
async def main():
|
||||
config_path = sys.argv[1] if len(sys.argv) > 1 else "configs/config.yaml"
|
||||
room = Room(config_path)
|
||||
@@ -37,29 +16,21 @@ async def main():
|
||||
# Initial sync establishes the next-batch; after this, listen from "now"
|
||||
assert room.client is not None
|
||||
await room.client.sync(timeout=room.config.initial_sync_timeout)
|
||||
room.write_room_slugs()
|
||||
|
||||
try:
|
||||
while True:
|
||||
listen_since_ms = int(time.time() * 1000)
|
||||
|
||||
# Get the timestamp of the last !getvotes command
|
||||
last_getvotes = get_last_getvotes_timestamp(room)
|
||||
if last_getvotes:
|
||||
print(f"Last !getvotes was at UNIX timestamp {last_getvotes}")
|
||||
else:
|
||||
print("No previous !getvotes found - will show all unposted suggestions")
|
||||
|
||||
# Wait for !gettitles before posting
|
||||
await room.wait_for_gettitles(since_ms=listen_since_ms)
|
||||
|
||||
# Then read and post suggestions created since last !getvotes
|
||||
room.read_db(since_timestamp=last_getvotes, only_unposted=True)
|
||||
# Results commands leave the pending queue untouched.
|
||||
room.read_db(only_unposted=True)
|
||||
if room.posts:
|
||||
await room.post_suggestions_individually()
|
||||
room.update_last_post()
|
||||
else:
|
||||
await room.alert_post("No new unposted suggestions found.")
|
||||
await room.alert_post("No new title suggestions posted.")
|
||||
await asyncio.sleep(room.config.post_loop_interval)
|
||||
finally:
|
||||
await room.close()
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
matrix-nio
|
||||
pyyaml
|
||||
requests
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
matrix-nio
|
||||
pyyaml
|
||||
requests
|
||||
|
||||
@@ -11,6 +11,7 @@ import sqlite3
|
||||
import re
|
||||
import asyncio
|
||||
from nio import AsyncClient, LoginResponse, MessageDirection, ProfileGetDisplayNameResponse, RoomMessageText, RoomMessagesResponse, RoomSendResponse, SyncResponse
|
||||
from nio.api import RelationshipType
|
||||
from post import Post
|
||||
import requests
|
||||
import dbutil
|
||||
@@ -30,20 +31,13 @@ class Config:
|
||||
self.lookback_limit = config.get("lookback_limit", 3600 * 2)
|
||||
self.db_name = config.get("dbName", "suggestions.db")
|
||||
self.batch_size = config.get("batch_size", 100)
|
||||
self.user_lookback_limit = config.get("user_lookback_limit", 5000)
|
||||
self.homeserver = config.get("homeserver", "https://matrix.org")
|
||||
self.vote_host = config.get("vote_host", "localhost")
|
||||
self.vote_activity_hours = config.get("vote_activity_hours", 72)
|
||||
self.vote_path = config.get("vote_path", "")
|
||||
self.sync_timeout = config.get("sync_timeout", 3000)
|
||||
self.poll_interval = config.get("poll_interval", 30)
|
||||
self.command_sync_timeout = config.get("command_sync_timeout", 30000)
|
||||
self.post_interval = config.get("post_interval", 0.5)
|
||||
self.initial_sync_timeout = config.get("initial_sync_timeout", 300)
|
||||
self.post_loop_interval = config.get("post_loop_interval", 1)
|
||||
self.user_list_interval = config.get("user_list_interval", 300)
|
||||
self.user_list_retry_interval = config.get("user_list_retry_interval", 60)
|
||||
self.suggestion_announce_interval = config.get("suggestion_announce_interval", 0)
|
||||
|
||||
class Room:
|
||||
def __init__(self, config_path="configs/config.yaml", search_terms=None, homeserver=None):
|
||||
@@ -68,7 +62,6 @@ class Room:
|
||||
self.banned_db_path = "banned_users.db"
|
||||
self.init_db()
|
||||
self.init_banned_db()
|
||||
self.init_suggestion_counter_db()
|
||||
|
||||
|
||||
def safe_db_name(self, room_id):
|
||||
@@ -140,21 +133,36 @@ class Room:
|
||||
)
|
||||
''')
|
||||
|
||||
# New: table to log all !voteurl detections (prevents duplicates)
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS voteurl (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT,
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS clear_commands (
|
||||
event_id TEXT UNIQUE,
|
||||
sender TEXT,
|
||||
timestamp INTEGER,
|
||||
datetime TEXT,
|
||||
UNIQUE(event_id),
|
||||
UNIQUE(sender, timestamp)
|
||||
)
|
||||
''')
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def clear_suggestions(self, sender, timestamp, event_id=None):
|
||||
"""Retire pending suggestions once per command; return their count."""
|
||||
conn = dbutil.connect(self.db_path)
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"INSERT OR IGNORE INTO clear_commands (event_id, sender, timestamp) VALUES (?, ?, ?)",
|
||||
(event_id, sender, timestamp),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
cursor = conn.execute(
|
||||
"UPDATE suggestions SET is_posted = 1 WHERE is_posted = 0"
|
||||
)
|
||||
count = cursor.rowcount
|
||||
dbutil.commit(conn)
|
||||
return count
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def init_banned_db(self):
|
||||
"""Initialize the shared banned-users database (primary key + user string)."""
|
||||
conn = dbutil.connect(self.banned_db_path)
|
||||
@@ -195,47 +203,6 @@ class Room:
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def init_suggestion_counter_db(self):
|
||||
"""Initialize the suggestion counter table for periodic announcements."""
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS suggestion_counter (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
count INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
_ = c.execute('INSERT OR IGNORE INTO suggestion_counter (id, count) VALUES (1, 0)')
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def get_suggestion_counter(self):
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute('SELECT count FROM suggestion_counter WHERE id = 1')
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
return row[0] if row else 0
|
||||
|
||||
def increment_suggestion_counter(self, amount=1):
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute('UPDATE suggestion_counter SET count = count + ? WHERE id = 1', (amount,))
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
return self.get_suggestion_counter()
|
||||
|
||||
async def check_and_announce_vote_beta(self, new_count):
|
||||
"""Post a beta announcement if the suggestion count hits the configured interval."""
|
||||
interval = self.config.suggestion_announce_interval
|
||||
if interval <= 0:
|
||||
return
|
||||
if new_count > 0 and new_count % interval == 0:
|
||||
url = self.vote_url()
|
||||
await self.alert_post(
|
||||
f"Psst! HTTP voting is now in beta! You can vote on titles at:\n{url}"
|
||||
)
|
||||
|
||||
async def reject_banned_suggestion(self, post):
|
||||
"""Reply to a banned user's suggestion and do not insert it."""
|
||||
await self.alert_post(f"{post.display_name}: this user has been banned from submitting titles")
|
||||
@@ -276,46 +243,6 @@ class Room:
|
||||
self._display_name_cache[user_id] = name
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def slugify(text):
|
||||
text = re.sub(r'[^a-z0-9]+', '-', str(text).lower()).strip('-')
|
||||
return text
|
||||
|
||||
def get_room_slug(self):
|
||||
"""Resolve a URL-safe slug for this room (config override, then room name, then alias)."""
|
||||
if self.config.vote_path:
|
||||
return self.config.vote_path
|
||||
slug = None
|
||||
if self.client:
|
||||
room = self.client.rooms.get(self.config.room_id)
|
||||
if room:
|
||||
slug = getattr(room, "name", None) or getattr(room, "canonical_alias", None)
|
||||
if not slug:
|
||||
slug = self.config.room_id
|
||||
self.config.vote_path = self.slugify(slug)
|
||||
return self.config.vote_path
|
||||
|
||||
def write_room_slugs(self):
|
||||
"""Persist the resolved slug so the Go voting services can route by path."""
|
||||
import json
|
||||
import os
|
||||
slug = self.get_room_slug()
|
||||
path = "room_slugs.json"
|
||||
data = {}
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
data = {}
|
||||
data[self.config.room_id] = slug
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True)
|
||||
|
||||
def vote_url(self):
|
||||
"""Build the full voting URL for this room."""
|
||||
return f"http://{self.config.vote_host}:9081/{self.get_room_slug()}"
|
||||
|
||||
async def get_convos(self, limit=None):
|
||||
if limit is None:
|
||||
limit = self.config.lookback_limit
|
||||
@@ -519,120 +446,94 @@ class Room:
|
||||
conn.close()
|
||||
return inserted
|
||||
|
||||
# New: record a !voteurl detection; returns True if newly recorded (not duplicate)
|
||||
def record_voteurl(self, sender, timestamp, event_id=None):
|
||||
"""Record a !voteurl detection; returns True if newly recorded (not duplicate)"""
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
dt_str = datetime.datetime.fromtimestamp(int(timestamp)).isoformat(sep=' ', timespec='seconds')
|
||||
_ = c.execute('''
|
||||
INSERT OR IGNORE INTO voteurl (event_id, sender, timestamp, datetime)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (event_id, sender, int(timestamp), dt_str))
|
||||
inserted = c.rowcount == 1
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
return inserted
|
||||
|
||||
async def count_votes(self):
|
||||
"""Count upvotes for each suggestion and return formatted results."""
|
||||
# Get recent messages to find suggestion posts and their reactions
|
||||
batch_size = self.config.batch_size
|
||||
"""Read current thumbs-up votes for the most recent voting round."""
|
||||
assert self.client is not None
|
||||
next_batch = None
|
||||
suggestion_votes = {}
|
||||
all_events = []
|
||||
assert self.client is not None
|
||||
seen_tokens = set()
|
||||
found_round = False
|
||||
|
||||
# First pass: collect all recent events (both messages and reactions)
|
||||
for _ in range(15): # Look back further to catch more events
|
||||
# History is newest first. The latest voting announcement bounds the
|
||||
# round, regardless of how long people have continued reacting to it.
|
||||
while True:
|
||||
response = await self.client.room_messages(
|
||||
self.config.room_id,
|
||||
start=next_batch,
|
||||
limit=batch_size,
|
||||
direction=MessageDirection.back
|
||||
limit=self.config.batch_size,
|
||||
direction=MessageDirection.back,
|
||||
)
|
||||
|
||||
if not isinstance(response, RoomMessagesResponse) or not response.chunk:
|
||||
if not isinstance(response, RoomMessagesResponse):
|
||||
return "Unable to read voting history. Please try !countvotes again."
|
||||
if not response.chunk:
|
||||
break
|
||||
for event in response.chunk:
|
||||
if (isinstance(event, RoomMessageText)
|
||||
and event.sender == self.user_id
|
||||
and event.body.strip().startswith("Time to vote!")):
|
||||
found_round = True
|
||||
break
|
||||
if isinstance(event, RoomMessageText) and event.sender == self.user_id:
|
||||
match = re.match(r"^Suggestion (\d+):\s*(.*)", event.body, re.DOTALL)
|
||||
event_id = getattr(event, "event_id", None)
|
||||
if match and event_id:
|
||||
suggestion_votes[event_id] = {
|
||||
"number": int(match[1]),
|
||||
"content": match[2].strip(),
|
||||
"voters": set(),
|
||||
}
|
||||
if found_round:
|
||||
break
|
||||
|
||||
all_events.extend(response.chunk)
|
||||
next_batch = getattr(response, "end", None)
|
||||
if not next_batch:
|
||||
if not next_batch or next_batch in seen_tokens:
|
||||
break
|
||||
|
||||
# Second pass: find suggestion posts from our bot
|
||||
for event in all_events:
|
||||
if isinstance(event, RoomMessageText):
|
||||
body = event.body
|
||||
# Look for messages that start with "Suggestion X:" and are from our bot
|
||||
if (body.startswith("Suggestion ") and ":" in body and
|
||||
event.sender == self.user_id):
|
||||
try:
|
||||
# Extract suggestion number and content
|
||||
parts = body.split(":", 2)
|
||||
if len(parts) >= 2:
|
||||
suggestion_num = parts[0].replace("Suggestion ", "").strip()
|
||||
content = ":".join(parts[1:]).strip()
|
||||
event_id = getattr(event, "event_id", None)
|
||||
|
||||
if event_id:
|
||||
suggestion_votes[suggestion_num] = {
|
||||
'content': content,
|
||||
'event_id': event_id,
|
||||
'votes': 0,
|
||||
'voters': set()
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error parsing suggestion: {e}")
|
||||
continue
|
||||
seen_tokens.add(next_batch)
|
||||
|
||||
if not suggestion_votes:
|
||||
return "No suggestion posts found to count votes for."
|
||||
return "No suggestion posts found for the latest voting round."
|
||||
|
||||
# Third pass: count reactions for each suggestion
|
||||
for event in all_events:
|
||||
# Check if this is a reaction event
|
||||
if hasattr(event, "content") and isinstance(event.content, dict):
|
||||
relates_to = event.content.get("m.relates_to", {})
|
||||
if (relates_to.get("rel_type") == "m.annotation" and
|
||||
relates_to.get("key") == "👍"):
|
||||
|
||||
target_event_id = relates_to.get("event_id")
|
||||
# Fetch the live relations for each suggestion rather than assuming
|
||||
# reactions are included in the room-history pages used above.
|
||||
try:
|
||||
for event_id, data in suggestion_votes.items():
|
||||
async for event in self.client.room_get_event_relations(
|
||||
self.config.room_id,
|
||||
event_id,
|
||||
rel_type=RelationshipType.annotation,
|
||||
event_type="m.reaction",
|
||||
limit=self.config.batch_size,
|
||||
):
|
||||
source = getattr(event, "source", {})
|
||||
if source.get("unsigned", {}).get("redacted_because"):
|
||||
continue
|
||||
relation = source.get("content", {}).get("m.relates_to", {})
|
||||
key = relation.get("key", "")
|
||||
# Treat the usual presentation/skin-tone variants as the
|
||||
# same thumbs-up vote that a user sees in their client.
|
||||
key = key.replace("\ufe0f", "")
|
||||
key = re.sub("[\U0001f3fb-\U0001f3ff]", "", key)
|
||||
if (relation.get("rel_type") != "m.annotation"
|
||||
or relation.get("event_id") != event_id or key != "👍"):
|
||||
continue
|
||||
sender = getattr(event, "sender", None)
|
||||
if sender and sender != self.user_id:
|
||||
data["voters"].add(sender)
|
||||
except Exception as error:
|
||||
print(f"Unable to fetch emoji voting reactions: {error}")
|
||||
return "Unable to read emoji votes. Please try !countvotes again."
|
||||
|
||||
# Find which suggestion this reaction is for
|
||||
for suggestion_num, data in suggestion_votes.items():
|
||||
if (data['event_id'] == target_event_id and
|
||||
sender and sender not in data['voters'] and
|
||||
sender != self.user_id): # Don't count bot's own reactions
|
||||
data['voters'].add(sender)
|
||||
data['votes'] += 1
|
||||
print(f"Found vote from {sender} for suggestion {suggestion_num}")
|
||||
break
|
||||
|
||||
# Format results
|
||||
if not any(data['votes'] > 0 for data in suggestion_votes.values()):
|
||||
return "No votes found for any suggestions."
|
||||
|
||||
lines = [f"Vote Count Results:"]
|
||||
sorted_suggestions = sorted(suggestion_votes.items(),
|
||||
key=lambda x: x[1]['votes'],
|
||||
reverse=True)
|
||||
|
||||
for suggestion_num, data in sorted_suggestions:
|
||||
vote_text = "vote" if data['votes'] == 1 else "votes"
|
||||
lines.append(f"Suggestion {suggestion_num}: {data['votes']} {vote_text}")
|
||||
# Truncate long suggestions for readability
|
||||
content = data['content']
|
||||
if len(content) > 80:
|
||||
content = content[:77] + "..."
|
||||
lines.append(f" └─ {content}")
|
||||
|
||||
lines = ["Latest voting round — current emoji votes (most to fewest):"]
|
||||
ranked = sorted(suggestion_votes.values(),
|
||||
key=lambda data: (-len(data["voters"]), data["number"]))
|
||||
for data in ranked:
|
||||
count = len(data["voters"])
|
||||
vote_text = "vote" if count == 1 else "votes"
|
||||
lines.append(f"Suggestion {data['number']}: {count} {vote_text}")
|
||||
lines.append(f" └─ {data['content']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Renamed and expanded to handle both !gettitles and !mayhem commands
|
||||
async def wait_for_commands(self, since_ms=None):
|
||||
"""Wait for and handle commands like !gettitles and !mayhem"""
|
||||
"""Handle room commands until !gettitles requests a voting round."""
|
||||
# If no since_ms provided, start listening from "now"
|
||||
if since_ms is None:
|
||||
since_ms = int(_time.time() * 1000)
|
||||
@@ -646,7 +547,7 @@ class Room:
|
||||
room_info = sync_response.rooms.join.get(self.config.room_id)
|
||||
if not room_info or not hasattr(room_info, "timeline") or not hasattr(room_info.timeline, "events"):
|
||||
continue
|
||||
for event in reversed(room_info.timeline.events):
|
||||
for event in room_info.timeline.events:
|
||||
if isinstance(event, RoomMessageText):
|
||||
# Ignore old events (pre-listen timestamp)
|
||||
if getattr(event, "server_timestamp", 0) < since_ms:
|
||||
@@ -656,8 +557,19 @@ class Room:
|
||||
continue
|
||||
body = event.body.strip()
|
||||
|
||||
if body == "!clear":
|
||||
count = self.clear_suggestions(
|
||||
event.sender,
|
||||
int(getattr(event, "server_timestamp", 0) // 1000),
|
||||
getattr(event, "event_id", None),
|
||||
)
|
||||
if count is not None:
|
||||
await self.alert_post(
|
||||
f"Cleared {count} pending title suggestions. They will not be posted for voting."
|
||||
)
|
||||
|
||||
# Handle !gettitles
|
||||
if body == "!gettitles":
|
||||
elif body == "!gettitles":
|
||||
event_id = getattr(event, "event_id", None)
|
||||
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
|
||||
if not self.record_gettitles(event.sender, ts_seconds, event_id):
|
||||
@@ -673,30 +585,20 @@ class Room:
|
||||
await self.alert_post("Standby. Ollama is being instructed to generate mayhem. This may take a moment...")
|
||||
await self.alert_post(self.generate_mayhem())
|
||||
|
||||
# Handle !countvotes
|
||||
elif body == "!countvotes":
|
||||
# Both result commands read current emoji votes.
|
||||
elif body in ("!countvotes", "!getvotes"):
|
||||
event_id = getattr(event, "event_id", None)
|
||||
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
|
||||
if not self.record_countvotes(event.sender, ts_seconds, event_id):
|
||||
continue
|
||||
print("!countvotes detected")
|
||||
print(f"{body} detected")
|
||||
await self.alert_post("Counting votes... This may take a moment...")
|
||||
vote_results = await self.count_votes()
|
||||
await self.alert_post(vote_results)
|
||||
|
||||
# Handle !voteurl
|
||||
elif body == "!voteurl":
|
||||
event_id = getattr(event, "event_id", None)
|
||||
ts_seconds = int(getattr(event, "server_timestamp", 0) // 1000)
|
||||
if not self.record_voteurl(event.sender, ts_seconds, event_id):
|
||||
continue
|
||||
print("!voteurl detected")
|
||||
vote_message = f"🗳️ Cast your vote here: {self.vote_url()}\n\nYou must have been active in the room within {self.config.vote_activity_hours} hours of the most recent activity to vote. Enter your Matrix display name exactly as it appears in the room."
|
||||
await self.alert_post(vote_message)
|
||||
|
||||
# Keep the old method name for backward compatibility but mark as deprecated
|
||||
async def wait_for_gettitles(self, since_ms=None, exact=True):
|
||||
"""DEPRECATED: Use wait_for_commands() instead. This method only handles !gettitles."""
|
||||
"""Handle room commands until !gettitles requests suggestion posting."""
|
||||
result = await self.wait_for_commands(since_ms)
|
||||
return result == "gettitles"
|
||||
|
||||
@@ -798,7 +700,7 @@ class Room:
|
||||
# Filter out already posted suggestions
|
||||
unposted_posts = [post for post in self.posts if not post.is_posted]
|
||||
if not unposted_posts:
|
||||
await self.alert_post("No new unposted suggestions found.")
|
||||
await self.alert_post("No new title suggestions posted.")
|
||||
return
|
||||
|
||||
# Temporarily set posts to only unposted ones for formatting
|
||||
@@ -834,17 +736,23 @@ class Room:
|
||||
assert self.client is not None
|
||||
unposted_posts = [post for post in self.posts if not post.is_posted]
|
||||
if not unposted_posts:
|
||||
await self.alert_post("No new unposted suggestions found.")
|
||||
await self.alert_post("No new title suggestions posted.")
|
||||
return
|
||||
|
||||
await self.alert_post(f"""
|
||||
await self.alert_post("""
|
||||
Time to vote! 🚢🚢🚢
|
||||
|
||||
Each title is a separate post below. Tap the 👍 reaction on the one you like to cast your vote!
|
||||
|
||||
May the odds be ever in your favor! 🏹🏹🏹
|
||||
|
||||
NOTE: HTTP VOTING IS IN PRE-ALPHA. IF YOU WOULD LIKE TO SEE THE EARLY PRE-RELEASE, GO TO {self.vote_url()}
|
||||
COMMANDS:
|
||||
!suggest <title> — Submit a title suggestion.
|
||||
!gettitles — Post pending suggestions for voting.
|
||||
!countvotes — Show the latest round's current 👍 vote rankings.
|
||||
!getvotes — Show the same current emoji results as !countvotes.
|
||||
!clear — Clear pending suggestions from future voting rounds.
|
||||
!mayhem — Generate chaotic text with Ollama AI.
|
||||
|
||||
MAKE MAGBOT BETTER:
|
||||
GIT REPO: https://magbot.online ⬅️⬅️⬅️ PRs welcome!
|
||||
@@ -881,6 +789,9 @@ class Room:
|
||||
print(f"Failed to post suggestion {idx}: {resp}")
|
||||
continue
|
||||
|
||||
self.mark_posts_as_posted([post])
|
||||
post.is_posted = True
|
||||
|
||||
reaction = {
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
@@ -913,13 +824,6 @@ class Room:
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
def mark_submissions_posted(self):
|
||||
conn = dbutil.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
_ = c.execute("UPDATE suggestions SET is_posted = 1 WHERE is_posted = 0")
|
||||
dbutil.commit(conn)
|
||||
conn.close()
|
||||
|
||||
async def close(self):
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"!ajyOBbqoCIbBhEqNPU:matrix.org": "random",
|
||||
"!gJYEKNllaubNlNkFIj:jupiterbroadcasting.com": "jb-chat"
|
||||
}
|
||||
+54
-235
@@ -2,7 +2,6 @@
|
||||
|
||||
# TitleBot-NG - Master Launch Script
|
||||
# Starts all components of the TitleBot system
|
||||
# THIS SCRIPT WAS CREATED VIA BLACKBOX AI -- HUMANIZATION REQUIRED
|
||||
|
||||
set -e
|
||||
|
||||
@@ -25,88 +24,6 @@ detect_os() {
|
||||
OS=$(detect_os)
|
||||
echo -e "${BLUE}Detected OS: $OS${NC}"
|
||||
|
||||
# Ensure Go is installed, attempting native package manager install if missing
|
||||
ensure_go() {
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}Go found: $(go version)${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Go not found. Attempting to install via native package manager...${NC}"
|
||||
|
||||
local pm=""
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
pm="apt"
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
pm="pacman"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
pm="dnf"
|
||||
elif command -v pkg >/dev/null 2>&1 && [ "$OS" = "freebsd" ]; then
|
||||
pm="pkg"
|
||||
fi
|
||||
|
||||
if [ -z "$pm" ]; then
|
||||
echo -e "${RED}Error: No supported package manager found (apt/pacman/dnf/pkg).${NC}"
|
||||
echo -e "${YELLOW}Please install Go manually: https://go.dev/dl/${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local sudo_cmd=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo_cmd="sudo"
|
||||
else
|
||||
echo -e "${RED}Error: Need root privileges to install Go but sudo is unavailable.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$pm" in
|
||||
apt)
|
||||
$sudo_cmd apt-get update
|
||||
$sudo_cmd apt-get install -y golang-go
|
||||
;;
|
||||
pacman)
|
||||
$sudo_cmd pacman -Syu --noconfirm go
|
||||
;;
|
||||
dnf)
|
||||
$sudo_cmd dnf install -y golang
|
||||
;;
|
||||
pkg)
|
||||
$sudo_cmd pkg install -y go
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}Go installed successfully: $(go version)${NC}"
|
||||
else
|
||||
echo -e "${RED}Error: Go installation failed. Please install Go manually: https://go.dev/dl/${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_go
|
||||
|
||||
# Set OS-specific binary names
|
||||
get_bin_name() {
|
||||
local base=$1
|
||||
case "$OS" in
|
||||
freebsd)
|
||||
case "$base" in
|
||||
jep) echo "jepfbd" ;;
|
||||
vs) echo "vsfbsd" ;;
|
||||
*) echo "${base}_${OS}" ;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "$base"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
JEP_BIN=$(get_bin_name "jep")
|
||||
VS_BIN=$(get_bin_name "vs")
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
@@ -118,126 +35,63 @@ mkdir -p "$PID_DIR"
|
||||
LOG_DIR="$SCRIPT_DIR/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# Cleanup function
|
||||
# Track only services launched for this run, so stale restart commands cannot
|
||||
# introduce services that are no longer configured.
|
||||
service_labels=()
|
||||
|
||||
stop_services() {
|
||||
for pidfile in "$PID_DIR"/*.pid; do
|
||||
[ -f "$pidfile" ] || continue
|
||||
local pid name
|
||||
pid=$(cat "$pidfile")
|
||||
name=$(basename "$pidfile" .pid)
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo -e "${YELLOW}Stopping $name (PID: $pid)${NC}"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pidfile" "$PID_DIR/$name.cmd"
|
||||
done
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT SIGINT SIGTERM
|
||||
echo -e "\n${YELLOW}Shutting down all services...${NC}"
|
||||
|
||||
# Kill all background processes
|
||||
if [ -d "$PID_DIR" ]; then
|
||||
for pidfile in "$PID_DIR"/*.pid; do
|
||||
if [ -f "$pidfile" ]; then
|
||||
pid=$(cat "$pidfile")
|
||||
name=$(basename "$pidfile" .pid)
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo -e "${YELLOW}Stopping $name (PID: $pid)${NC}"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pidfile"
|
||||
rm -f "$PID_DIR/$name.cmd"
|
||||
fi
|
||||
done
|
||||
stop_services
|
||||
if [ -n "${RESTART_STATE_DIR:-}" ]; then
|
||||
rm -rf "$RESTART_STATE_DIR"
|
||||
fi
|
||||
|
||||
# Fallback: kill any lingering service processes even if pid files are stale/missing
|
||||
for bin in "$JEP_BIN" "$VS_BIN"; do
|
||||
pkill -x "$bin" 2>/dev/null || true
|
||||
done
|
||||
for script in scraper post_results get_votes get_user_list; do
|
||||
pkill -f "python3 $script.py" 2>/dev/null || true
|
||||
done
|
||||
|
||||
rm -rf "${RESTART_STATE_DIR:-}" 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}All services stopped.${NC}"
|
||||
exit 0
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
# Set up trap for cleanup
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
# Function to start a Go service
|
||||
start_go_service() {
|
||||
local name=$1
|
||||
local dir=$2
|
||||
local binary=$3
|
||||
local rebuild=0
|
||||
|
||||
if [ -f "$dir/$binary" ]; then
|
||||
echo -e "${GREEN}Found existing $name binary ($dir/$binary), checking freshness...${NC}"
|
||||
# Only skip the build if the binary matches the current OS target and
|
||||
# is newer than every Go source file in the project (stale binaries rebuild).
|
||||
local os_ok=1
|
||||
case "$OS:$binary" in
|
||||
freebsd:jepfbd|freebsd:vsfbsd) os_ok=0 ;;
|
||||
linux:jep|linux:vs) os_ok=0 ;;
|
||||
esac
|
||||
if [ "$os_ok" -ne 0 ]; then
|
||||
echo -e "${YELLOW}$binary is for another platform; rebuilding ${name} for $OS${NC}"
|
||||
rebuild=1
|
||||
else
|
||||
local newest_src
|
||||
newest_src=$( (cd "$dir" && ls -t *.go go.mod go.sum 2>/dev/null) | head -1 )
|
||||
if [ -z "$newest_src" ] || [ "$dir/$binary" -nt "$dir/$newest_src" ]; then
|
||||
echo -e "${GREEN}✓ $name binary is current; skipping build${NC}"
|
||||
rebuild=0
|
||||
else
|
||||
echo -e "${YELLOW}$name binary is older than ${newest_src}; rebuilding${NC}"
|
||||
rebuild=1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
rebuild=1
|
||||
fi
|
||||
|
||||
if [ "$rebuild" = "1" ]; then
|
||||
echo -e "${BLUE}Building $name...${NC}"
|
||||
if [ "$OS" = "freebsd" ]; then
|
||||
(cd "$dir" && GOOS=freebsd go build -o "$binary") || {
|
||||
echo -e "${RED}Failed to build $name for FreeBSD${NC}"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
(cd "$dir" && go build -o "$binary") || {
|
||||
echo -e "${RED}Failed to build $name${NC}"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Starting $name...${NC}"
|
||||
(cd "$dir" && ./"$binary" > "$LOG_DIR/$name.log" 2>&1) &
|
||||
echo $! > "$PID_DIR/$name.pid"
|
||||
echo "(cd \"$dir\" && .\"/$binary\" > \"$LOG_DIR/$name.log\" 2>&1) &" > "$PID_DIR/$name.cmd"
|
||||
|
||||
# Brief wait to check if it started successfully
|
||||
sleep 1
|
||||
if kill -0 $(cat "$PID_DIR/$name.pid") 2>/dev/null; then
|
||||
echo -e "${GREEN}✓ $name started (PID: $(cat "$PID_DIR/$name.pid"))${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ $name failed to start${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' SIGINT
|
||||
trap 'exit 143' SIGTERM
|
||||
|
||||
# Function to start a Python service
|
||||
start_python_service() {
|
||||
local name=$1
|
||||
local script=$2
|
||||
local config_path=${3:-}
|
||||
local config_arg=""
|
||||
local config_args=()
|
||||
local label="$name"
|
||||
if [ -n "$config_path" ]; then
|
||||
config_arg="$config_path"
|
||||
label="${name}-$(basename "$config_path" .yaml)"
|
||||
config_args+=("$config_path")
|
||||
label="${name}-$(basename "$config_path")"
|
||||
fi
|
||||
|
||||
service_labels+=("$label")
|
||||
echo -e "${GREEN}Starting $label...${NC}"
|
||||
|
||||
# Activate virtual environment and run script
|
||||
(source .venv/bin/activate && python3 "$script" $config_arg > "$LOG_DIR/$label.log" 2>&1) &
|
||||
(source .venv/bin/activate && exec python3 "$script" "${config_args[@]}" > "$LOG_DIR/$label.log" 2>&1) &
|
||||
echo $! > "$PID_DIR/$label.pid"
|
||||
echo "(source .venv/bin/activate && python3 \"$script\" $config_arg > \"$LOG_DIR/$label.log\" 2>&1) &" > "$PID_DIR/$label.cmd"
|
||||
printf '(source .venv/bin/activate && exec python3 %q ' "$script" > "$PID_DIR/$label.cmd"
|
||||
if [ ${#config_args[@]} -gt 0 ]; then
|
||||
printf '%q ' "${config_args[@]}" >> "$PID_DIR/$label.cmd"
|
||||
fi
|
||||
printf '> %q 2>&1) &\n' "$LOG_DIR/$label.log" >> "$PID_DIR/$label.cmd"
|
||||
|
||||
# Brief wait to check if it started successfully
|
||||
sleep 1
|
||||
@@ -265,7 +119,7 @@ ensure_venv() {
|
||||
}
|
||||
|
||||
echo -e "${GREEN}Virtual environment created. Installing dependencies...${NC}"
|
||||
if source .venv/bin/activate && pip install matrix-nio pyyaml requests; then
|
||||
if source .venv/bin/activate && pip install -r requirements.txt; then
|
||||
echo -e "${GREEN}Dependencies installed successfully.${NC}"
|
||||
else
|
||||
echo -e "${RED}Error: Failed to install Python dependencies.${NC}"
|
||||
@@ -275,23 +129,18 @@ ensure_venv() {
|
||||
|
||||
ensure_venv
|
||||
|
||||
# Check if config.yaml exists
|
||||
if [ ! -f "configs/config.yaml" ]; then
|
||||
echo -e "${RED}Error: configs/config.yaml not found${NC}"
|
||||
echo -e "${YELLOW}Please copy configs/config_example.yaml to configs/config.yaml and configure it${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Discover active room configs (skip example/template files)
|
||||
room_configs=()
|
||||
for cfg in configs/*.yaml; do
|
||||
if [[ "$(basename "$cfg")" == *example* ]]; then
|
||||
for cfg in configs/*.yaml configs/*.yml; do
|
||||
[ -f "$cfg" ] || continue
|
||||
if [[ "$(basename "$cfg")" == "config_example.yaml" ]]; then
|
||||
continue
|
||||
fi
|
||||
room_configs+=("$cfg")
|
||||
done
|
||||
if [ ${#room_configs[@]} -eq 0 ]; then
|
||||
echo -e "${RED}Error: no room configs found in configs/*.yaml${NC}"
|
||||
echo -e "${RED}Error: no room configs found in configs/*.yaml or configs/*.yml${NC}"
|
||||
echo -e "${YELLOW}Copy configs/config_example.yaml to configs/your-room.yaml and configure it${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${BLUE}Found ${#room_configs[@]} room config(s): ${room_configs[*]}${NC}"
|
||||
@@ -301,56 +150,25 @@ echo -e "${BLUE} TitleBot-NG - Starting All Services ${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Start services in order
|
||||
# Stop the processes recorded by the previous run before starting room bots.
|
||||
echo -e "${YELLOW}Cleaning up any existing processes...${NC}"
|
||||
for bin in "$JEP_BIN" "$VS_BIN"; do
|
||||
if pgrep -x "$bin" > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Stopping existing $bin process...${NC}"
|
||||
pkill -x "$bin" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
for script in scraper post_results get_votes get_user_list; do
|
||||
if pgrep -f "python3 $script.py" > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Stopping existing $script process...${NC}"
|
||||
pkill -f "python3 $script.py" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
sleep 1
|
||||
stop_services
|
||||
|
||||
echo -e "${YELLOW}Step 1: Starting scrapers (write room_slugs.json for voting paths)...${NC}"
|
||||
echo -e "${YELLOW}Starting Matrix room bots...${NC}"
|
||||
for cfg in "${room_configs[@]}"; do
|
||||
start_python_service "scraper" "scraper.py" "$cfg"
|
||||
done
|
||||
sleep 2
|
||||
|
||||
echo -e "${YELLOW}Step 2: Starting backend services...${NC}"
|
||||
start_go_service "jsonEndpoint" "jsonEndpoint" "$JEP_BIN"; true
|
||||
sleep 2 # Give jsonEndpoint time to fully start
|
||||
|
||||
start_go_service "voteServer" "voteServer" "$VS_BIN"; true
|
||||
sleep 2 # Give voteServer time to fully start
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Step 3: Starting remaining Matrix bots...${NC}"
|
||||
for cfg in "${room_configs[@]}"; do
|
||||
start_python_service "post_results" "post_results.py" "$cfg"
|
||||
sleep 1
|
||||
start_python_service "get_votes" "get_votes.py" "$cfg"
|
||||
sleep 1
|
||||
start_python_service "get_user_list" "get_user_list.py" "$cfg"
|
||||
start_python_service "scraper" "scraper.py" "$cfg" || true
|
||||
start_python_service "post_results" "post_results.py" "$cfg" || true
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} All services started successfully! ${NC}"
|
||||
echo -e "${GREEN} Service startup complete ${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Service Status:${NC}"
|
||||
echo -e " • JSON Endpoint: http://localhost:9080"
|
||||
echo -e " • Vote Server: http://localhost:9081"
|
||||
for cfg in "${room_configs[@]}"; do
|
||||
label=$(basename "$cfg" .yaml)
|
||||
echo -e " • Room ($label): scraper / post_results / get_votes / get_user_list"
|
||||
label=$(basename "$cfg")
|
||||
echo -e " • Room ($label): scraper / post_results"
|
||||
done
|
||||
echo ""
|
||||
echo -e "${BLUE}Logs are available in: $LOG_DIR${NC}"
|
||||
@@ -457,14 +275,15 @@ restart_service() {
|
||||
while true; do
|
||||
# Check if all processes are still running
|
||||
all_running=true
|
||||
for pidfile in "$PID_DIR"/*.pid; do
|
||||
for name in "${service_labels[@]}"; do
|
||||
pidfile="$PID_DIR/$name.pid"
|
||||
if [ -f "$pidfile" ]; then
|
||||
pid=$(cat "$pidfile")
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
name=$(basename "$pidfile" .pid)
|
||||
echo -e "${RED}Warning: $name (PID: $pid) has stopped unexpectedly${NC}"
|
||||
all_running=false
|
||||
restart_service "$name"
|
||||
restart_service "$name" || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -23,7 +23,6 @@ async def main():
|
||||
# Initial sync
|
||||
assert room.client is not None
|
||||
await room.client.sync(timeout=room.config.sync_timeout)
|
||||
room.write_room_slugs()
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -35,9 +34,6 @@ async def main():
|
||||
# Scrape and write to database
|
||||
await room.get_convos()
|
||||
room.write_to_db()
|
||||
if room.newly_inserted_posts:
|
||||
count = room.increment_suggestion_counter(len(room.newly_inserted_posts))
|
||||
await room.check_and_announce_vote_beta(count)
|
||||
# Reply to banned users that they cannot submit titles
|
||||
await room.acknowledge_rejected_suggestions()
|
||||
# Ack newly recorded suggestions with a ✅ reaction
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+108
@@ -0,0 +1,108 @@
|
||||
import asyncio
|
||||
from contextlib import closing
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import dbutil
|
||||
import post_results
|
||||
from room import Room
|
||||
|
||||
|
||||
class ClearSuggestionsTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.room = Room.__new__(Room)
|
||||
self.room.db_path = str(Path(self.temp.name) / "suggestions.db")
|
||||
self.room.init_db()
|
||||
with closing(dbutil.connect(self.room.db_path)) as conn, conn:
|
||||
conn.executemany(
|
||||
"INSERT INTO suggestions (message, is_posted) VALUES (?, ?)",
|
||||
[("pending one", 0), ("pending two", 0), ("already posted", 1)],
|
||||
)
|
||||
|
||||
def test_clear_preserves_rows_and_excludes_pending_from_voting(self):
|
||||
self.assertEqual(self.room.clear_suggestions("alice", 100, "$clear"), 2)
|
||||
self.room.read_db(only_unposted=True)
|
||||
self.assertEqual(self.room.posts, [])
|
||||
with closing(dbutil.connect(self.room.db_path)) as conn, conn:
|
||||
self.assertEqual(conn.execute("SELECT COUNT(*) FROM suggestions").fetchone()[0], 3)
|
||||
conn.execute("INSERT INTO suggestions (message) VALUES ('new title')")
|
||||
self.assertIsNone(self.room.clear_suggestions("alice", 100, "$clear"))
|
||||
with closing(dbutil.connect(self.room.db_path)) as conn, conn:
|
||||
self.assertEqual(conn.execute("SELECT COUNT(*) FROM suggestions WHERE is_posted = 0").fetchone()[0], 1)
|
||||
self.assertEqual(self.room.clear_suggestions("alice", 101, "$next"), 1)
|
||||
self.assertEqual(self.room.clear_suggestions("alice", 102, "$empty"), 0)
|
||||
|
||||
async def test_command_confirms_clear_and_keeps_waiting_for_gettitles(self):
|
||||
class Event(SimpleNamespace):
|
||||
pass
|
||||
|
||||
class Response(SimpleNamespace):
|
||||
pass
|
||||
|
||||
def response(body, event_id, timestamp):
|
||||
event = Event(body=body, sender="alice", event_id=event_id, server_timestamp=timestamp)
|
||||
return Response(rooms=SimpleNamespace(join={
|
||||
"test": SimpleNamespace(timeline=SimpleNamespace(events=[event]))
|
||||
}))
|
||||
|
||||
self.room.config = SimpleNamespace(room_id="test", command_sync_timeout=1)
|
||||
self.room.user_id = "bot"
|
||||
self.room.client = SimpleNamespace(sync=AsyncMock(side_effect=[
|
||||
response("!clear", "$clear", 100000),
|
||||
response("!clear", "$clear", 100000),
|
||||
response("!gettitles", "$titles", 101000),
|
||||
]))
|
||||
self.room.alert_post = AsyncMock()
|
||||
with patch("room.RoomMessageText", Event), patch("room.SyncResponse", Response):
|
||||
self.assertEqual(await self.room.wait_for_commands(since_ms=0), "gettitles")
|
||||
self.room.alert_post.assert_awaited_once_with(
|
||||
"Cleared 2 pending title suggestions. They will not be posted for voting."
|
||||
)
|
||||
|
||||
async def test_clear_then_gettitles_in_same_batch_reports_no_titles(self):
|
||||
class Event(SimpleNamespace):
|
||||
pass
|
||||
|
||||
class Response(SimpleNamespace):
|
||||
pass
|
||||
|
||||
events = [
|
||||
Event(body="!clear", sender="alice", event_id="$clear", server_timestamp=100000),
|
||||
Event(body="!gettitles", sender="alice", event_id="$titles", server_timestamp=101000),
|
||||
]
|
||||
response = Response(rooms=SimpleNamespace(join={
|
||||
"test": SimpleNamespace(timeline=SimpleNamespace(events=events))
|
||||
}))
|
||||
self.room.config = SimpleNamespace(
|
||||
room_id="test", command_sync_timeout=1, initial_sync_timeout=1, post_loop_interval=1
|
||||
)
|
||||
self.room.user_id = "bot"
|
||||
self.room.client = SimpleNamespace(sync=AsyncMock(side_effect=[None, response]))
|
||||
self.room.login_to_matrix = AsyncMock(return_value=True)
|
||||
self.room.close = AsyncMock()
|
||||
self.room.alert_post = AsyncMock()
|
||||
self.room.post_suggestions_individually = AsyncMock()
|
||||
with (
|
||||
patch("room.RoomMessageText", Event),
|
||||
patch("room.SyncResponse", Response),
|
||||
patch("post_results.Room", return_value=self.room),
|
||||
patch("post_results.time.time", return_value=99),
|
||||
patch("post_results.asyncio.sleep", side_effect=asyncio.CancelledError),
|
||||
):
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await post_results.main()
|
||||
self.assertEqual(
|
||||
self.room.alert_post.await_args_list[-1].args,
|
||||
("No new title suggestions posted.",),
|
||||
)
|
||||
self.room.post_suggestions_individually.assert_not_awaited()
|
||||
self.room.close.assert_awaited_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,183 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from nio import AsyncClient, RoomMessagesResponse
|
||||
from nio.responses import RoomEventRelationsResponse
|
||||
from room import Room
|
||||
|
||||
|
||||
class Message(SimpleNamespace):
|
||||
pass
|
||||
|
||||
|
||||
class Response(SimpleNamespace):
|
||||
pass
|
||||
|
||||
|
||||
def message(body, event_id, sender="bot"):
|
||||
return Message(body=body, event_id=event_id, sender=sender, source={})
|
||||
|
||||
|
||||
def reaction(target, sender, event_id, key="👍"):
|
||||
return SimpleNamespace(sender=sender, event_id=event_id, source={
|
||||
"type": "m.reaction",
|
||||
"content": {"m.relates_to": {
|
||||
"rel_type": "m.annotation", "key": key, "event_id": target,
|
||||
}},
|
||||
})
|
||||
|
||||
|
||||
class CountVotesTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.room = Room.__new__(Room)
|
||||
self.room.config = SimpleNamespace(room_id="test", batch_size=100)
|
||||
self.room.user_id = "bot"
|
||||
self.room.client = SimpleNamespace(room_messages=AsyncMock())
|
||||
self.enterContext(patch("room.RoomMessageText", Message))
|
||||
self.enterContext(patch("room.RoomMessagesResponse", Response))
|
||||
|
||||
async def count(self, *pages, live_reactions=None):
|
||||
self.room.client.room_messages.side_effect = pages
|
||||
events = [event for page in pages for event in getattr(page, "chunk", [])]
|
||||
removed = set()
|
||||
for event in events:
|
||||
source = getattr(event, "source", {})
|
||||
if source.get("type") == "m.room.redaction":
|
||||
removed.add(source.get("redacts") or source.get("content", {}).get("redacts"))
|
||||
|
||||
async def relations(room_id, event_id, **kwargs):
|
||||
for event in events if live_reactions is None else live_reactions:
|
||||
relation = event.source.get("content", {}).get("m.relates_to", {})
|
||||
if relation.get("event_id") == event_id and event.event_id not in removed:
|
||||
yield event
|
||||
|
||||
self.room.client.room_get_event_relations = relations
|
||||
return await self.room.count_votes()
|
||||
|
||||
async def test_votes_absent_from_timeline_are_read_from_live_relations(self):
|
||||
result = await self.count(Response(chunk=[
|
||||
message("Suggestion 1: Author: Title", "$one"),
|
||||
message("Time to vote!", "$start"),
|
||||
], end=None), live_reactions=[
|
||||
reaction("$one", "alice", "$vote", key="👍🏽"),
|
||||
reaction("$one", "bob", "$vote2", key="👍\ufe0f"),
|
||||
])
|
||||
self.assertIn("Suggestion 1: 2 votes", result)
|
||||
|
||||
async def test_relation_failure_is_not_reported_as_zero_votes(self):
|
||||
async def broken_relations(*args, **kwargs):
|
||||
raise RuntimeError("Server unavailable")
|
||||
yield
|
||||
|
||||
self.room.client.room_messages.return_value = Response(chunk=[
|
||||
message("Suggestion 1: Author: Title", "$one"),
|
||||
message("Time to vote!", "$start"),
|
||||
], end=None)
|
||||
self.room.client.room_get_event_relations = broken_relations
|
||||
result = await self.room.count_votes()
|
||||
self.assertIn("Unable to read emoji votes", result)
|
||||
self.assertNotIn("0 votes", result)
|
||||
|
||||
async def test_latest_round_ranking_and_later_votes_refresh(self):
|
||||
latest = [
|
||||
reaction("$two", "alice", "$v2"),
|
||||
reaction("$one", "alice", "$v1"),
|
||||
reaction("$one", "alice", "$duplicate"),
|
||||
reaction("$one", "bot", "$starter"),
|
||||
reaction("$one", "charlie", "$wrong", key="👎"),
|
||||
reaction("$old", "dana", "$oldvote"),
|
||||
message("Suggestion 3: Author: Zero votes", "$three"),
|
||||
message("Suggestion 2: Author: Second title", "$two"),
|
||||
message("Suggestion 1: Author: First title", "$one"),
|
||||
message("Time to vote! 🚢", "$announcement"),
|
||||
message("Suggestion 1: Author: Old title", "$old"),
|
||||
]
|
||||
result = await self.count(Response(chunk=latest, end="older"))
|
||||
self.assertIn("Suggestion 1: 1 vote", result)
|
||||
self.assertIn("Suggestion 2: 1 vote", result)
|
||||
self.assertIn("Suggestion 3: 0 votes", result)
|
||||
self.assertNotIn("Old title", result)
|
||||
self.assertLess(result.index("Suggestion 1:"), result.index("Suggestion 2:"))
|
||||
updated = await self.count(Response(chunk=[
|
||||
reaction("$two", "bob", "$later"), *latest,
|
||||
], end="older"))
|
||||
self.assertIn("Suggestion 2: 2 votes", updated)
|
||||
self.assertLess(updated.index("Suggestion 2:"), updated.index("Suggestion 1:"))
|
||||
|
||||
async def test_removed_reactions_and_zero_vote_results(self):
|
||||
redactions = [SimpleNamespace(source={
|
||||
"type": "m.room.redaction", **target,
|
||||
}) for target in ({"redacts": "$removed"}, {"content": {"redacts": "$removedv11"}})]
|
||||
result = await self.count(Response(chunk=[
|
||||
*redactions,
|
||||
reaction("$one", "alice", "$removed"),
|
||||
reaction("$one", "bob", "$removedv11"),
|
||||
message("Suggestion 1: Author: Title", "$one"),
|
||||
message("Time to vote!", "$start"),
|
||||
], end=None))
|
||||
self.assertIn("Suggestion 1: 0 votes", result)
|
||||
|
||||
async def test_round_older_than_fifteen_pages(self):
|
||||
pages = [Response(chunk=[reaction("$one", "alice", "$vote")], end="1")]
|
||||
pages.extend(Response(chunk=[message("Chat", f"$chat{i}", sender="alice")], end=str(i))
|
||||
for i in range(2, 18))
|
||||
pages.append(Response(chunk=[
|
||||
message("Suggestion 1: Author: Title", "$one"),
|
||||
message("Time to vote!", "$start"),
|
||||
], end="old"))
|
||||
result = await self.count(*pages)
|
||||
self.assertIn("Suggestion 1: 1 vote", result)
|
||||
self.assertEqual(self.room.client.room_messages.await_count, 18)
|
||||
|
||||
async def test_history_failure_does_not_report_partial_counts(self):
|
||||
result = await self.count(Response(chunk=[
|
||||
message("Suggestion 1: Author: Title", "$one"),
|
||||
], end="older"), object())
|
||||
self.assertIn("Unable to read voting history", result)
|
||||
|
||||
async def test_no_round(self):
|
||||
self.assertIn("No suggestion posts found", await self.count(Response(chunk=[], end=None)))
|
||||
|
||||
|
||||
class MatrixSDKVotesTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_real_sdk_parses_and_paginates_live_reactions(self):
|
||||
room = Room.__new__(Room)
|
||||
room.config = SimpleNamespace(room_id="!room:matrix.test", batch_size=2)
|
||||
room.user_id = "@bot:matrix.test"
|
||||
room.client = AsyncClient("https://matrix.test", room.user_id)
|
||||
room.client.access_token = "test-token"
|
||||
self.addAsyncCleanup(room.client.close)
|
||||
|
||||
def event(event_id, sender, event_type, content):
|
||||
return {"event_id": event_id, "sender": sender, "type": event_type,
|
||||
"origin_server_ts": 100000, "content": content,
|
||||
"room_id": room.config.room_id}
|
||||
|
||||
history = RoomMessagesResponse.from_dict({
|
||||
"start": "now", "chunk": [
|
||||
event("$one", room.user_id, "m.room.message", {
|
||||
"msgtype": "m.text", "body": "Suggestion 1: Author: Title"}),
|
||||
event("$start", room.user_id, "m.room.message", {
|
||||
"msgtype": "m.text", "body": "Time to vote!"}),
|
||||
],
|
||||
}, room.config.room_id)
|
||||
|
||||
def votes(sender, event_id, next_batch=None):
|
||||
return RoomEventRelationsResponse.from_dict({
|
||||
"chunk": [event(event_id, sender, "m.reaction", {"m.relates_to": {
|
||||
"rel_type": "m.annotation", "key": "👍", "event_id": "$one",
|
||||
}})], **({"next_batch": next_batch} if next_batch else {}),
|
||||
}, room.config.room_id, "$one")
|
||||
|
||||
room.client._send = AsyncMock(side_effect=[
|
||||
history, votes("@alice:matrix.test", "$v1", "page2"),
|
||||
votes("@bob:matrix.test", "$v2"),
|
||||
])
|
||||
result = await room.count_votes()
|
||||
self.assertIn("Suggestion 1: 2 votes", result)
|
||||
self.assertIn("from=page2", room.client._send.await_args_list[-1].args[2])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import dbutil
|
||||
from room import Room
|
||||
|
||||
|
||||
class Event(SimpleNamespace):
|
||||
pass
|
||||
|
||||
|
||||
class Response(SimpleNamespace):
|
||||
pass
|
||||
|
||||
|
||||
class GetVotesTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_both_commands_report_current_emoji_votes_without_consuming_titles(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
room = Room.__new__(Room)
|
||||
room.db_path = str(Path(directory) / "suggestions.db")
|
||||
room.init_db()
|
||||
with closing(dbutil.connect(room.db_path)) as conn, conn:
|
||||
conn.execute("INSERT INTO suggestions (user, display_name, message, timestamp, age) "
|
||||
"VALUES ('alice', 'Alice', 'Pending title', 100, 0)")
|
||||
room.config = SimpleNamespace(room_id="test", command_sync_timeout=1)
|
||||
room.user_id = "bot"
|
||||
room.count_votes = AsyncMock(side_effect=[
|
||||
"Suggestion 1: 1 vote", "Suggestion 1: 2 votes",
|
||||
])
|
||||
room.alert_post = AsyncMock()
|
||||
events = [
|
||||
Event(body=body, sender="alice", event_id=event_id, server_timestamp=timestamp)
|
||||
for body, event_id, timestamp in [
|
||||
("!countvotes", "$count", 100000),
|
||||
("!countvotes", "$count", 100000), # replay ignored
|
||||
("!getvotes", "$get", 101000),
|
||||
("!gettitles", "$titles", 102000),
|
||||
]
|
||||
]
|
||||
room.client = SimpleNamespace(sync=AsyncMock(return_value=Response(
|
||||
rooms=SimpleNamespace(join={"test": SimpleNamespace(
|
||||
timeline=SimpleNamespace(events=events)
|
||||
)})
|
||||
)))
|
||||
with patch("room.RoomMessageText", Event), patch("room.SyncResponse", Response):
|
||||
self.assertEqual(await room.wait_for_commands(since_ms=0), "gettitles")
|
||||
self.assertEqual(room.count_votes.await_count, 2)
|
||||
messages = [call.args[0] for call in room.alert_post.await_args_list]
|
||||
self.assertEqual(messages, [
|
||||
"Counting votes... This may take a moment...", "Suggestion 1: 1 vote",
|
||||
"Counting votes... This may take a moment...", "Suggestion 1: 2 votes",
|
||||
])
|
||||
room.read_db(only_unposted=True)
|
||||
self.assertEqual([post.content for post in room.posts], ["Pending title"])
|
||||
self.assertEqual(sorted(path.name for path in Path(directory).iterdir()),
|
||||
["suggestions.db"])
|
||||
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
class LauncherTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
shutil.copy(Path(__file__).with_name("run_all.sh"), self.root)
|
||||
(self.root / "configs").mkdir()
|
||||
(self.root / ".venv/bin").mkdir(parents=True)
|
||||
(self.root / ".venv/bin/activate").write_text("")
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.executable(self.bin / "go", "#!/bin/sh\ntouch unexpected-go-invocation\nexit 1\n")
|
||||
self.executable(self.bin / "pgrep", "#!/bin/sh\nexit 1\n")
|
||||
self.executable(self.bin / "pkill", "#!/bin/sh\nexit 0\n")
|
||||
self.executable(self.bin / "sleep", "#!/bin/sh\n/bin/sleep 0.02\n")
|
||||
self.executable(self.bin / "python3", f'#!/bin/sh\nexec "{sys.executable}" "$@"\n')
|
||||
for name in ("scraper", "post_results"):
|
||||
(self.root / f"{name}.py").write_text(
|
||||
"import json, pathlib, sys, time\n"
|
||||
"name = pathlib.Path(sys.argv[0]).stem\n"
|
||||
"config = pathlib.Path(sys.argv[1]).name\n"
|
||||
"pathlib.Path(f'started-{name}-{config}.json').write_text(json.dumps(sys.argv[1:]))\n"
|
||||
"if config == 'broken room.yaml': sys.exit(1)\n"
|
||||
"time.sleep(300)\n"
|
||||
)
|
||||
|
||||
def executable(self, path, content):
|
||||
path.write_text(content)
|
||||
path.chmod(0o755)
|
||||
|
||||
def launch(self):
|
||||
output = open(self.root / "launcher.log", "w+")
|
||||
self.addCleanup(output.close)
|
||||
env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ["PATH"])
|
||||
process = subprocess.Popen(
|
||||
["bash", str(self.root / "run_all.sh")], env=env,
|
||||
stdout=output, stderr=subprocess.STDOUT, start_new_session=True,
|
||||
)
|
||||
|
||||
def stop():
|
||||
# All processes belong to this isolated test session.
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait(timeout=5)
|
||||
|
||||
self.addCleanup(stop)
|
||||
return process
|
||||
|
||||
def test_each_config_launches_independently_without_config_yaml(self):
|
||||
configs = ("main room.yaml", "example-room.yml", "broken room.yaml")
|
||||
for name in (*configs, "config_example.yaml", "disabled.yaml.hold"):
|
||||
(self.root / "configs" / name).write_text("room_id: test\n")
|
||||
pid_dir = self.root / ".pids"
|
||||
pid_dir.mkdir()
|
||||
(pid_dir / "retired.pid").write_text("999999999")
|
||||
(pid_dir / "retired.cmd").write_text("touch unexpected-service-started")
|
||||
process = self.launch()
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
if "Service startup complete" in (self.root / "launcher.log").read_text():
|
||||
break
|
||||
if process.poll() is not None:
|
||||
self.fail((self.root / "launcher.log").read_text())
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
self.fail((self.root / "launcher.log").read_text())
|
||||
self.assertEqual(len(list(self.root.glob("started-*.json"))), 6)
|
||||
for config in configs:
|
||||
for service in ("scraper", "post_results"):
|
||||
record = self.root / f"started-{service}-{config}.json"
|
||||
self.assertEqual(json.loads(record.read_text()), [f"configs/{config}"])
|
||||
self.assertTrue((self.root / "logs" / f"{service}-{config}.log").is_file())
|
||||
# A failed restart must also leave the supervisor and other rooms up.
|
||||
deadline = time.monotonic() + 5
|
||||
while "failed to restart" not in (self.root / "launcher.log").read_text():
|
||||
if time.monotonic() >= deadline:
|
||||
self.fail("Failed room was not retried")
|
||||
time.sleep(0.05)
|
||||
self.assertIsNone(process.poll())
|
||||
self.assertFalse((self.root / "unexpected-go-invocation").exists())
|
||||
self.assertFalse((self.root / "unexpected-service-started").exists())
|
||||
self.assertFalse((pid_dir / "retired.cmd").exists())
|
||||
for config in configs[:2]:
|
||||
pid = int((self.root / ".pids" / f"scraper-{config}.pid").read_text())
|
||||
os.kill(pid, 0)
|
||||
|
||||
def test_no_active_configs_reports_error(self):
|
||||
(self.root / "configs/config_example.yaml").write_text("room_id: template\n")
|
||||
process = self.launch()
|
||||
process.wait(timeout=5)
|
||||
self.assertIn("no room configs found", (self.root / "launcher.log").read_text())
|
||||
self.assertEqual(list(self.root.glob("started-*.json")), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from nio import RoomSendResponse
|
||||
|
||||
import dbutil
|
||||
from post import Post
|
||||
from room import Room
|
||||
|
||||
|
||||
class PostSuggestionsTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.room = Room.__new__(Room)
|
||||
self.room.db_path = str(Path(self.temp.name) / "suggestions.db")
|
||||
self.room.init_db()
|
||||
self.room.config = SimpleNamespace(room_id="test", post_interval=0)
|
||||
self.room.client = SimpleNamespace(room_send=AsyncMock())
|
||||
self.room.alert_post = AsyncMock()
|
||||
self.room.is_banned = lambda user: False
|
||||
|
||||
def suggestions(self, *titles):
|
||||
self.room.posts = [Post("alice", "Alice", title, i, age=0)
|
||||
for i, title in enumerate(titles, start=1)]
|
||||
self.room.write_to_db()
|
||||
|
||||
def sent(self, event_id):
|
||||
return RoomSendResponse.from_dict({"event_id": event_id}, "test")
|
||||
|
||||
async def test_posting_consumes_queue_and_repeat_gettitles_does_not_restart_vote(self):
|
||||
self.suggestions("Title")
|
||||
self.room.client.room_send.side_effect = [self.sent("$title"), self.sent("$reaction")]
|
||||
await self.room.post_suggestions_individually()
|
||||
banner = self.room.alert_post.await_args.args[0]
|
||||
self.assertIn("!countvotes", banner)
|
||||
self.assertIn("!getvotes", banner)
|
||||
self.assertNotIn("!voteurl", banner)
|
||||
self.assertNotIn("HTTP VOTING", banner)
|
||||
self.room.read_db(only_unposted=True)
|
||||
self.assertEqual(self.room.posts, [])
|
||||
await self.room.post_suggestions_individually()
|
||||
self.room.alert_post.assert_awaited_with("No new title suggestions posted.")
|
||||
self.assertEqual(self.room.client.room_send.await_count, 2)
|
||||
|
||||
async def test_failed_title_send_stays_pending(self):
|
||||
self.suggestions("Posted", "Retry me")
|
||||
self.room.client.room_send.side_effect = [self.sent("$title"), self.sent("$reaction"), object()]
|
||||
await self.room.post_suggestions_individually()
|
||||
self.room.read_db(only_unposted=True)
|
||||
self.assertEqual([post.content for post in self.room.posts], ["Retry me"])
|
||||
with closing(dbutil.connect(self.room.db_path)) as conn:
|
||||
self.assertEqual(conn.execute("SELECT COUNT(*) FROM suggestions").fetchone()[0], 2)
|
||||
|
||||
async def test_reaction_failure_does_not_repost_successful_title(self):
|
||||
self.suggestions("Title")
|
||||
self.room.client.room_send.side_effect = [self.sent("$title"), RuntimeError("Reaction failed")]
|
||||
with self.assertRaises(RuntimeError):
|
||||
await self.room.post_suggestions_individually()
|
||||
self.room.read_db(only_unposted=True)
|
||||
self.assertEqual(self.room.posts, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,36 +0,0 @@
|
||||
module vs
|
||||
|
||||
go 1.24.9
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/nbd-wtf/go-nostr v0.52.3
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.13.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
)
|
||||
@@ -1,173 +0,0 @@
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
|
||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
|
||||
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g=
|
||||
github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nbd-wtf/go-nostr v0.52.3 h1:Xd87pXfJEJRXHpM+fLjQQln8dBNNaoPA10V7BbyP4KI=
|
||||
github.com/nbd-wtf/go-nostr v0.52.3/go.mod h1:4avYoc9mDGZ9wHsvCOhHH9vPzKucCfuYBtJUSpHTfNk=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
@@ -1,211 +0,0 @@
|
||||
.suggestor {
|
||||
font-weight: italic;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
font-weight: bold;
|
||||
color: #844;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#suggestion_list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#suggestion_list li {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#submit_vote {
|
||||
margin-top: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#auth_section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
#vote_section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#logged_in_as {
|
||||
padding: 10px;
|
||||
background: #e8f5e9;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
font-weight: bold;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 15px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
color: #1565c0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 8px 12px;
|
||||
margin-top: 10px;
|
||||
background: #e8f5e9;
|
||||
border-radius: 4px;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 8px 12px;
|
||||
margin-top: 10px;
|
||||
background: #ffebee;
|
||||
border-radius: 4px;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
#connect_btn {
|
||||
background-color: #f57c00;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#connect_btn:hover {
|
||||
background-color: #ef6c00;
|
||||
}
|
||||
|
||||
#auth_btn {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
#auth_btn:hover {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
|
||||
#link_link {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#link_link:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#auth_method_select {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#auth_method_select p {
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
#matrix_auth_btn {
|
||||
background-color: #0dbd8b;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#matrix_auth_btn:hover {
|
||||
background-color: #0aa37a;
|
||||
}
|
||||
|
||||
#nostr_auth_btn {
|
||||
background-color: #f57c00;
|
||||
}
|
||||
|
||||
#nostr_auth_btn:hover {
|
||||
background-color: #ef6c00;
|
||||
}
|
||||
|
||||
#login_form {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
#login_form input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#back_link {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#back_link:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#sign_out_btn {
|
||||
background-color: #999;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#sign_out_btn:hover {
|
||||
background-color: #777;
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
let currentUser = null;
|
||||
let currentChallenge = null;
|
||||
let authMethod = null;
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- Matrix auth ---
|
||||
|
||||
function goToMatrixLogin() {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
window.location.href = '/' + slug + '/matrix-login';
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
sessionStorage.removeItem('matrix_session_token');
|
||||
currentUser = null;
|
||||
authMethod = null;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function showNostrAuth() {
|
||||
document.getElementById('auth_method_select').style.display = 'none';
|
||||
document.getElementById('nostr_auth_section').style.display = 'block';
|
||||
}
|
||||
|
||||
async function checkMatrixSession() {
|
||||
const sessionToken = sessionStorage.getItem('matrix_session_token');
|
||||
if (!sessionToken) return false;
|
||||
|
||||
try {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
const resp = await fetch('/' + slug + '/matrixVerify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_token: sessionToken })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
currentUser = { displayName: data.display_name, sessionToken: sessionToken };
|
||||
authMethod = 'matrix';
|
||||
|
||||
document.getElementById('auth_section').style.display = 'none';
|
||||
document.getElementById('vote_section').style.display = 'block';
|
||||
document.getElementById('logged_in_as').textContent = 'Signed in as: ' + data.display_name;
|
||||
|
||||
loadSubmissions();
|
||||
return true;
|
||||
} else {
|
||||
sessionStorage.removeItem('matrix_session_token');
|
||||
}
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem('matrix_session_token');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Nostr auth ---
|
||||
|
||||
function hasNip07() {
|
||||
return typeof window.nostr !== 'undefined' && window.nostr !== null;
|
||||
}
|
||||
|
||||
async function connectNip07() {
|
||||
if (!hasNip07()) {
|
||||
setAuthStatus('No Nostr browser extension detected. Enter your npub manually.', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hexKey = await window.nostr.getPublicKey();
|
||||
const resp = await fetch('encode_npub', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hex: hexKey })
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
document.getElementById('npub_input').value = data.npub;
|
||||
document.getElementById('nip07_badge').style.display = 'block';
|
||||
setAuthStatus('Connected! Click "Sign In to Vote" to continue.');
|
||||
} else {
|
||||
document.getElementById('npub_input').value = hexKey;
|
||||
setAuthStatus('Connected (using hex key). Click "Sign In to Vote".');
|
||||
}
|
||||
} catch (err) {
|
||||
setAuthStatus('Error: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const npub = document.getElementById('npub_input').value.trim();
|
||||
if (!npub) {
|
||||
setAuthStatus('Enter your npub or connect AlbyHub first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthStatus('Requesting challenge...');
|
||||
|
||||
try {
|
||||
const resp = await fetch('challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ npub: npub })
|
||||
});
|
||||
|
||||
if (resp.status === 404) {
|
||||
setAuthStatus('Npub not linked. Please <a href="link">link your identity</a> first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
setAuthStatus('Error: ' + text, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
currentChallenge = data.challenge;
|
||||
|
||||
if (!hasNip07()) {
|
||||
setAuthStatus('No Nostr extension. Please install AlbyHub to sign.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthStatus('Please sign the challenge in your extension...');
|
||||
|
||||
const signedEvent = await window.nostr.signEvent({
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: currentChallenge
|
||||
});
|
||||
|
||||
currentUser = {
|
||||
npub: npub,
|
||||
displayName: data.display_name,
|
||||
signedEvent: signedEvent
|
||||
};
|
||||
authMethod = 'nostr';
|
||||
|
||||
document.getElementById('auth_section').style.display = 'none';
|
||||
document.getElementById('vote_section').style.display = 'block';
|
||||
document.getElementById('logged_in_as').textContent = 'Signed in as: ' + data.display_name;
|
||||
|
||||
loadSubmissions();
|
||||
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('denied')) {
|
||||
setAuthStatus('Signature request denied.', true);
|
||||
} else {
|
||||
setAuthStatus('Error: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAuthStatus(msg, isError) {
|
||||
const el = document.getElementById('auth_status');
|
||||
el.innerHTML = msg;
|
||||
el.className = isError ? 'error' : 'success';
|
||||
}
|
||||
|
||||
// --- Submissions ---
|
||||
|
||||
async function loadSubmissions() {
|
||||
try {
|
||||
const response = await fetch('submissions');
|
||||
const data = await response.json();
|
||||
|
||||
const submissionList = document.getElementById('suggestion_list');
|
||||
submissionList.innerHTML = '';
|
||||
|
||||
if (data.Submissions && data.Submissions.length > 0) {
|
||||
data.Submissions.forEach((sub) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `
|
||||
<span class="suggestor">${escapeHtml(sub.submitter)} SUGGESTED</span> :
|
||||
<span class="suggestion">${escapeHtml(sub.submission)}</span>
|
||||
<input type="radio" name="vote" value="${escapeHtml(sub.submission)}" data-submitter="${escapeHtml(sub.submitter)}" data-time="${sub.submission_time}">
|
||||
`;
|
||||
submissionList.appendChild(li);
|
||||
});
|
||||
} else {
|
||||
submissionList.innerHTML = '<li>No submissions available</li>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading submissions:', error);
|
||||
document.getElementById('suggestion_list').innerHTML = '<li>Error loading submissions</li>';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Voting ---
|
||||
|
||||
async function submitVote() {
|
||||
if (!currentUser) {
|
||||
alert('Please sign in first');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRadio = document.querySelector('input[name="vote"]:checked');
|
||||
if (!selectedRadio) {
|
||||
alert('Please select a title to vote for');
|
||||
return;
|
||||
}
|
||||
|
||||
const voteData = {
|
||||
selected_submission: selectedRadio.value,
|
||||
submitter: selectedRadio.dataset.submitter,
|
||||
submission_time: parseInt(selectedRadio.dataset.time)
|
||||
};
|
||||
|
||||
try {
|
||||
if (authMethod === 'matrix') {
|
||||
const resp = await fetch('matrixVote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_token: currentUser.sessionToken,
|
||||
...voteData
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
alert('Vote submitted successfully!');
|
||||
selectedRadio.checked = false;
|
||||
} else {
|
||||
const text = await resp.text();
|
||||
alert('Error: ' + text);
|
||||
}
|
||||
} else if (authMethod === 'nostr') {
|
||||
const challengeResp = await fetch('challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ npub: currentUser.npub })
|
||||
});
|
||||
|
||||
if (!challengeResp.ok) {
|
||||
const text = await challengeResp.text();
|
||||
alert('Error getting challenge: ' + text);
|
||||
return;
|
||||
}
|
||||
|
||||
const challengeData = await challengeResp.json();
|
||||
const challengeMsg = challengeData.challenge;
|
||||
|
||||
const signedEvent = await window.nostr.signEvent({
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: challengeMsg
|
||||
});
|
||||
|
||||
const voteResp = await fetch('verifyVote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
npub: currentUser.npub,
|
||||
challenge: challengeMsg,
|
||||
...voteData,
|
||||
signed_event: signedEvent
|
||||
})
|
||||
});
|
||||
|
||||
if (voteResp.ok) {
|
||||
alert('Vote submitted successfully!');
|
||||
selectedRadio.checked = false;
|
||||
} else {
|
||||
const text = await voteResp.text();
|
||||
alert('Error: ' + text);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting vote:', error);
|
||||
alert('Error submitting vote: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
setInterval(() => {
|
||||
if (currentUser) loadSubmissions();
|
||||
}, 30000);
|
||||
|
||||
const matrixAuthed = await checkMatrixSession();
|
||||
if (matrixAuthed) return;
|
||||
|
||||
if (hasNip07()) {
|
||||
document.getElementById('nip07_badge').style.display = 'block';
|
||||
}
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Link Nostr Identity</title>
|
||||
<link rel="stylesheet" href="css.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Link Your Nostr Identity</h1>
|
||||
<p>Connect your npub to your Matrix display name so you can vote.</p>
|
||||
|
||||
<div id="auth_section">
|
||||
<div id="nip07_status" class="info-box" style="display:none;">
|
||||
Nostr browser extension detected.
|
||||
</div>
|
||||
|
||||
<label for="display_name">Matrix Display Name:</label>
|
||||
<input type="text" id="display_name" placeholder="Enter your Matrix display name exactly">
|
||||
|
||||
<label for="npub_input">Nostr Public Key (npub):</label>
|
||||
<input type="text" id="npub_input" placeholder="npub1...">
|
||||
|
||||
<button id="connect_btn" onclick="connectNip07()">Connect AlbyHub</button>
|
||||
<button id="link_btn" onclick="linkNpub()">Link & Sign</button>
|
||||
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentNpub = null;
|
||||
|
||||
async function connectNip07() {
|
||||
if (!window.nostr) {
|
||||
setStatus('No Nostr browser extension detected. Please enter your npub manually.', true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const resp = await fetch('encode_npub', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({hex: pubkey})
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
document.getElementById('npub_input').value = data.npub;
|
||||
currentNpub = data.npub;
|
||||
setStatus('Connected! Npub loaded from extension.');
|
||||
} else {
|
||||
document.getElementById('npub_input').value = pubkey;
|
||||
currentNpub = pubkey;
|
||||
setStatus('Connected! Paste this as npub if needed: ' + pubkey);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error connecting to extension: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function linkNpub() {
|
||||
const displayName = document.getElementById('display_name').value.trim();
|
||||
const npub = document.getElementById('npub_input').value.trim();
|
||||
|
||||
if (!displayName) {
|
||||
setStatus('Please enter your Matrix display name.', true);
|
||||
return;
|
||||
}
|
||||
if (!npub) {
|
||||
setStatus('Please enter your npub or connect AlbyHub.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Requesting signature...');
|
||||
|
||||
try {
|
||||
let npubHex = npub;
|
||||
|
||||
const decodeResp = await fetch('decode_npub', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({npub: npub})
|
||||
});
|
||||
|
||||
if (!decodeResp.ok) {
|
||||
if (!window.nostr) {
|
||||
setStatus('Could not decode npub. Please install a Nostr extension.', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Could not decode npub via server.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const decodeData = await decodeResp.json();
|
||||
npubHex = decodeData.hex;
|
||||
|
||||
const slug = window.location.pathname.split('/')[1];
|
||||
const linkMsg = `Link npub ${npubHex} to Matrix user ${displayName} for room ${slug}`;
|
||||
|
||||
let signedEvent;
|
||||
if (window.nostr) {
|
||||
signedEvent = await window.nostr.signEvent({
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: linkMsg
|
||||
});
|
||||
} else {
|
||||
setStatus('No Nostr extension available. Please install AlbyHub or another NIP-07 signer.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Sending link request...');
|
||||
|
||||
const resp = await fetch('linkNpub', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
display_name: displayName,
|
||||
npub: npub,
|
||||
signed_event: signedEvent
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
setStatus('Successfully linked! You can now <a href="/">go vote</a>.');
|
||||
document.getElementById('link_btn').disabled = true;
|
||||
} else {
|
||||
const text = await resp.text();
|
||||
setStatus('Error: ' + text, true);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
const el = document.getElementById('status');
|
||||
el.innerHTML = msg;
|
||||
el.className = isError ? 'error' : 'success';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,149 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in with Matrix</title>
|
||||
<link rel="stylesheet" href="css.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Sign in with Matrix</h1>
|
||||
<p>Log in with your Matrix account to vote.</p>
|
||||
|
||||
<div id="login_form">
|
||||
<label for="homeserver">Homeserver:</label>
|
||||
<input type="text" id="homeserver" placeholder="https://matrix.org">
|
||||
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" id="username" placeholder="@username:matrix.org">
|
||||
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" placeholder="Password">
|
||||
|
||||
<button id="login_btn" onclick="matrixLogin()">Sign In</button>
|
||||
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<a id="back_link" href="../">Back to voting page</a>
|
||||
|
||||
<script>
|
||||
async function init() {
|
||||
const parts = window.location.pathname.split('/').filter(Boolean);
|
||||
const slug = parts[0];
|
||||
|
||||
if (!slug) {
|
||||
setStatus('Invalid URL. Go to the voting page first.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/' + slug + '/roomConfig');
|
||||
if (resp.ok) {
|
||||
const config = await resp.json();
|
||||
document.getElementById('homeserver').value = config.homeserver || 'https://matrix.org';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('homeserver').value = 'https://matrix.org';
|
||||
}
|
||||
|
||||
const sessionToken = sessionStorage.getItem('matrix_session_token');
|
||||
if (sessionToken) {
|
||||
window.location.href = '/' + slug + '/';
|
||||
}
|
||||
}
|
||||
|
||||
async function matrixLogin() {
|
||||
const homeserver = document.getElementById('homeserver').value.trim().replace(/\/+$/, '');
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!homeserver || !username || !password) {
|
||||
setStatus('All fields are required.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Checking homeserver...');
|
||||
document.getElementById('login_btn').disabled = true;
|
||||
|
||||
try {
|
||||
const loginTypesResp = await fetch(homeserver + '/_matrix/client/v3/login');
|
||||
if (!loginTypesResp.ok) {
|
||||
setStatus('Could not reach homeserver. Check the URL.', true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const loginTypes = await loginTypesResp.json();
|
||||
const supportsPassword = loginTypes.flows && loginTypes.flows.some(f => f.type === 'm.login.password');
|
||||
|
||||
if (!supportsPassword) {
|
||||
setStatus('This homeserver does not support password login. Try the Nostr option instead.', true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Logging in...');
|
||||
|
||||
const loginResp = await fetch(homeserver + '/_matrix/client/v3/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'm.login.password',
|
||||
identifier: { type: 'm.id.user', user: username },
|
||||
password: password
|
||||
})
|
||||
});
|
||||
|
||||
if (!loginResp.ok) {
|
||||
setStatus('Login failed: ' + (loginResp.status === 403 ? 'Invalid credentials' : 'Homeserver error'), true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const loginData = await loginResp.json();
|
||||
const accessToken = loginData.access_token;
|
||||
|
||||
setStatus('Verifying identity...');
|
||||
|
||||
const parts = window.location.pathname.split('/').filter(Boolean);
|
||||
const slug = parts[0];
|
||||
|
||||
const authResp = await fetch('/' + slug + '/matrixAuth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
homeserver: homeserver
|
||||
})
|
||||
});
|
||||
|
||||
if (!authResp.ok) {
|
||||
const errText = await authResp.text();
|
||||
setStatus('Verification failed: ' + errText, true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const authData = await authResp.json();
|
||||
|
||||
sessionStorage.setItem('matrix_session_token', authData.session_token);
|
||||
|
||||
window.location.href = '/' + slug + '/';
|
||||
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, true);
|
||||
document.getElementById('login_btn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
const el = document.getElementById('status');
|
||||
el.innerHTML = msg;
|
||||
el.className = isError ? 'error' : 'success';
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,51 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pick a Title!</title>
|
||||
<link rel="stylesheet" href="css.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Choose Your Favorite Title</h1>
|
||||
|
||||
<div id="auth_section">
|
||||
<div id="auth_method_select">
|
||||
<p>How would you like to sign in?</p>
|
||||
<button id="matrix_auth_btn" onclick="goToMatrixLogin()">Sign in with Matrix</button>
|
||||
<button id="nostr_auth_btn" onclick="showNostrAuth()">Sign in with Nostr</button>
|
||||
</div>
|
||||
|
||||
<div id="nostr_auth_section" style="display:none;">
|
||||
<div id="nip07_badge" class="info-box" style="display:none;">
|
||||
Nostr extension connected
|
||||
</div>
|
||||
|
||||
<div id="auth_npub_row">
|
||||
<label for="npub_input">Nostr Public Key:</label>
|
||||
<input type="text" id="npub_input" placeholder="npub1... or connect extension">
|
||||
<button id="connect_btn" onclick="connectNip07()">Connect AlbyHub</button>
|
||||
</div>
|
||||
|
||||
<button id="auth_btn" onclick="authenticate()">Sign In to Vote</button>
|
||||
<a id="link_link" href="link">First time? Link your npub</a>
|
||||
|
||||
<div id="auth_status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vote_section" style="display:none;">
|
||||
<div id="logged_in_as"></div>
|
||||
<button id="sign_out_btn" onclick="signOut()">Sign Out</button>
|
||||
|
||||
<h2>Suggestions:</h2>
|
||||
<ul id="suggestion_list">
|
||||
<!-- Populated Dynamically Via JS -->
|
||||
</ul>
|
||||
|
||||
<button id="submit_vote" onclick="submitVote()">⛵⛵⛵⛵⛵⛵⛵⛵⛵</button>
|
||||
</div>
|
||||
|
||||
<script src="js.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
-1238
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user