refact round 2
This commit is contained in:
133
content/catalog.go
Normal file
133
content/catalog.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"bbs-server/engine"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const GamesAPIURL = "https://games.teletype.hu/api/software"
|
||||
|
||||
type softwareEntry struct {
|
||||
Software struct {
|
||||
Title string `json:"title"`
|
||||
Platform string `json:"platform"`
|
||||
Author string `json:"author"`
|
||||
Desc string `json:"desc"`
|
||||
} `json:"software"`
|
||||
Releases []interface{} `json:"releases"`
|
||||
LatestRelease *struct {
|
||||
Version string `json:"version"`
|
||||
HTMLFolderPath string `json:"htmlFolderPath"`
|
||||
CartridgePath string `json:"cartridgePath"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
DocsFolderPath string `json:"docsFolderPath"`
|
||||
} `json:"latestRelease"`
|
||||
}
|
||||
|
||||
type catalogRepository struct{}
|
||||
|
||||
func (r *catalogRepository) fetchGames() ([]softwareEntry, error) {
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
resp, err := client.Get(GamesAPIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Softwares []softwareEntry `json:"softwares"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Softwares, nil
|
||||
}
|
||||
|
||||
// CatalogHandler renders the game catalog
|
||||
type CatalogHandler struct {
|
||||
repo *catalogRepository
|
||||
}
|
||||
|
||||
func NewCatalogHandler() *CatalogHandler {
|
||||
return &CatalogHandler{repo: &catalogRepository{}}
|
||||
}
|
||||
|
||||
// Show displays the game catalog
|
||||
func (h *CatalogHandler) Show(s *engine.Session) {
|
||||
s.Printer.BoxHeader(s.Lang["CatTitle"], engine.YL)
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, s.Lang["WikiLoading"], engine.R))
|
||||
|
||||
softwares, err := h.repo.fetchGames()
|
||||
if err != nil {
|
||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", engine.RD, s.Lang["WikiConnError"], err, engine.R))
|
||||
s.Printer.Pause(s.Lang)
|
||||
return
|
||||
}
|
||||
|
||||
s.Printer.Send("\r\033[A\033[2K")
|
||||
|
||||
if len(softwares) == 0 {
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, s.Lang["CatNoGames"], engine.R))
|
||||
s.Printer.Pause(s.Lang)
|
||||
return
|
||||
}
|
||||
|
||||
base := "https://games.teletype.hu"
|
||||
|
||||
for i, entry := range softwares {
|
||||
sw := entry.Software
|
||||
lr := entry.LatestRelease
|
||||
|
||||
s.Printer.Send(fmt.Sprintf("\r\n %s%s%s\r\n", engine.YL, strings.Repeat("─", engine.W-4), engine.R))
|
||||
s.Printer.Send(fmt.Sprintf(" %s%2d.%s %s%s%s%s %s[%s] by %s%s\r\n",
|
||||
engine.YL, i+1, engine.R,
|
||||
engine.WH, engine.B, sw.Title, engine.R,
|
||||
engine.GY, sw.Platform, sw.Author, engine.R))
|
||||
|
||||
if sw.Desc != "" {
|
||||
wrappedDesc := s.Printer.Wrapped(sw.Desc, 7, 1000)
|
||||
for _, line := range strings.Split(wrappedDesc, "\r\n") {
|
||||
s.Printer.Send(fmt.Sprintf("%s%s\r\n", engine.GY, line))
|
||||
}
|
||||
}
|
||||
|
||||
if lr != nil {
|
||||
badges := []string{}
|
||||
if lr.HTMLFolderPath != "" {
|
||||
badges = append(badges, fmt.Sprintf("%s[▶ Play]%s", engine.GR, engine.R))
|
||||
}
|
||||
if lr.CartridgePath != "" {
|
||||
badges = append(badges, fmt.Sprintf("%s[⬇ Download]%s", engine.BL, engine.R))
|
||||
}
|
||||
if lr.SourcePath != "" {
|
||||
badges = append(badges, fmt.Sprintf("%s[Source]%s", engine.MG, engine.R))
|
||||
}
|
||||
if lr.DocsFolderPath != "" {
|
||||
badges = append(badges, fmt.Sprintf("%s[Docs]%s", engine.YL, engine.R))
|
||||
}
|
||||
|
||||
badgeStr := "–"
|
||||
if len(badges) > 0 {
|
||||
badgeStr = strings.Join(badges, " ")
|
||||
}
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s: v%s%s %s\r\n",
|
||||
engine.GY, s.Lang["CatLatest"], lr.Version, engine.R, badgeStr))
|
||||
|
||||
if lr.HTMLFolderPath != "" {
|
||||
url := base + lr.HTMLFolderPath
|
||||
s.Printer.Send(fmt.Sprintf(" %s▶ %s%s\r\n", engine.DIM, url, engine.R))
|
||||
}
|
||||
}
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n",
|
||||
engine.GY, fmt.Sprintf(s.Lang["CatVersions"], len(entry.Releases)), engine.R))
|
||||
}
|
||||
|
||||
s.Printer.HR("─", engine.YL)
|
||||
s.Printer.Send("\r\n")
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s: %s%s\r\n", engine.GY, s.Lang["CatFull"], base, engine.R))
|
||||
s.Printer.Pause(s.Lang)
|
||||
}
|
||||
121
content/messages.go
Normal file
121
content/messages.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"bbs-server/engine"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type message struct {
|
||||
Timestamp string
|
||||
User string
|
||||
Text string
|
||||
}
|
||||
|
||||
// MessageBoard holds message board data and its handler
|
||||
type MessageBoard struct {
|
||||
messages []message
|
||||
mu sync.Mutex
|
||||
path string
|
||||
}
|
||||
|
||||
// NewMessageBoard loads messages from the given .dat file (if it exists)
|
||||
func NewMessageBoard(path string) *MessageBoard {
|
||||
b := &MessageBoard{path: path}
|
||||
b.load()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *MessageBoard) load() {
|
||||
f, err := os.Open(b.path)
|
||||
if err != nil {
|
||||
return // file does not exist yet, start empty
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
r := csv.NewReader(f)
|
||||
records, err := r.ReadAll()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, row := range records {
|
||||
if len(row) != 3 {
|
||||
continue
|
||||
}
|
||||
b.messages = append(b.messages, message{Timestamp: row[0], User: row[1], Text: row[2]})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MessageBoard) append(msg message) error {
|
||||
f, err := os.OpenFile(b.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := csv.NewWriter(f)
|
||||
err = w.Write([]string{msg.Timestamp, msg.User, msg.Text})
|
||||
w.Flush()
|
||||
return err
|
||||
}
|
||||
|
||||
// Count returns the number of messages in a thread-safe manner
|
||||
func (b *MessageBoard) Count() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return len(b.messages)
|
||||
}
|
||||
|
||||
// Show displays the message board and handles new message input
|
||||
func (b *MessageBoard) Show(s *engine.Session) {
|
||||
s.Printer.BoxHeader(s.Lang["MsgBoardTitle"], engine.GR)
|
||||
|
||||
b.mu.Lock()
|
||||
snap := make([]message, len(b.messages))
|
||||
copy(snap, b.messages)
|
||||
b.mu.Unlock()
|
||||
|
||||
if len(snap) == 0 {
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, s.Lang["MsgNoMessages"], engine.R))
|
||||
} else {
|
||||
start := 0
|
||||
if len(snap) > 30 {
|
||||
start = len(snap) - 30
|
||||
}
|
||||
for i, msg := range snap[start:] {
|
||||
s.Printer.Send(fmt.Sprintf(" %s%02d%s %s%s%s %s%s:%s %s\r\n",
|
||||
engine.GR, i+1, engine.R,
|
||||
engine.GY, msg.Timestamp, engine.R,
|
||||
engine.WH, msg.User, engine.R, msg.Text))
|
||||
}
|
||||
}
|
||||
|
||||
s.Printer.Send(fmt.Sprintf("\r\n%s[N]%s %s %s[ENTER]%s %s → ",
|
||||
engine.GY, engine.R, s.Lang["MsgNew"],
|
||||
engine.GY, engine.R, s.Lang["MsgBack"]))
|
||||
|
||||
choice, _ := s.Printer.ReadLine()
|
||||
if strings.ToUpper(strings.TrimSpace(choice)) == "N" {
|
||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", engine.WH, s.Lang["MsgEnterText"], engine.R))
|
||||
msgText, _ := s.Printer.ReadLine()
|
||||
msgText = strings.TrimSpace(msgText)
|
||||
if msgText != "" {
|
||||
if len(msgText) > 200 {
|
||||
msgText = msgText[:200]
|
||||
}
|
||||
ts := time.Now().Format("01-02 15:04")
|
||||
msg := message{Timestamp: ts, User: s.Username, Text: msgText}
|
||||
b.mu.Lock()
|
||||
b.messages = append(b.messages, msg)
|
||||
b.mu.Unlock()
|
||||
b.append(msg)
|
||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", engine.GR, s.Lang["MsgSent"], engine.R))
|
||||
} else {
|
||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", engine.GY, s.Lang["MsgEmpty"], engine.R))
|
||||
}
|
||||
}
|
||||
}
|
||||
32
content/online.go
Normal file
32
content/online.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"bbs-server/engine"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Online displays currently logged-in users
|
||||
func Online(s *engine.Session) {
|
||||
s.Printer.BoxHeader(s.Lang["OnlineTitle"], engine.CY)
|
||||
|
||||
snap := s.State.Snapshot()
|
||||
keys := make([]string, 0, len(snap))
|
||||
for k := range snap {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, addr := range keys {
|
||||
user := snap[addr]
|
||||
marker := ""
|
||||
if addr == s.Addr {
|
||||
marker = fmt.Sprintf(" %s%s%s", engine.GR, s.Lang["OnlineYou"], engine.R)
|
||||
}
|
||||
s.Printer.Send(fmt.Sprintf(" %s•%s %s%s%s%s\r\n", engine.CY, engine.R, engine.WH, user, engine.R, marker))
|
||||
}
|
||||
|
||||
s.Printer.Send(fmt.Sprintf("\r\n %s%s\r\n", engine.GY,
|
||||
fmt.Sprintf(s.Lang["OnlineTotal"], engine.WH, len(snap), engine.GY, engine.R)))
|
||||
s.Printer.Pause(s.Lang)
|
||||
}
|
||||
32
content/sysinfo.go
Normal file
32
content/sysinfo.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"bbs-server/engine"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sysinfo returns a HandlerFunc that displays system information
|
||||
func Sysinfo(board *MessageBoard) engine.HandlerFunc {
|
||||
return func(s *engine.Session) {
|
||||
s.Printer.BoxHeader(s.Lang["SysInfoTitle"], engine.GY)
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
|
||||
rows := [][]string{
|
||||
{s.Lang["SysServerTime"], now},
|
||||
{s.Lang["SysOnlineUsers"], strconv.Itoa(s.State.UserCount())},
|
||||
{s.Lang["SysMsgCount"], strconv.Itoa(board.Count())},
|
||||
{s.Lang["SysWikiURL"], WikiJSBaseURL},
|
||||
{s.Lang["SysGamesAPI"], GamesAPIURL},
|
||||
{s.Lang["SysPlatform"], "Go BBS v2.0"},
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
s.Printer.Send(fmt.Sprintf(" %s%-18s%s %s%s%s\r\n",
|
||||
engine.GY, row[0], engine.R, engine.WH, row[1], engine.R))
|
||||
}
|
||||
s.Printer.Pause(s.Lang)
|
||||
}
|
||||
}
|
||||
203
content/wiki.go
Normal file
203
content/wiki.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"bbs-server/engine"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const WikiJSBaseURL = "https://wiki.teletype.hu"
|
||||
|
||||
type wikiPage struct {
|
||||
ID interface{} `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
type wikiRepository struct {
|
||||
token string
|
||||
}
|
||||
|
||||
func (r *wikiRepository) graphql(query string) (map[string]interface{}, error) {
|
||||
payload := map[string]string{"query": query}
|
||||
data, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", WikiJSBaseURL+"/graphql", bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if r.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *wikiRepository) fetchList(tag string) ([]wikiPage, error) {
|
||||
q := fmt.Sprintf(`{ pages { list(orderBy: CREATED, orderByDirection: DESC, tags: ["%s"]) { id path title description createdAt updatedAt locale } } }`, tag)
|
||||
res, err := r.graphql(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := res["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid response format")
|
||||
}
|
||||
pages, ok := data["pages"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid response format")
|
||||
}
|
||||
listRaw, ok := pages["list"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid response format")
|
||||
}
|
||||
|
||||
var list []wikiPage
|
||||
jsonBytes, _ := json.Marshal(listRaw)
|
||||
json.Unmarshal(jsonBytes, &list)
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *wikiRepository) fetchContent(pageID interface{}) (string, error) {
|
||||
var idStr string
|
||||
switch v := pageID.(type) {
|
||||
case string:
|
||||
idStr = v
|
||||
case float64:
|
||||
idStr = fmt.Sprintf("%.0f", v)
|
||||
default:
|
||||
idStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`{ pages { single(id: %s) { content } } }`, idStr)
|
||||
res, err := r.graphql(q)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data := res["data"].(map[string]interface{})
|
||||
pages := data["pages"].(map[string]interface{})
|
||||
single := pages["single"].(map[string]interface{})
|
||||
return single["content"].(string), nil
|
||||
}
|
||||
|
||||
// WikiHandler renders Wiki.js content in the BBS
|
||||
type WikiHandler struct {
|
||||
repo *wikiRepository
|
||||
}
|
||||
|
||||
func NewWikiHandler(token string) *WikiHandler {
|
||||
return &WikiHandler{repo: &wikiRepository{token: token}}
|
||||
}
|
||||
|
||||
// List returns a HandlerFunc that lists pages with the given tag
|
||||
func (h *WikiHandler) List(tag, title, color string) engine.HandlerFunc {
|
||||
return func(s *engine.Session) {
|
||||
s.Printer.BoxHeader(title, color)
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, s.Lang["WikiLoading"], engine.R))
|
||||
|
||||
pages, err := h.repo.fetchList(tag)
|
||||
if err != nil {
|
||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", engine.RD, s.Lang["WikiConnError"], err, engine.R))
|
||||
s.Printer.Pause(s.Lang)
|
||||
return
|
||||
}
|
||||
|
||||
s.Printer.Send("\r\033[A\033[2K")
|
||||
|
||||
if len(pages) == 0 {
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, fmt.Sprintf(s.Lang["WikiNoResults"], tag), engine.R))
|
||||
s.Printer.Pause(s.Lang)
|
||||
return
|
||||
}
|
||||
|
||||
maxIdx := 25
|
||||
if len(pages) < maxIdx {
|
||||
maxIdx = len(pages)
|
||||
}
|
||||
|
||||
for i, p := range pages[:maxIdx] {
|
||||
date := s.Printer.FmtDate(p.CreatedAt)
|
||||
if date == "–" {
|
||||
date = s.Printer.FmtDate(p.UpdatedAt)
|
||||
}
|
||||
titleP := p.Title
|
||||
if titleP == "" {
|
||||
titleP = p.Path
|
||||
}
|
||||
if len(titleP) > 55 {
|
||||
titleP = titleP[:55]
|
||||
}
|
||||
desc := p.Description
|
||||
if len(desc) > 60 {
|
||||
desc = desc[:60]
|
||||
}
|
||||
s.Printer.Send(fmt.Sprintf(" %s%2d%s %s%s%s\r\n", color, i+1, engine.R, engine.WH, titleP, engine.R))
|
||||
if desc != "" {
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.GY, date, engine.DIM, desc, engine.R))
|
||||
} else {
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, date, engine.R))
|
||||
}
|
||||
}
|
||||
|
||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", engine.GY, s.Lang["WikiEnterNum"], engine.R))
|
||||
choice, _ := s.Printer.ReadLine()
|
||||
choice = strings.TrimSpace(choice)
|
||||
idx, err := strconv.Atoi(choice)
|
||||
if err != nil || idx < 1 || idx > len(pages) {
|
||||
return
|
||||
}
|
||||
|
||||
page := pages[idx-1]
|
||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s", engine.GY, s.Lang["WikiFetchContent"], engine.R))
|
||||
pageContent, err := h.repo.fetchContent(page.ID)
|
||||
if err != nil {
|
||||
s.Printer.Send(fmt.Sprintf("\r\n%sHiba: %v%s\r\n", engine.RD, err, engine.R))
|
||||
s.Printer.Pause(s.Lang)
|
||||
return
|
||||
}
|
||||
|
||||
s.Printer.Send("\r\033[A\033[2K")
|
||||
s.Printer.Send("\r\n")
|
||||
s.Printer.HR("═", color)
|
||||
s.Printer.Send("\r\n")
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s%s%s\r\n", engine.WH, engine.B, page.Title, engine.R))
|
||||
url := fmt.Sprintf("%s/%s/%s", WikiJSBaseURL, page.Locale, page.Path)
|
||||
s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.GY, s.Printer.FmtDate(page.CreatedAt), engine.DIM, url, engine.R))
|
||||
s.Printer.HR("─", engine.GY)
|
||||
s.Printer.Send("\r\n\r\n")
|
||||
|
||||
body := s.Printer.Wrapped(pageContent, 2, 5000)
|
||||
for _, line := range strings.Split(body, "\r\n") {
|
||||
s.Printer.Send(line + "\r\n")
|
||||
}
|
||||
|
||||
s.Printer.Send("\r\n")
|
||||
s.Printer.HR("─", engine.GY)
|
||||
s.Printer.Send("\r\n")
|
||||
s.Printer.Pause(s.Lang)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user