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)) }