remove golang related files

This commit is contained in:
2026-04-28 23:04:12 +02:00
parent 4ac5f1632f
commit f72fbb7581
20 changed files with 0 additions and 1208 deletions

View File

@@ -1,31 +0,0 @@
root = "."
tmp_dir = "tmp"
[build]
bin = "./tmp/main"
cmd = "go build -o ./tmp/main main.go"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor"]
include_dir = ["lib"]
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_ext = ["go", "tpl", "tmpl", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
time = false
[misc]
clean_on_exit = false

View File

@@ -1,9 +0,0 @@
FROM --platform=linux/amd64 golang:1.26.1-alpine
WORKDIR /app
RUN go install github.com/air-verse/air@latest
EXPOSE 2323
CMD ["air", "-c", ".air.toml"]

View File

@@ -1,45 +0,0 @@
package content
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 (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
}

View File

@@ -1,66 +0,0 @@
package content
import (
"encoding/csv"
"os"
"sync"
)
type message struct {
Timestamp string
User string
Text string
}
// MessageBoard holds message board data
type MessageBoard struct {
messages []message
mu sync.Mutex
path string
}
// NewMessageBoard loads messages from the given .dat file
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
}
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
}
func (b *MessageBoard) Count() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.messages)
}

View File

@@ -1,133 +0,0 @@
package content
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 (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
}
if errorsRaw, ok := res["errors"].([]interface{}); ok && len(errorsRaw) > 0 {
if firstErr, ok := errorsRaw[0].(map[string]interface{}); ok {
if msg, ok := firstErr["message"].(string); ok {
return nil, fmt.Errorf("wiki error: %s", msg)
}
}
return nil, fmt.Errorf("wiki returned an error")
}
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
}
if errorsRaw, ok := res["errors"].([]interface{}); ok && len(errorsRaw) > 0 {
if firstErr, ok := errorsRaw[0].(map[string]interface{}); ok {
if msg, ok := firstErr["message"].(string); ok {
return "", fmt.Errorf("wiki error: %s", msg)
}
}
return "", fmt.Errorf("wiki returned an error")
}
data, ok := res["data"].(map[string]interface{})
if !ok {
return "", fmt.Errorf("invalid response format: missing data")
}
pages, ok := data["pages"].(map[string]interface{})
if !ok {
return "", fmt.Errorf("invalid response format: missing pages")
}
single, ok := pages["single"].(map[string]interface{})
if !ok {
return "", fmt.Errorf("page not found")
}
content, ok := single["content"].(string)
if !ok {
return "", fmt.Errorf("invalid response format: missing content")
}
return content, nil
}

View File

@@ -1,108 +0,0 @@
package content
import (
"bbs-server/engine"
"fmt"
"strconv"
"strings"
)
// BlogHandler handles the Blog Posts menu item
type BlogHandler struct {
repo *wikiRepository
}
func NewBlogHandler(token string) *BlogHandler {
return &BlogHandler{repo: &wikiRepository{token: token}}
}
func (h *BlogHandler) Handle(s *engine.Session) {
renderWikiList(s, h.repo, "blog", "Blog Posts", engine.COLOR_BLUE)
}
// renderWikiList is a helper used by wiki-based handlers
func renderWikiList(s *engine.Session, repo *wikiRepository, tag, title, color string) {
s.Printer.BoxHeader(title, color)
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, s.Lang["WikiLoading"], engine.COLOR_RESET))
pages, err := repo.fetchList(tag)
if err != nil {
s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", engine.COLOR_RED, s.Lang["WikiConnError"], err, engine.COLOR_RESET))
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.COLOR_GRAY, fmt.Sprintf(s.Lang["WikiNoResults"], tag), engine.COLOR_RESET))
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.COLOR_RESET, engine.COLOR_WHITE, titleP, engine.COLOR_RESET))
if desc != "" {
s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.COLOR_GRAY, date, engine.COLOR_DIM, desc, engine.COLOR_RESET))
} else {
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, date, engine.COLOR_RESET))
}
}
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", engine.COLOR_GRAY, s.Lang["WikiEnterNum"], engine.COLOR_RESET))
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.COLOR_GRAY, s.Lang["WikiFetchContent"], engine.COLOR_RESET))
pageContent, err := repo.fetchContent(page.ID)
if err != nil {
s.Printer.Send(fmt.Sprintf("\r\n%sHiba: %v%s\r\n", engine.COLOR_RED, err, engine.COLOR_RESET))
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.COLOR_WHITE, engine.COLOR_BOLD, page.Title, engine.COLOR_RESET))
url := fmt.Sprintf("%s/%s/%s", WikiJSBaseURL, page.Locale, page.Path)
s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.COLOR_GRAY, s.Printer.FmtDate(page.CreatedAt), engine.COLOR_DIM, url, engine.COLOR_RESET))
s.Printer.HR("─", engine.COLOR_GRAY)
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.COLOR_GRAY)
s.Printer.Send("\r\n")
s.Printer.Pause(s.Lang)
}

