From d843df816af51152940e6748c987a7a9070492a8 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 11 Mar 2026 07:28:14 +0100 Subject: [PATCH] refact round 2 --- .gitignore | 1 + content/catalog.go | 133 +++++++++++++++++ content/messages.go | 121 +++++++++++++++ content/online.go | 32 ++++ content/sysinfo.go | 32 ++++ content/wiki.go | 203 ++++++++++++++++++++++++++ data/.keep | 0 docker-compose.prod.yaml | 8 +- docker-compose.yaml | 2 + lib/sys.i18n.go => engine/i18n.go | 19 +-- engine/menu.go | 78 ++++++++++ lib/sys.print.go => engine/printer.go | 17 +-- engine/server.go | 123 ++++++++++++++++ engine/session.go | 43 ++++++ lib/menu.catalog.go | 76 ---------- lib/menu.online.go | 33 ----- lib/menu.sysinfo.go | 31 ---- lib/menu.uzenopal.go | 47 ------ lib/menu.wiki.go | 93 ------------ lib/repository.catalog.go | 49 ------- lib/repository.wiki.go | 106 -------------- lib/sys.header.go | 102 ------------- main.go | 144 ++++++------------ 23 files changed, 834 insertions(+), 659 deletions(-) create mode 100644 content/catalog.go create mode 100644 content/messages.go create mode 100644 content/online.go create mode 100644 content/sysinfo.go create mode 100644 content/wiki.go create mode 100644 data/.keep rename lib/sys.i18n.go => engine/i18n.go (76%) create mode 100644 engine/menu.go rename lib/sys.print.go => engine/printer.go (96%) create mode 100644 engine/server.go create mode 100644 engine/session.go delete mode 100644 lib/menu.catalog.go delete mode 100644 lib/menu.online.go delete mode 100644 lib/menu.sysinfo.go delete mode 100644 lib/menu.uzenopal.go delete mode 100644 lib/menu.wiki.go delete mode 100644 lib/repository.catalog.go delete mode 100644 lib/repository.wiki.go delete mode 100644 lib/sys.header.go diff --git a/.gitignore b/.gitignore index fe6a153..bdeb5b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env bbs-server tmp +data/messages.dat \ No newline at end of file diff --git a/content/catalog.go b/content/catalog.go new file mode 100644 index 0000000..55702f6 --- /dev/null +++ b/content/catalog.go @@ -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) +} diff --git a/content/messages.go b/content/messages.go new file mode 100644 index 0000000..53e8cf1 --- /dev/null +++ b/content/messages.go @@ -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)) + } + } +} diff --git a/content/online.go b/content/online.go new file mode 100644 index 0000000..e7433f4 --- /dev/null +++ b/content/online.go @@ -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) +} diff --git a/content/sysinfo.go b/content/sysinfo.go new file mode 100644 index 0000000..2987518 --- /dev/null +++ b/content/sysinfo.go @@ -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) + } +} diff --git a/content/wiki.go b/content/wiki.go new file mode 100644 index 0000000..dd1a254 --- /dev/null +++ b/content/wiki.go @@ -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) + } +} diff --git a/data/.keep b/data/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 46380d2..26cbb3c 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -8,6 +8,9 @@ services: - "2323:2323" environment: - WEBAPP_WIKIJS_TOKEN=${WEBAPP_WIKIJS_TOKEN} + - MESSAGES_PATH=/data/messages.dat + volumes: + - bbs-messages:/data restart: always read_only: true tmpfs: @@ -15,4 +18,7 @@ services: cap_drop: - ALL security_opt: - - no-new-privileges:true \ No newline at end of file + - no-new-privileges:true + +volumes: + bbs-messages: \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 76f390e..e15e450 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,8 +8,10 @@ services: - "2323:2323" volumes: - ./:/app/ + - ./data:/data environment: - WEBAPP_WIKIJS_TOKEN=${WEBAPP_WIKIJS_TOKEN:-} + - MESSAGES_PATH=/data/messages.dat restart: unless-stopped stdin_open: true tty: true diff --git a/lib/sys.i18n.go b/engine/i18n.go similarity index 76% rename from lib/sys.i18n.go rename to engine/i18n.go index 8aae750..778acd9 100644 --- a/lib/sys.i18n.go +++ b/engine/i18n.go @@ -1,22 +1,15 @@ -package lib +package engine +// T is the i18n dictionary type type T map[string]string +// En is the default English dictionary var En = T{ - "Welcome": "Welcome to Teletype's Bulletin Board System!", "AskName": "Enter your name:", "Greeting": "Hello, %s%s%s! Welcome to Teletype BBS!", - "MainMenuTitle": "MAIN MENU", - "MenuUzenopal": "Message Board", - "MenuBlog": "Blog Posts", - "MenuHowto": "HowTo Guides", - "MenuCatalog": "Game Catalog", - "MenuOnline": "Online Users", - "MenuSysinfo": "System Info", - "MenuExit": "Exit", - "Choice": "Choice: ", - "Pause": "Press ENTER...", "Goodbye": "Goodbye, %s! 👋", + "Pause": "Press ENTER...", + "Choice": "Choice: ", "MsgBoardTitle": "📋 MESSAGE BOARD", "MsgNoMessages": "(No messages yet — be the first!)", "MsgNew": "Write new message", @@ -44,6 +37,4 @@ var En = T{ "SysWikiURL": "Wiki URL", "SysGamesAPI": "Games API", "SysPlatform": "Platform", - "SysYes": "yes", - "SysNo": "no", } diff --git a/engine/menu.go b/engine/menu.go new file mode 100644 index 0000000..64d9256 --- /dev/null +++ b/engine/menu.go @@ -0,0 +1,78 @@ +package engine + +import ( + "fmt" + "strings" +) + +// HandlerFunc is the handler type for a menu item +type HandlerFunc func(*Session) + +// Exit is a built-in handler that terminates the session +var Exit HandlerFunc = func(s *Session) { s.Quit = true } + +type menuItem struct { + Key string + Label string + Color string + Handler HandlerFunc +} + +// Menu defines the BBS main menu using a declarative DSL +type Menu struct { + title string + items []menuItem +} + +// Title sets the menu header +func (m *Menu) Title(t string) { + m.title = t +} + +// Item adds a menu entry (key, display label, ANSI color, handler) +func (m *Menu) Item(key, label, color string, handler HandlerFunc) { + m.items = append(m.items, menuItem{ + Key: strings.ToUpper(key), + Label: label, + Color: color, + Handler: handler, + }) +} + +// Dispatch finds and calls the handler for the given key +func (m *Menu) Dispatch(s *Session, key string) { + key = strings.ToUpper(strings.TrimSpace(key)) + for _, item := range m.items { + if item.Key == key { + item.Handler(s) + return + } + } +} + +// Render returns the rendered menu string +func (m *Menu) Render(p *Printer, lang T, username string) string { + headerLine := strings.Repeat("═", W) + l1 := p.PadLine(fmt.Sprintf(" %s%s%s %s@%s%s", YL, m.title, R, GY, username, R), W) + + var rows []string + for i := 0; i < len(m.items); i += 2 { + if i+1 < len(m.items) { + left := p.PadLine(fmt.Sprintf(" %s[%s]%s %s", m.items[i].Color, m.items[i].Key, R, m.items[i].Label), W/2) + right := p.PadLine(fmt.Sprintf(" %s[%s]%s %s", m.items[i+1].Color, m.items[i+1].Key, R, m.items[i+1].Label), W/2) + rows = append(rows, left+right) + } else { + rows = append(rows, p.PadLine(fmt.Sprintf(" %s[%s]%s %s", m.items[i].Color, m.items[i].Key, R, m.items[i].Label), W)) + } + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("\n%s╔%s╗%s\n", WH, headerLine, R)) + sb.WriteString(fmt.Sprintf("%s║%s%s║%s\n", WH, l1, WH, R)) + sb.WriteString(fmt.Sprintf("%s╠%s╣%s\n", WH, headerLine, R)) + for _, row := range rows { + sb.WriteString(fmt.Sprintf("%s║%s%s║%s\n", WH, row, WH, R)) + } + sb.WriteString(fmt.Sprintf("%s╚%s╝%s\n%s", WH, headerLine, R, lang["Choice"])) + return sb.String() +} diff --git a/lib/sys.print.go b/engine/printer.go similarity index 96% rename from lib/sys.print.go rename to engine/printer.go index d93483f..e30c199 100644 --- a/lib/sys.print.go +++ b/engine/printer.go @@ -1,4 +1,4 @@ -package lib +package engine import ( "bytes" @@ -10,7 +10,7 @@ import ( "unicode/utf8" ) -// ANSI Colors +// ANSI color codes const ( R = "\033[0m" B = "\033[1m" @@ -25,14 +25,9 @@ const ( GY = "\033[0;37m" ) +// W is the default terminal width const W = 70 -type Message struct { - User string - Timestamp string - Text string -} - type Printer struct { Conn net.Conn } @@ -152,7 +147,7 @@ func (p *Printer) StripMD(text string) string { reImg := regexp.MustCompile(`!\[.*?\]\(.*?\)|\[(.*?)\]\(.*?\)|#{1,6}\s*|[*_` + "`" + `~>|]`) text = reImg.ReplaceAllStringFunc(text, func(s string) string { if strings.HasPrefix(s, "[") { - sub := regexp.MustCompile(`\[(.*?)\]\(.*?\)` ) + sub := regexp.MustCompile(`\[(.*?)\]\(.*?\)`) matches := sub.FindStringSubmatch(s) if len(matches) > 1 { return matches[1] @@ -181,12 +176,12 @@ func (p *Printer) Wrapped(text string, indent int, maxChars int) string { lines = append(lines, "") continue } - + words := strings.Fields(para) if len(words) == 0 { continue } - + currentLine := prefix + words[0] for _, word := range words[1:] { if len(currentLine)+1+len(word) > W { diff --git a/engine/server.go b/engine/server.go new file mode 100644 index 0000000..c86d04b --- /dev/null +++ b/engine/server.go @@ -0,0 +1,123 @@ +package engine + +import ( + "fmt" + "net" + "os" + "strings" +) + +// Config holds the BBS server configuration +type Config struct { + Host string + Port string + Banner string + Lang T +} + +// BBS is the main server struct +type BBS struct { + config Config + state *State + menu *Menu +} + +// New creates a new BBS server with the given configuration +func New(cfg Config) *BBS { + return &BBS{ + config: cfg, + state: newState(), + menu: &Menu{}, + } +} + +// State returns the shared server state (for use by content handlers) +func (b *BBS) State() *State { + return b.state +} + +// Menu configures the main menu using the DSL +func (b *BBS) Menu(fn func(*Menu)) { + fn(b.menu) +} + +// Start launches the TCP server and blocks waiting for connections +func (b *BBS) Start() { + ln, err := net.Listen("tcp", b.config.Host+":"+b.config.Port) + if err != nil { + fmt.Printf("Server start error: %v\n", err) + os.Exit(1) + } + defer ln.Close() + + fmt.Printf("BBS running → telnet localhost %s\n", b.config.Port) + fmt.Println("Stop: Ctrl+C") + + for { + conn, err := ln.Accept() + if err != nil { + fmt.Printf("Accept error: %v\n", err) + continue + } + fmt.Printf("[+] Connected: %s\n", conn.RemoteAddr().String()) + go b.handleClient(conn) + } +} + +func (b *BBS) handleClient(conn net.Conn) { + defer conn.Close() + addr := conn.RemoteAddr().String() + lang := b.config.Lang + printer := NewPrinter(conn) + + // Telnet negotiation (IAC WILL ECHO, IAC WILL SGA, IAC WONT LINEMODE) + printer.Send("\xff\xfb\x01\xff\xfb\x03\xff\xfe\x22") + + printer.Send(CY + b.config.Banner + R) + printer.Send(fmt.Sprintf("\n%s%s%s ", WH, lang["AskName"], R)) + + username, err := printer.ReadLine() + if err != nil { + return + } + username = strings.TrimSpace(username) + if username == "" { + username = "Anonymous" + } + if len(username) > 20 { + username = username[:20] + } + + b.state.Mu.Lock() + b.state.OnlineUsers[addr] = username + b.state.Mu.Unlock() + + defer func() { + b.state.Mu.Lock() + delete(b.state.OnlineUsers, addr) + b.state.Mu.Unlock() + }() + + printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", GR, fmt.Sprintf(lang["Greeting"], WH, username, GR), R)) + + session := &Session{ + State: b.state, + Printer: printer, + Username: username, + Addr: addr, + Lang: lang, + } + + for !session.Quit { + printer.Send(b.menu.Render(printer, lang, username)) + choice, err := printer.ReadLine() + if err != nil { + break + } + b.menu.Dispatch(session, choice) + } + + if session.Quit { + printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n\r\n", RD, fmt.Sprintf(lang["Goodbye"], username), R)) + } +} diff --git a/engine/session.go b/engine/session.go new file mode 100644 index 0000000..152912e --- /dev/null +++ b/engine/session.go @@ -0,0 +1,43 @@ +package engine + +import "sync" + +// State holds shared server state (thread-safe) +type State struct { + OnlineUsers map[string]string // addr -> username + Mu sync.Mutex +} + +func newState() *State { + return &State{ + OnlineUsers: make(map[string]string), + } +} + +// UserCount returns the number of online users in a thread-safe manner +func (st *State) UserCount() int { + st.Mu.Lock() + defer st.Mu.Unlock() + return len(st.OnlineUsers) +} + +// Snapshot returns a thread-safe copy of the online users map +func (st *State) Snapshot() map[string]string { + st.Mu.Lock() + defer st.Mu.Unlock() + snap := make(map[string]string) + for k, v := range st.OnlineUsers { + snap[k] = v + } + return snap +} + +// Session represents an active user connection +type Session struct { + State *State + Printer *Printer + Username string + Addr string + Lang T + Quit bool +} diff --git a/lib/menu.catalog.go b/lib/menu.catalog.go deleted file mode 100644 index 2eed254..0000000 --- a/lib/menu.catalog.go +++ /dev/null @@ -1,76 +0,0 @@ -package lib - -import ( - "fmt" - "strings" -) - -func (s *Session) ShowGames() { - s.Printer.BoxHeader(s.Lang["CatTitle"], YL) - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", GY, s.Lang["WikiLoading"], R)) - - softwares, err := s.BBS.CatalogRepo.FetchGames() - if err != nil { - s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", RD, s.Lang["WikiConnError"], err, 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", GY, s.Lang["CatNoGames"], 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", YL, strings.Repeat("─", W-4), R)) - s.Printer.Send(fmt.Sprintf(" %s%2d.%s %s%s%s%s %s[%s] by %s%s\r\n", YL, i+1, R, WH, B, sw.Title, R, GY, sw.Platform, sw.Author, 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", GY, line)) - } - } - - if lr != nil { - badges := []string{} - if lr.HTMLFolderPath != "" { - badges = append(badges, fmt.Sprintf("%s[▶ Play]%s", GR, R)) - } - if lr.CartridgePath != "" { - badges = append(badges, fmt.Sprintf("%s[⬇ Download]%s", BL, R)) - } - if lr.SourcePath != "" { - badges = append(badges, fmt.Sprintf("%s[Source]%s", MG, R)) - } - if lr.DocsFolderPath != "" { - badges = append(badges, fmt.Sprintf("%s[Docs]%s", YL, R)) - } - - badgeStr := "–" - if len(badges) > 0 { - badgeStr = strings.Join(badges, " ") - } - s.Printer.Send(fmt.Sprintf(" %s%s: v%s%s %s\r\n", GY, s.Lang["CatLatest"], lr.Version, R, badgeStr)) - - if lr.HTMLFolderPath != "" { - url := base + lr.HTMLFolderPath - s.Printer.Send(fmt.Sprintf(" %s▶ %s%s\r\n", DIM, url, R)) - } - } - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", GY, fmt.Sprintf(s.Lang["CatVersions"], len(entry.Releases)), R)) - } - - s.Printer.HR("─", YL) - s.Printer.Send("\r\n") - s.Printer.Send(fmt.Sprintf(" %s%s: %s%s\r\n", GY, s.Lang["CatFull"], base, R)) - s.Printer.Pause(s.Lang) -} diff --git a/lib/menu.online.go b/lib/menu.online.go deleted file mode 100644 index 3c3177e..0000000 --- a/lib/menu.online.go +++ /dev/null @@ -1,33 +0,0 @@ -package lib - -import ( - "fmt" - "sort" -) - -func (s *Session) ShowOnline() { - s.Printer.BoxHeader(s.Lang["OnlineTitle"], CY) - s.BBS.Mu.Lock() - snap := make(map[string]string) - for k, v := range s.BBS.OnlineUsers { - snap[k] = v - } - s.BBS.Mu.Unlock() - - 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", GR, s.Lang["OnlineYou"], R) - } - s.Printer.Send(fmt.Sprintf(" %s•%s %s%s%s%s\r\n", CY, R, WH, user, R, marker)) - } - s.Printer.Send(fmt.Sprintf("\r\n %s%s\r\n", GY, fmt.Sprintf(s.Lang["OnlineTotal"], WH, len(snap), GY, R))) - s.Printer.Pause(s.Lang) -} diff --git a/lib/menu.sysinfo.go b/lib/menu.sysinfo.go deleted file mode 100644 index 303a9a7..0000000 --- a/lib/menu.sysinfo.go +++ /dev/null @@ -1,31 +0,0 @@ -package lib - -import ( - "fmt" - "strconv" - "time" -) - -func (s *Session) ShowSysinfo() { - s.Printer.BoxHeader(s.Lang["SysInfoTitle"], GY) - s.BBS.Mu.Lock() - uc := len(s.BBS.OnlineUsers) - mc := len(s.BBS.Messages) - s.BBS.Mu.Unlock() - - now := time.Now().Format("2006-01-02 15:04:05") - - rows := [][]string{ - {s.Lang["SysServerTime"], now}, - {s.Lang["SysOnlineUsers"], strconv.Itoa(uc)}, - {s.Lang["SysMsgCount"], strconv.Itoa(mc)}, - {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", GY, row[0], R, WH, row[1], R)) - } - s.Printer.Pause(s.Lang) -} diff --git a/lib/menu.uzenopal.go b/lib/menu.uzenopal.go deleted file mode 100644 index af678b3..0000000 --- a/lib/menu.uzenopal.go +++ /dev/null @@ -1,47 +0,0 @@ -package lib - -import ( - "fmt" - "strings" - "time" -) - -func (s *Session) ShowUzenopal() { - s.Printer.BoxHeader(s.Lang["MsgBoardTitle"], GR) - s.BBS.Mu.Lock() - snap := make([]Message, len(s.BBS.Messages)) - copy(snap, s.BBS.Messages) - s.BBS.Mu.Unlock() - - if len(snap) == 0 { - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", GY, s.Lang["MsgNoMessages"], 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", GR, i+1, R, GY, msg.Timestamp, R, WH, msg.User, R, msg.Text)) - } - } - - s.Printer.Send(fmt.Sprintf("\r\n%s[N]%s %s %s[ENTER]%s %s → ", GY, R, s.Lang["MsgNew"], GY, 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 ", WH, s.Lang["MsgEnterText"], 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") - s.BBS.Mu.Lock() - s.BBS.Messages = append(s.BBS.Messages, Message{User: s.Username, Timestamp: ts, Text: msgText}) - s.BBS.Mu.Unlock() - s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", GR, s.Lang["MsgSent"], R)) - } else { - s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", GY, s.Lang["MsgEmpty"], R)) - } - } -} diff --git a/lib/menu.wiki.go b/lib/menu.wiki.go deleted file mode 100644 index b21d188..0000000 --- a/lib/menu.wiki.go +++ /dev/null @@ -1,93 +0,0 @@ -package lib - -import ( - "fmt" - "strconv" - "strings" -) - -func (s *Session) ShowWikiList(tag string, color string, title string) { - s.Printer.BoxHeader(title, color) - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", GY, s.Lang["WikiLoading"], R)) - - pages, err := s.BBS.WikiRepo.FetchList(tag) - if err != nil { - s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", RD, s.Lang["WikiConnError"], err, 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", GY, fmt.Sprintf(s.Lang["WikiNoResults"], tag), 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, R, WH, titleP, R)) - if desc != "" { - s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", GY, date, DIM, desc, R)) - } else { - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", GY, date, R)) - } - } - - s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", GY, s.Lang["WikiEnterNum"], 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", GY, s.Lang["WikiFetchContent"], R)) - content, err := s.BBS.WikiRepo.FetchContent(page.ID) - if err != nil { - s.Printer.Send(fmt.Sprintf("\r\n%sHiba: %v%s\r\n", RD, err, R)) - s.Printer.Pause(s.Lang) - return - } - - s.Printer.Send("\r\033[A\033[2K") - s.Printer.Send(fmt.Sprintf("\r\n")) - s.Printer.HR("═", color) - s.Printer.Send("\r\n") - s.Printer.Send(fmt.Sprintf(" %s%s%s%s\r\n", WH, B, page.Title, R)) - url := fmt.Sprintf("%s/%s/%s", WikiJSBaseURL, page.Locale, page.Path) - s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", GY, s.Printer.FmtDate(page.CreatedAt), DIM, url, R)) - s.Printer.HR("─", GY) - s.Printer.Send("\r\n\r\n") - - body := s.Printer.Wrapped(content, 2, 5000) - for _, line := range strings.Split(body, "\r\n") { - s.Printer.Send(line+"\r\n") - } - - s.Printer.Send("\r\n") - s.Printer.HR("─", GY) - s.Printer.Send("\r\n") - s.Printer.Pause(s.Lang) -} diff --git a/lib/repository.catalog.go b/lib/repository.catalog.go deleted file mode 100644 index c2634bb..0000000 --- a/lib/repository.catalog.go +++ /dev/null @@ -1,49 +0,0 @@ -package lib - -import ( - "encoding/json" - "net/http" - "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 NewCatalogRepository() *CatalogRepository { - return &CatalogRepository{} -} - -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 -} diff --git a/lib/repository.wiki.go b/lib/repository.wiki.go deleted file mode 100644 index c38b8ae..0000000 --- a/lib/repository.wiki.go +++ /dev/null @@ -1,106 +0,0 @@ -package lib - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "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 NewWikiRepository(token string) *WikiRepository { - return &WikiRepository{Token: token} -} - -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 -} diff --git a/lib/sys.header.go b/lib/sys.header.go deleted file mode 100644 index db848e7..0000000 --- a/lib/sys.header.go +++ /dev/null @@ -1,102 +0,0 @@ -package lib - -import ( - "fmt" - "strings" - "sync" -) - -const Banner = ` - ████████╗███████╗██╗ ███████╗████████╗██╗ ██╗██████╗ ███████╗ - ██╔══╝██╔════╝██║ ██╔════╝╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝ - ██║ █████╗ ██║ █████╗ ██║ ╚████╔╝ ██████╔╝█████╗ - ██║ ██╔══╝ ██║ ██╔══╝ ██║ ╚██╔╝ ██╔═══╝ ██╔══╝ - ██║ ███████╗███████╗███████╗ ██║ ██║ ██║ ███████╗ - ╚═╝ ╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ - - ██████╗ █████╗ ███╗ ███╗███████╗███████╗ - ██╔════╝ ██╔══██╗████╗ ████║██╔════╝██╔════╝ - ██║ ███╗███████║██╔████╔██║█████╗ ███████╗ - ██║ ██║██╔══██║██║╚██╔╝██║██╔══╝ ╚════██║ - ╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗███████║ - ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ - - ░░ BBS v2.0 ░░ teletype.hu ░░ - Welcome to the Teletype community bulletin board! -` - -type BBS struct { - Messages []Message - OnlineUsers map[string]string // addr -> username - Mu sync.Mutex - WikiToken string - WikiRepo *WikiRepository - CatalogRepo *CatalogRepository -} - -func NewBBS(wikiToken string) *BBS { - return &BBS{ - Messages: []Message{}, - OnlineUsers: make(map[string]string), - WikiToken: wikiToken, - WikiRepo: NewWikiRepository(wikiToken), - CatalogRepo: NewCatalogRepository(), - } -} - -type UI struct { - Printer *Printer - Lang T -} - -func NewUI(printer *Printer, lang T) *UI { - return &UI{Printer: printer, Lang: lang} -} - -type Session struct { - BBS *BBS - Printer *Printer - Username string - Addr string - Lang T -} - -func NewSession(bbs *BBS, printer *Printer, username string, addr string, lang T) *Session { - return &Session{ - BBS: bbs, - Printer: printer, - Username: username, - Addr: addr, - Lang: lang, - } -} - -func (ui *UI) MainMenu(username string) string { - headerLine := strings.Repeat("═", W) - - l1 := ui.Printer.PadLine(fmt.Sprintf(" %s%s%s %s@%s%s", YL, ui.Lang["MainMenuTitle"], R, GY, username, R), W) - l2 := ui.Printer.PadLine(fmt.Sprintf(" %s[1]%s %s", GR, R, ui.Lang["MenuUzenopal"]), W/2) + ui.Printer.PadLine(fmt.Sprintf(" %s[2]%s %s", BL, R, ui.Lang["MenuBlog"]), W/2) - l3 := ui.Printer.PadLine(fmt.Sprintf(" %s[3]%s %s", MG, R, ui.Lang["MenuHowto"]), W/2) + ui.Printer.PadLine(fmt.Sprintf(" %s[4]%s %s", YL, R, ui.Lang["MenuCatalog"]), W/2) - l4 := ui.Printer.PadLine(fmt.Sprintf(" %s[5]%s %s", CY, R, ui.Lang["MenuOnline"]), W/2) + ui.Printer.PadLine(fmt.Sprintf(" %s[6]%s %s", GY, R, ui.Lang["MenuSysinfo"]), W/2) - l5 := ui.Printer.PadLine(fmt.Sprintf(" %s[Q]%s %s", RD, R, ui.Lang["MenuExit"]), W) - - return fmt.Sprintf( - "\n%s╔%s╗%s\n"+ - "%s║%s%s║%s\n"+ - "%s╠%s╣%s\n"+ - "%s║%s%s║%s\n"+ - "%s║%s%s║%s\n"+ - "%s║%s%s║%s\n"+ - "%s║%s%s║%s\n"+ - "%s╚%s╝%s\n%s", - WH, headerLine, R, - WH, l1, WH, R, - WH, headerLine, R, - WH, l2, WH, R, - WH, l3, WH, R, - WH, l4, WH, R, - WH, l5, WH, R, - WH, headerLine, R, - ui.Lang["Choice"], - ) -} diff --git a/main.go b/main.go index 0a1e7bb..069b9c5 100644 --- a/main.go +++ b/main.go @@ -1,116 +1,68 @@ package main import ( - "bbs-server/lib" + "bbs-server/content" + "bbs-server/engine" "fmt" - "net" "os" - "strings" ) -const ( - Host = "0.0.0.0" - Port = "2323" -) +const banner = ` + ████████╗███████╗██╗ ███████╗████████╗██╗ ██╗██████╗ ███████╗ + ██╔══╝██╔════╝██║ ██╔════╝╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝ + ██║ █████╗ ██║ █████╗ ██║ ╚████╔╝ ██████╔╝█████╗ + ██║ ██╔══╝ ██║ ██╔══╝ ██║ ╚██╔╝ ██╔═══╝ ██╔══╝ + ██║ ███████╗███████╗███████╗ ██║ ██║ ██║ ███████╗ + ╚═╝ ╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ -var bbs *lib.BBS + ██████╗ █████╗ ███╗ ███╗███████╗███████╗ + ██╔════╝ ██╔══██╗████╗ ████║██╔════╝██╔════╝ + ██║ ███╗███████║██╔████╔██║█████╗ ███████╗ + ██║ ██║██╔══██║██║╚██╔╝██║██╔══╝ ╚════██║ + ╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗███████║ + ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ + + ░░ BBS v2.0 ░░ teletype.hu ░░ + Welcome to the Teletype community bulletin board! +` func main() { wikiToken := os.Getenv("WEBAPP_WIKIJS_TOKEN") - bbs = lib.NewBBS(wikiToken) - ln, err := net.Listen("tcp", Host+":"+Port) - if err != nil { - fmt.Printf("Hiba a szerver indításakor: %v\n", err) - os.Exit(1) + boardPath := os.Getenv("MESSAGES_PATH") + if boardPath == "" { + boardPath = "messages.dat" } - defer ln.Close() - fmt.Printf("Teletype BBS running → telnet localhost %s\n", Port) - fmt.Printf("Wiki: %s\n", lib.WikiJSBaseURL) - fmt.Printf("Games API: %s\n", lib.GamesAPIURL) tokenStatus := "✗ not set" if wikiToken != "" { tokenStatus = "✓ set" } - fmt.Printf("Token: %s\n", tokenStatus) - fmt.Println("Stop: Ctrl+C") + fmt.Printf("Wiki: %s\n", content.WikiJSBaseURL) + fmt.Printf("Games API: %s\n", content.GamesAPIURL) + fmt.Printf("Token: %s\n", tokenStatus) - for { - conn, err := ln.Accept() - if err != nil { - fmt.Printf("Error accepting connection: %v\n", err) - continue - } - fmt.Printf("[+] Connected: %s\n", conn.RemoteAddr().String()) - go handleClient(conn) - } -} - -func handleClient(conn net.Conn) { - defer conn.Close() - addr := conn.RemoteAddr().String() - - printer := lib.NewPrinter(conn) - lang := lib.En - ui := lib.NewUI(printer, lang) - - // Telnet negotiation - printer.Send("\xff\xfb\x01\xff\xfb\x03\xff\xfe\x22") - - printer.Send(lib.CY + lib.Banner + lib.R) - printer.Send(fmt.Sprintf("\n%s%s%s ", lib.WH, lang["AskName"], lib.R)) - - username, err := printer.ReadLine() - if err != nil { - return - } - username = strings.TrimSpace(username) - if username == "" { - username = "Anonymous" - } - if len(username) > 20 { - username = username[:20] - } - - bbs.Mu.Lock() - bbs.OnlineUsers[addr] = username - bbs.Mu.Unlock() - - defer func() { - bbs.Mu.Lock() - delete(bbs.OnlineUsers, addr) - bbs.Mu.Unlock() - }() - - printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", lib.GR, fmt.Sprintf(lang["Greeting"], lib.WH, username, lib.GR), lib.R)) - - session := lib.NewSession(bbs, printer, username, addr, lang) - - for { - printer.Send(ui.MainMenu(username)) - choice, err := printer.ReadLine() - if err != nil { - break - } - c := strings.ToUpper(strings.TrimSpace(choice)) - - switch c { - case "1": - session.ShowUzenopal() - case "2": - session.ShowWikiList("blog", lib.BL, lang["MenuBlog"]) - case "3": - session.ShowWikiList("howto", lib.MG, lang["MenuHowto"]) - case "4": - session.ShowGames() - case "5": - session.ShowOnline() - case "6": - session.ShowSysinfo() - case "Q": - printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n\r\n", lib.RD, fmt.Sprintf(lang["Goodbye"], username), lib.R)) - return - } - } + wiki := content.NewWikiHandler(wikiToken) + cat := content.NewCatalogHandler() + board := content.NewMessageBoard(boardPath) + + bbs := engine.New(engine.Config{ + Host: "0.0.0.0", + Port: "2323", + Banner: banner, + Lang: engine.En, + }) + + bbs.Menu(func(m *engine.Menu) { + m.Title("MAIN MENU") + m.Item("1", "Message Board", engine.GR, board.Show) + m.Item("2", "Blog Posts", engine.BL, wiki.List("blog", "Blog Posts", engine.BL)) + m.Item("3", "HowTo Guides", engine.MG, wiki.List("howto", "HowTo Guides", engine.MG)) + m.Item("4", "Game Catalog", engine.YL, cat.Show) + m.Item("5", "Online Users", engine.CY, content.Online) + m.Item("6", "System Info", engine.GY, content.Sysinfo(board)) + m.Item("Q", "Exit", engine.RD, engine.Exit) + }) + + bbs.Start() }