FIRST
This commit is contained in:
Executable
BIN
Binary file not shown.
Executable
+8
@@ -0,0 +1,8 @@
|
||||
module jep
|
||||
|
||||
go 1.24.9
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
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=
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
+373
@@ -0,0 +1,373 @@
|
||||
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))
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
# 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
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
go
|
||||
gcc
|
||||
sqlite
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export CGO_ENABLED=1
|
||||
echo "Go development environment ready with CGO support"
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user