View File

@@ -1,91 +0,0 @@
package content
import (
"bbs-server/engine"
"fmt"
"strings"
)
// CatalogHandler handles the Game Catalog menu item
type CatalogHandler struct {
repo *catalogRepository
}
func NewCatalogHandler() *CatalogHandler {
return &CatalogHandler{repo: &catalogRepository{}}
}
func (h *CatalogHandler) Handle(s *engine.Session) {
s.Printer.BoxHeader(s.Lang["CatTitle"], engine.COLOR_YELLOW)
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, s.Lang["WikiLoading"], engine.COLOR_RESET))
softwares, err := h.repo.fetchGames()
if err != nil {
s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", engine.COLOR_RED, s.Lang["WikiConnError"], err, engine.COLOR_RESET))
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.COLOR_GRAY, s.Lang["CatNoGames"], engine.COLOR_RESET))
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.COLOR_YELLOW, strings.Repeat("─", engine.W-4), engine.COLOR_RESET))
s.Printer.Send(fmt.Sprintf(" %s%2d.%s %s%s%s%s %s[%s] by %s%s\r\n",
engine.COLOR_YELLOW, i+1, engine.COLOR_RESET,
engine.COLOR_WHITE, engine.COLOR_BOLD, sw.Title, engine.COLOR_RESET,
engine.COLOR_GREEN, sw.Platform, sw.Author, engine.COLOR_RESET))
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.COLOR_GRAY, line))
}
}
if lr != nil {
badges := []string{}
if lr.HTMLFolderPath != "" {
badges = append(badges, fmt.Sprintf("%s[▶ Play]%s", engine.COLOR_GREEN, engine.COLOR_RESET))
}
if lr.CartridgePath != "" {
badges = append(badges, fmt.Sprintf("%s[⬇ Download]%s", engine.COLOR_BLUE, engine.COLOR_RESET))
}
if lr.SourcePath != "" {
badges = append(badges, fmt.Sprintf("%s[Source]%s", engine.COLOR_MAGENTA, engine.COLOR_RESET))
}
if lr.DocsFolderPath != "" {
badges = append(badges, fmt.Sprintf("%s[Docs]%s", engine.COLOR_YELLOW, engine.COLOR_RESET))
}
badgeStr := ""
if len(badges) > 0 {
badgeStr = strings.Join(badges, " ")
}
s.Printer.Send(fmt.Sprintf(" %s%s: v%s%s %s\r\n",
engine.COLOR_GRAY, s.Lang["CatLatest"], lr.Version, engine.COLOR_RESET, badgeStr))
if lr.HTMLFolderPath != "" {
url := base + lr.HTMLFolderPath
s.Printer.Send(fmt.Sprintf(" %s▶ %s%s\r\n", engine.COLOR_DIM, url, engine.COLOR_RESET))
}
}
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n",
engine.COLOR_GRAY, fmt.Sprintf(s.Lang["CatVersions"], len(entry.Releases)), engine.COLOR_RESET))
}
s.Printer.HR("─", engine.COLOR_YELLOW)
s.Printer.Send("\r\n")
s.Printer.Send(fmt.Sprintf(" %s%s: %s%s\r\n", engine.COLOR_GREEN, s.Lang["CatFull"], base, engine.COLOR_RESET))
s.Printer.Pause(s.Lang)
}

