Files
titlebot-ng-ng/jsonEndpoint/main.go
T

374 lines
8.9 KiB
Go
Raw Normal View History

2026-08-06 21:34:52 -05:00
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))
}