374 lines
8.9 KiB
Go
Executable File
374 lines
8.9 KiB
Go
Executable File
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))
|
|
}
|