View File

@@ -1,8 +0,0 @@
package content
import "bbs-server/engine"
// Handler is the common interface for all BBS menu handlers
type Handler interface {
Handle(s *engine.Session)
}

View File

@@ -1,16 +0,0 @@
package content
import "bbs-server/engine"
// HowToHandler handles the HowTo Guides menu item
type HowToHandler struct {
repo *wikiRepository
}
func NewHowToHandler(token string) *HowToHandler {
return &HowToHandler{repo: &wikiRepository{token: token}}
}
func (h *HowToHandler) Handle(s *engine.Session) {
renderWikiList(s, h.repo, "howto", "HowTo Guides", engine.COLOR_MAGENTA)
}

View File

@@ -1,40 +0,0 @@
package content
import (
"bbs-server/engine"
"fmt"
)
// MessageBoardIndexHandler displays the message board posts
type MessageBoardIndexHandler struct {
board *MessageBoard
}
func NewMessageBoardIndexHandler(board *MessageBoard) *MessageBoardIndexHandler {
return &MessageBoardIndexHandler{board: board}
}
func (h *MessageBoardIndexHandler) Handle(s *engine.Session) {
s.Printer.BoxHeader(s.Lang["MsgBoardTitle"], engine.COLOR_GRAY)
h.board.mu.Lock()
snap := make([]message, len(h.board.messages))
copy(snap, h.board.messages)
h.board.mu.Unlock()
if len(snap) == 0 {
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, s.Lang["MsgNoMessages"], engine.COLOR_RESET))
} 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.COLOR_GRAY, i+1, engine.COLOR_RESET,
engine.COLOR_GRAY, msg.Timestamp, engine.COLOR_RESET,
engine.COLOR_WHITE, msg.User, engine.COLOR_RESET, msg.Text))
}
}
s.Printer.Pause(s.Lang)
}

View File

@@ -1,38 +0,0 @@
package content
import (
"bbs-server/engine"
"fmt"
"strings"
"time"
)
// MessageBoardNewHandler handles posting new messages to the board
type MessageBoardNewHandler struct {
board *MessageBoard
}
func NewMessageBoardNewHandler(board *MessageBoard) *MessageBoardNewHandler {
return &MessageBoardNewHandler{board: board}
}
func (h *MessageBoardNewHandler) Handle(s *engine.Session) {
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", engine.COLOR_WHITE, s.Lang["MsgEnterText"], engine.COLOR_RESET))
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}
h.board.mu.Lock()
h.board.messages = append(h.board.messages, msg)
h.board.mu.Unlock()
h.board.append(msg)
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", engine.COLOR_GREEN, s.Lang["MsgSent"], engine.COLOR_RESET))
} else {
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", engine.COLOR_GRAY, s.Lang["MsgEmpty"], engine.COLOR_RESET))
}
s.Printer.Pause(s.Lang)
}

View File

@@ -1,38 +0,0 @@
package content
import (
"bbs-server/engine"
"fmt"
"sort"
)
// OnlineHandler displays currently logged-in users
type OnlineHandler struct{}
func NewOnlineHandler() *OnlineHandler {
return &OnlineHandler{}
}
func (h *OnlineHandler) Handle(s *engine.Session) {
s.Printer.BoxHeader(s.Lang["OnlineTitle"], engine.COLOR_YELLOW)
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.COLOR_GREEN, s.Lang["OnlineYou"], engine.COLOR_RESET)
}
s.Printer.Send(fmt.Sprintf(" %s•%s %s%s%s%s\r\n", engine.COLOR_YELLOW, engine.COLOR_RESET, engine.COLOR_WHITE, user, engine.COLOR_RESET, marker))
}
s.Printer.Send(fmt.Sprintf("\r\n %s%s\r\n", engine.COLOR_GRAY,
fmt.Sprintf(s.Lang["OnlineTotal"], engine.COLOR_WHITE, len(snap), engine.COLOR_GRAY, engine.COLOR_RESET)))
s.Printer.Pause(s.Lang)
}

View File

@@ -1,27 +0,0 @@
package content
import (
"bbs-server/engine"
"fmt"
"runtime"
)
// SysinfoHandler displays system information
type SysinfoHandler struct {
board *MessageBoard
}
func NewSysinfoHandler(board *MessageBoard) *SysinfoHandler {
return &SysinfoHandler{board: board}
}
func (h *SysinfoHandler) Handle(s *engine.Session) {
s.Printer.BoxHeader(s.Lang["SysTitle"], engine.COLOR_GRAY)
s.Printer.Send(fmt.Sprintf(" %s%-15s%s %d\r\n", engine.COLOR_GRAY, s.Lang["SysUsers"], engine.COLOR_RESET, s.State.UserCount()))
s.Printer.Send(fmt.Sprintf(" %s%-15s%s %d\r\n", engine.COLOR_GRAY, s.Lang["SysMessages"], engine.COLOR_RESET, h.board.Count()))
s.Printer.Send(fmt.Sprintf(" %s%-15s%s %s\r\n", engine.COLOR_GRAY, s.Lang["SysOS"], engine.COLOR_RESET, runtime.GOOS))
s.Printer.Send(fmt.Sprintf(" %s%-15s%s %s\r\n", engine.COLOR_GRAY, s.Lang["SysArch"], engine.COLOR_RESET, runtime.GOARCH))
s.Printer.Pause(s.Lang)
}

View File

@@ -1,40 +0,0 @@
package engine
// T is the i18n dictionary type
type T map[string]string
// En is the default English dictionary
var En = T{
"AskName": "Enter your name:",
"Greeting": "Hello, %s%s%s! Welcome to Teletype BBS!",
"Goodbye": "Goodbye, %s! 👋",
"Pause": "Press ENTER...",
"Choice": "Choice: ",
"MsgBoardTitle": "📋 MESSAGE BOARD",
"MsgNoMessages": "(No messages yet — be the first!)",
"MsgNew": "Write new message",
"MsgBack": "Back",
"MsgEnterText": "Message text:",
"MsgSent": "✓ Sent!",
"MsgEmpty": "(Empty not sent)",
"WikiLoading": "Loading...",
"WikiConnError": "Connection error",
"WikiNoResults": "No results for tag '%s'.",
"WikiEnterNum": "Enter number to open, ENTER to go back:",
"WikiFetchContent": "Fetching content...",
"CatTitle": "🎮 GAME CATALOG",
"CatNoGames": "No games available.",
"CatLatest": "Latest",
"CatVersions": "%d versions available",
"CatFull": "Full catalog",
"OnlineTitle": "👥 ONLINE USERS",
"OnlineYou": "← you",
"OnlineTotal": "Total: %s%d%s users online",
"SysInfoTitle": " SYSTEM INFO",
"SysServerTime": "Server time",
"SysOnlineUsers": "Online users",
"SysMsgCount": "Message count",
"SysWikiURL": "Wiki URL",
"SysGamesAPI": "Games API",
"SysPlatform": "Platform",
}

View File

@@ -1,78 +0,0 @@
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", COLOR_YELLOW, m.title, COLOR_RESET, COLOR_GRAY, username, COLOR_RESET), 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, COLOR_RESET, 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, COLOR_RESET, 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, COLOR_RESET, m.items[i].Label), W))
}
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n%s╔%s╗%s\n", COLOR_WHITE, headerLine, COLOR_RESET))
sb.WriteString(fmt.Sprintf("%s║%s%s║%s\n", COLOR_WHITE, l1, COLOR_WHITE, COLOR_RESET))
sb.WriteString(fmt.Sprintf("%s╠%s╣%s\n", COLOR_WHITE, headerLine, COLOR_RESET))
for _, row := range rows {
sb.WriteString(fmt.Sprintf("%s║%s%s║%s\n", COLOR_WHITE, row, COLOR_WHITE, COLOR_RESET))
}
sb.WriteString(fmt.Sprintf("%s╚%s╝%s\n%s", COLOR_WHITE, headerLine, COLOR_RESET, lang["Choice"]))
return sb.String()
}

View File

@@ -1,197 +0,0 @@
package engine
import (
"bytes"
"fmt"
"net"
"regexp"
"strings"
"time"
"unicode/utf8"
)
// ANSI color codes
const (
COLOR_RESET = "\033[0m"
COLOR_BOLD = "\033[1m"
COLOR_DIM = "\033[2m"
COLOR_CYAN = "\033[1;36m"
COLOR_YELLOW = "\033[1;33m"
COLOR_GREEN = "\033[1;32m"
COLOR_RED = "\033[1;31m"
COLOR_MAGENTA = "\033[1;35m"
COLOR_WHITE = "\033[1;37m"
COLOR_BLUE = "\033[1;34m"
COLOR_GRAY = "\033[0;37m"
)
// W is the default terminal width
const W = 70
type Printer struct {
Conn net.Conn
}
func NewPrinter(conn net.Conn) *Printer {
return &Printer{Conn: conn}
}
func (p *Printer) Send(text string) {
normalized := strings.ReplaceAll(text, "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\n", "\r\n")
p.Conn.Write([]byte(normalized))
}
func (p *Printer) ReadLine() (string, error) {
var buf bytes.Buffer
for {
b := make([]byte, 1)
_, err := p.Conn.Read(b)
if err != nil {
return "", err
}
ch := b[0]
if ch == 255 {
cmd := make([]byte, 2)
_, err := p.Conn.Read(cmd)
if err != nil {
return "", err
}
if cmd[0] == 250 {
for {
tmp := make([]byte, 1)
_, err := p.Conn.Read(tmp)
if err != nil || tmp[0] == 240 {
break
}
}
}
continue
}
if ch == '\r' || ch == '\n' {
res := buf.String()
p.Send("\r\n")
return res, nil
}
if ch == 8 || ch == 127 {
if buf.Len() > 0 {
curr := buf.Bytes()
buf.Reset()
buf.Write(curr[:len(curr)-1])
p.Send("\b \b")
}
continue
}
if ch >= 32 && ch < 127 {
buf.WriteByte(ch)
p.Send(string(ch))
}
}
}
func (p *Printer) Pause(lang T) {
p.Send(fmt.Sprintf("\r\n%s [ %s ]%s ", COLOR_GRAY, lang["Pause"], COLOR_RESET))
p.ReadLine()
}
func (p *Printer) VisibleLen(s string) int {
re := regexp.MustCompile(`\033\[[0-9;]*m`)
return utf8.RuneCountInString(re.ReplaceAllString(s, ""))
}
func (p *Printer) PadLine(content string, width int) string {
vLen := p.VisibleLen(content)
padding := width - vLen
if padding < 0 {
padding = 0
}
return content + strings.Repeat(" ", padding)
}
func (p *Printer) BoxHeader(title string, color string) {
line := strings.Repeat("═", W)
titleLen := utf8.RuneCountInString(title)
padding := (W - 2 - titleLen) / 2
if padding < 0 {
padding = 0
}
inner := strings.Repeat(" ", padding) + title + strings.Repeat(" ", W-2-titleLen-padding)
p.Send(fmt.Sprintf(
"\n%s%s%s\n%s║%s %s%s%s %s║%s\n%s%s%s\n",
color, line, COLOR_RESET,
color, COLOR_RESET, COLOR_BOLD, inner, COLOR_RESET, color, COLOR_RESET,
color, line, COLOR_RESET,
))
}
func (p *Printer) HR(char string, color string) {
p.Send(fmt.Sprintf("%s%s%s", color, strings.Repeat(char, W), COLOR_RESET))
}
func (p *Printer) FmtDate(s string) string {
t, err := time.Parse(time.RFC3339, strings.Replace(s, "Z", "+00:00", 1))
if err != nil {
if len(s) >= 10 {
return s[:10]
}
return ""
}
return t.Format("2006-01-02")
}
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(`\[(.*?)\]\(.*?\)`)
matches := sub.FindStringSubmatch(s)
if len(matches) > 1 {
return matches[1]
}
}
if strings.HasPrefix(s, "!") || strings.HasPrefix(s, "#") || strings.ContainsAny(s, "*_`~>|") {
return ""
}
return s
})
return strings.TrimSpace(text)
}
func (p *Printer) Wrapped(text string, indent int, maxChars int) string {
if len(text) > maxChars {
text = text[:maxChars]
}
text = p.StripMD(text)
prefix := strings.Repeat(" ", indent)
var lines []string
paras := strings.Split(text, "\n")
for _, para := range paras {
para = strings.TrimSpace(para)
if para == "" {
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 {
lines = append(lines, currentLine)
currentLine = prefix + word
} else {
currentLine += " " + word
}
}
lines = append(lines, currentLine)
}
return strings.Join(lines, "\r\n")
}

View File

@@ -1,123 +0,0 @@
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(COLOR_CYAN + b.config.Banner + COLOR_RESET)
printer.Send(fmt.Sprintf("\n%s%s%s ", COLOR_WHITE, lang["AskName"], COLOR_RESET))
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", COLOR_GREEN, fmt.Sprintf(lang["Greeting"], COLOR_WHITE, username, COLOR_GREEN), COLOR_RESET))
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", COLOR_RED, fmt.Sprintf(lang["Goodbye"], username), COLOR_RESET))
}
}

View File

@@ -1,43 +0,0 @@
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
}

3
go.mod
View File

@@ -1,3 +0,0 @@
module bbs-server
go 1.26.1

74
main.go
View File

@@ -1,74 +0,0 @@
package main
import (
"bbs-server/content"
"bbs-server/engine"
"fmt"
"os"
)
const banner = `
████████╗███████╗██╗ ███████╗████████╗██╗ ██╗██████╗ ███████╗
██╔══╝██╔════╝██║ ██╔════╝╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝
██║ █████╗ ██║ █████╗ ██║ ╚████╔╝ ██████╔╝█████╗
██║ ██╔══╝ ██║ ██╔══╝ ██║ ╚██╔╝ ██╔═══╝ ██╔══╝
██║ ███████╗███████╗███████╗ ██║ ██║ ██║ ███████╗
╚═╝ ╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
██████╗ █████╗ ███╗ ███╗███████╗███████╗
██╔════╝ ██╔══██╗████╗ ████║██╔════╝██╔════╝
██║ ███╗███████║██╔████╔██║█████╗ ███████╗
██║ ██║██╔══██║██║╚██╔╝██║██╔══╝ ╚════██║
╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗███████║
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝
░░ BBS v2.0 ░░ games.teletype.hu ░░
Welcome to the Teletype community bulletin board!
`
func main() {
wikiToken := os.Getenv("WEBAPP_WIKIJS_TOKEN")
boardPath := os.Getenv("MESSAGES_PATH")
if boardPath == "" {
boardPath = "messages.dat"
}
tokenStatus := "✗ not set"
if wikiToken != "" {
tokenStatus = "✓ set"
}
fmt.Printf("Wiki: %s\n", content.WikiJSBaseURL)
fmt.Printf("Games API: %s\n", content.GamesAPIURL)
fmt.Printf("Token: %s\n", tokenStatus)
messageBoard := content.NewMessageBoard(boardPath)
blogHandler := content.NewBlogHandler(wikiToken)
howtoHandler := content.NewHowToHandler(wikiToken)
catalogHandler := content.NewCatalogHandler()
messageBoardIndexHandler := content.NewMessageBoardIndexHandler(messageBoard)
messageBoardNewHandler := content.NewMessageBoardNewHandler(messageBoard)
onlineHandler := content.NewOnlineHandler()
sysinfoHandler := content.NewSysinfoHandler(messageBoard)
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.COLOR_GREEN, messageBoardIndexHandler.Handle)
m.Item("N", "New Message", engine.COLOR_WHITE, messageBoardNewHandler.Handle)
m.Item("2", "Blog Posts", engine.COLOR_BLUE, blogHandler.Handle)
m.Item("3", "HowTo Guides", engine.COLOR_MAGENTA, howtoHandler.Handle)
m.Item("4", "Game Catalog", engine.COLOR_YELLOW, catalogHandler.Handle)
m.Item("5", "Online Users", engine.COLOR_CYAN, onlineHandler.Handle)
m.Item("6", "System Info", engine.COLOR_GRAY, sysinfoHandler.Handle)
m.Item("Q", "Exit", engine.COLOR_RED, engine.Exit)
})
bbs.Start()
}