From e837a9a04ef2f1d4296cfc9c36d716aab8ffeb79 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Tue, 10 Mar 2026 20:23:54 +0100 Subject: [PATCH] refact --- .air.toml | 31 ++ .gitignore | 2 + Dockerfile | 31 ++ Dockerfile.development | 3 +- README.md | 69 ++++ docker-compose.prod.yaml | 18 + docker-compose.yaml | 2 +- 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 ++++++ lib/sys.i18n.go | 49 +++ lib/sys.print.go | 202 +++++++++++ main.go | 700 +++----------------------------------- 18 files changed, 985 insertions(+), 659 deletions(-) create mode 100644 .air.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.prod.yaml create mode 100644 lib/menu.catalog.go create mode 100644 lib/menu.online.go create mode 100644 lib/menu.sysinfo.go create mode 100644 lib/menu.uzenopal.go create mode 100644 lib/menu.wiki.go create mode 100644 lib/repository.catalog.go create mode 100644 lib/repository.wiki.go create mode 100644 lib/sys.header.go create mode 100644 lib/sys.i18n.go create mode 100644 lib/sys.print.go diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..b348371 --- /dev/null +++ b/.air.toml @@ -0,0 +1,31 @@ +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 diff --git a/.gitignore b/.gitignore index 4c49bd7..fe6a153 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .env +bbs-server +tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7f66a8a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM golang:1.26.1-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY go.mod ./ +# COPY go.sum ./ # Uncomment if you have a go.sum file +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o bbs-server main.go + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /root/ + +# Copy the binary from the builder stage +COPY --from=builder /app/bbs-server . + +# Expose the port +EXPOSE 2323 + +# Run the binary +CMD ["./bbs-server"] diff --git a/Dockerfile.development b/Dockerfile.development index e01d581..86df8d1 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -2,7 +2,8 @@ FROM --platform=linux/amd64 golang:1.26.1-alpine WORKDIR /app +RUN go install github.com/air-verse/air@latest EXPOSE 2323 -CMD ["go", "run", "main.go"] +CMD ["air", "-c", ".air.toml"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dd7774 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Teletype BBS Server + +A modern, Go-based Bulletin Board System (BBS) server designed for Telnet access. It features community tools, wiki integration, and a game catalog, all rendered with retro-style ANSI graphics. + +## Quick Start + +### Prerequisites +- [Go](https://go.dev/dl/) 1.26 or higher +- A [Wiki.js](https://wiki.js.org/) instance (optional, for Wiki features) + +### Installation + +1. Clone the repository: + ```bash + git clone + cd bbs-server + ``` + +2. Install dependencies: + ```bash + go mod download + ``` + +3. Set up environment variables (optional but recommended for Wiki access): + ```bash + export WEBAPP_WIKIJS_TOKEN="your-wiki-js-api-token" + ``` + +4. Run the server: + ```bash + go run main.go + ``` + +The server starts on `0.0.0.0:2323` by default. + +### Connecting +You can connect to the BBS using any standard Telnet client: +```bash +telnet localhost 2323 +``` + +## Configuration + +- **Host/Port:** Currently hardcoded in `main.go` for simplicity. +- **Wiki API:** Configured in `lib/repository.wiki.go`. +- **Games API:** Configured in `lib/repository.catalog.go`. +- **Internationalization:** Text is managed in `lib/sys.i18n.go` using a map-based system. + +## Project Structure + +- `main.go`: Entry point and network listener. +- `lib/`: Core logic and modules. + - `repository.*.go`: Data fetching from external APIs (Wiki, Games). + - `menu.*.go`: Logic for individual BBS sections. + - `sys.print.go`: Telnet-specific I/O and ANSI rendering. + - `sys.header.go`: Core state and UI layouts. + - `sys.i18n.go`: Translation maps. + +## Development + +To build a production binary: +```bash +go build -o bbs-server main.go +``` + +To run with Docker: +```bash +docker-compose up --build +``` diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml new file mode 100644 index 0000000..46380d2 --- /dev/null +++ b/docker-compose.prod.yaml @@ -0,0 +1,18 @@ +services: + bbs-server: + build: + context: . + dockerfile: Dockerfile + container_name: bbs-server-prod + ports: + - "2323:2323" + environment: + - WEBAPP_WIKIJS_TOKEN=${WEBAPP_WIKIJS_TOKEN} + restart: always + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index b2fff69..76f390e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,7 +7,7 @@ services: ports: - "2323:2323" volumes: - - ./:/app/:ro + - ./:/app/ environment: - WEBAPP_WIKIJS_TOKEN=${WEBAPP_WIKIJS_TOKEN:-} restart: unless-stopped diff --git a/lib/menu.catalog.go b/lib/menu.catalog.go new file mode 100644 index 0000000..2eed254 --- /dev/null +++ b/lib/menu.catalog.go @@ -0,0 +1,76 @@ +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 new file mode 100644 index 0000000..3c3177e --- /dev/null +++ b/lib/menu.online.go @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..303a9a7 --- /dev/null +++ b/lib/menu.sysinfo.go @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..af678b3 --- /dev/null +++ b/lib/menu.uzenopal.go @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..b21d188 --- /dev/null +++ b/lib/menu.wiki.go @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..c2634bb --- /dev/null +++ b/lib/repository.catalog.go @@ -0,0 +1,49 @@ +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 new file mode 100644 index 0000000..c38b8ae --- /dev/null +++ b/lib/repository.wiki.go @@ -0,0 +1,106 @@ +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 new file mode 100644 index 0000000..db848e7 --- /dev/null +++ b/lib/sys.header.go @@ -0,0 +1,102 @@ +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/lib/sys.i18n.go b/lib/sys.i18n.go new file mode 100644 index 0000000..8aae750 --- /dev/null +++ b/lib/sys.i18n.go @@ -0,0 +1,49 @@ +package lib + +type T map[string]string + +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! 👋", + "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", + "SysYes": "yes", + "SysNo": "no", +} diff --git a/lib/sys.print.go b/lib/sys.print.go new file mode 100644 index 0000000..d93483f --- /dev/null +++ b/lib/sys.print.go @@ -0,0 +1,202 @@ +package lib + +import ( + "bytes" + "fmt" + "net" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +// ANSI Colors +const ( + R = "\033[0m" + B = "\033[1m" + DIM = "\033[2m" + CY = "\033[1;36m" + YL = "\033[1;33m" + GR = "\033[1;32m" + RD = "\033[1;31m" + MG = "\033[1;35m" + WH = "\033[1;37m" + BL = "\033[1;34m" + GY = "\033[0;37m" +) + +const W = 70 + +type Message struct { + User string + Timestamp string + Text string +} + +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 ", GY, lang["Pause"], R)) + 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, R, + color, R, B, inner, R, color, R, + color, line, R, + )) +} + +func (p *Printer) HR(char string, color string) { + p.Send(fmt.Sprintf("%s%s%s", color, strings.Repeat(char, W), R)) +} + +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") +} diff --git a/main.go b/main.go index 94820eb..0a1e7bb 100644 --- a/main.go +++ b/main.go @@ -1,70 +1,24 @@ package main import ( - "bytes" - "encoding/json" + "bbs-server/lib" "fmt" "net" - "net/http" "os" - "regexp" - "sort" - "strconv" "strings" - "sync" - "time" - "unicode/utf8" ) const ( - Host = "0.0.0.0" - Port = "2323" - WikiJSBaseURL = "https://wiki.teletype.hu" - GamesAPIURL = "https://games.teletype.hu/api/software" - W = 70 + Host = "0.0.0.0" + Port = "2323" ) -// ANSI Colors -const ( - R = "\033[0m" - B = "\033[1m" - DIM = "\033[2m" - CY = "\033[1;36m" - YL = "\033[1;33m" - GR = "\033[1;32m" - RD = "\033[1;31m" - MG = "\033[1;35m" - WH = "\033[1;37m" - BL = "\033[1;34m" - GY = "\033[0;37m" -) - -type Message struct { - User string - Timestamp string - Text string -} - -var ( - messages []Message - onlineUsers = make(map[string]string) // addr -> username - mu sync.Mutex - wikiToken = os.Getenv("WEBAPP_WIKIJS_TOKEN") -) - -const Banner = ` - ████████╗███████╗██╗ ███████╗████████╗██╗ ██╗██████╗ ███████╗ - ██╔══╝██╔════╝██║ ██╔════╝╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝ - ██║ █████╗ ██║ █████╗ ██║ ╚████╔╝ ██████╔╝█████╗ - ██║ ██╔══╝ ██║ ██╔══╝ ██║ ╚██╔╝ ██╔═══╝ ██╔══╝ - ██║ ███████╗███████╗███████╗ ██║ ██║ ██║ ███████╗ - ╚═╝ ╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ - - ░░ BBS v2.0 ░░ teletype.hu ░░ - Üdvözölünk a Teletype közösségi hirdetőtábláján! -` +var bbs *lib.BBS 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) @@ -72,23 +26,23 @@ func main() { } defer ln.Close() - fmt.Printf("Teletype BBS fut → telnet localhost %s\n", Port) - fmt.Printf("Wiki: %s\n", WikiJSBaseURL) - fmt.Printf("Games API: %s\n", GamesAPIURL) - tokenStatus := "✗ nincs beállítva" + 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 = "✓ beállítva" + tokenStatus = "✓ set" } fmt.Printf("Token: %s\n", tokenStatus) - fmt.Println("Leállítás: Ctrl+C") + fmt.Println("Stop: Ctrl+C") for { conn, err := ln.Accept() if err != nil { - fmt.Printf("Hiba a kapcsolódáskor: %v\n", err) + fmt.Printf("Error accepting connection: %v\n", err) continue } - fmt.Printf("[+] Kapcsolódott: %s\n", conn.RemoteAddr().String()) + fmt.Printf("[+] Connected: %s\n", conn.RemoteAddr().String()) go handleClient(conn) } } @@ -97,42 +51,45 @@ func handleClient(conn net.Conn) { defer conn.Close() addr := conn.RemoteAddr().String() - // Telnet negotiation: - // IAC WILL ECHO (255, 251, 1) - // IAC WILL SUPPRESS GO AHEAD (255, 251, 3) - // IAC DONT LINEMODE (255, 254, 34) - send(conn, "\xff\xfb\x01\xff\xfb\x03\xff\xfe\x22") + printer := lib.NewPrinter(conn) + lang := lib.En + ui := lib.NewUI(printer, lang) - send(conn, CY+Banner+R) - send(conn, fmt.Sprintf("\n%sAdd meg a nevedet:%s ", WH, R)) + // Telnet negotiation + printer.Send("\xff\xfb\x01\xff\xfb\x03\xff\xfe\x22") - username, err := readLine(conn) + 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 = "Névtelen" + username = "Anonymous" } if len(username) > 20 { username = username[:20] } - mu.Lock() - onlineUsers[addr] = username - mu.Unlock() + bbs.Mu.Lock() + bbs.OnlineUsers[addr] = username + bbs.Mu.Unlock() defer func() { - mu.Lock() - delete(onlineUsers, addr) - mu.Unlock() + bbs.Mu.Lock() + delete(bbs.OnlineUsers, addr) + bbs.Mu.Unlock() }() - send(conn, fmt.Sprintf("\r\n%sSzia, %s%s%s! Üdv a Teletype BBS-en!%s\r\n", GR, WH, username, GR, R)) + 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 { - send(conn, mainMenu(username)) - choice, err := readLine(conn) + printer.Send(ui.MainMenu(username)) + choice, err := printer.ReadLine() if err != nil { break } @@ -140,591 +97,20 @@ func handleClient(conn net.Conn) { switch c { case "1": - showUzenopal(conn, username) + session.ShowUzenopal() case "2": - showWikiList(conn, "blog", BL, "📰 BLOG BEJEGYZÉSEK") + session.ShowWikiList("blog", lib.BL, lang["MenuBlog"]) case "3": - showWikiList(conn, "howto", MG, "📖 HOWTO ÚTMUTATÓK") + session.ShowWikiList("howto", lib.MG, lang["MenuHowto"]) case "4": - showGames(conn) + session.ShowGames() case "5": - showOnline(conn, addr) + session.ShowOnline() case "6": - showSysinfo(conn) + session.ShowSysinfo() case "Q": - send(conn, fmt.Sprintf("\r\n%sViszlát, %s! 👋%s\r\n\r\n", RD, username, R)) + printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n\r\n", lib.RD, fmt.Sprintf(lang["Goodbye"], username), lib.R)) return } } } - -func visibleLen(s string) int { - re := regexp.MustCompile(`\033\[[0-9;]*m`) - return utf8.RuneCountInString(re.ReplaceAllString(s, "")) -} - -func padLine(content string, width int) string { - vLen := visibleLen(content) - padding := width - vLen - if padding < 0 { - padding = 0 - } - return content + strings.Repeat(" ", padding) -} - -func mainMenu(username string) string { - headerLine := strings.Repeat("═", W) - - // Sorok összeállítása pontos hosszal - l1 := padLine(fmt.Sprintf(" %sFŐMENÜ%s %s@%s%s", YL, R, GY, username, R), W) - l2 := padLine(fmt.Sprintf(" %s[1]%s Üzenőfal", GR, R), W/2) + padLine(fmt.Sprintf(" %s[2]%s Blog bejegyzések", BL, R), W/2) - l3 := padLine(fmt.Sprintf(" %s[3]%s HowTo útmutatók", MG, R), W/2) + padLine(fmt.Sprintf(" %s[4]%s Játékkatalógus", YL, R), W/2) - l4 := padLine(fmt.Sprintf(" %s[5]%s Online felhasználók", CY, R), W/2) + padLine(fmt.Sprintf(" %s[6]%s Rendszerinfo", GY, R), W/2) - l5 := padLine(fmt.Sprintf(" %s[Q]%s Kilépés", RD, R), 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\nVálasztá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, - ) -} - -// ── Telnet I/O ─────────────────────────────────────────────────────────────── - -func send(conn net.Conn, text string) { - // Minden \n-t \r\n-re cserélünk, ha még nem az, hogy Telnet-en ne essen szét az ASCII art - normalized := strings.ReplaceAll(text, "\r\n", "\n") - normalized = strings.ReplaceAll(normalized, "\n", "\r\n") - conn.Write([]byte(normalized)) -} - -func readLine(conn net.Conn) (string, error) { - var buf bytes.Buffer - for { - b := make([]byte, 1) - _, err := conn.Read(b) - if err != nil { - return "", err - } - ch := b[0] - - // Telnet IAC (Interpret As Command) - if ch == 255 { - cmd := make([]byte, 2) - _, err := conn.Read(cmd) - if err != nil { - return "", err - } - if cmd[0] == 250 { // SB - for { - tmp := make([]byte, 1) - _, err := conn.Read(tmp) - if err != nil || tmp[0] == 240 { // SE - break - } - } - } - continue - } - - // Enter billentyű (Telnetnél \r\n vagy csak \r) - if ch == '\r' || ch == '\n' { - res := buf.String() - send(conn, "\r\n") // Visszhangozzuk a sortörést - return res, nil - } - - // Backspace (8=BS, 127=DEL) - if ch == 8 || ch == 127 { - if buf.Len() > 0 { - curr := buf.Bytes() - buf.Reset() - buf.Write(curr[:len(curr)-1]) - send(conn, "\b \b") - } - continue - } - - // Csak a látható karaktereket gyűjtjük - if ch >= 32 && ch < 127 { - buf.WriteByte(ch) - send(conn, string(ch)) - } - } -} - -func pause(conn net.Conn) { - send(conn, fmt.Sprintf("\r\n%s [ Nyomj ENTER-t... ]%s ", GY, R)) - readLine(conn) -} - -// ── API hívások ─────────────────────────────────────────────────────────────── - -func 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 wikiToken != "" { - req.Header.Set("Authorization", "Bearer "+wikiToken) - } - - 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 -} - -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"` -} - -func fetchWikiList(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 := 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 fetchWikiContent(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 := 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 -} - -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"` -} - -func 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 -} - -// ── Szöveg segédek ──────────────────────────────────────────────────────────── - -func boxHeader(title string, color string) 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) - return fmt.Sprintf( - "\n%s%s%s\n%s║%s %s%s%s %s║%s\n%s%s%s\n", - color, line, R, - color, R, B, inner, R, color, R, - color, line, R, - ) -} - -func hr(char string, color string) string { - return fmt.Sprintf("%s%s%s", color, strings.Repeat(char, W), R) -} - -func 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 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 wrapped(text string, indent int, maxChars int) string { - if len(text) > maxChars { - text = text[:maxChars] - } - text = 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") -} - -// ── Menük ───────────────────────────────────────────────────────────────────── - -func showUzenopal(conn net.Conn, username string) { - send(conn, boxHeader("📋 ÜZENŐFAL", GR)) - mu.Lock() - snap := make([]Message, len(messages)) - copy(snap, messages) - mu.Unlock() - - if len(snap) == 0 { - send(conn, fmt.Sprintf(" %s(Még nincs üzenet — legyél az első!)%s\r\n", GY, R)) - } else { - start := 0 - if len(snap) > 30 { - start = len(snap) - 30 - } - for i, msg := range snap[start:] { - send(conn, 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)) - } - } - - send(conn, fmt.Sprintf("\r\n%s[N]%s Új üzenet írása %s[ENTER]%s Vissza → ", GY, R, GY, R)) - choice, _ := readLine(conn) - if strings.ToUpper(strings.TrimSpace(choice)) == "N" { - send(conn, fmt.Sprintf("\r\n%sÜzenet szövege:%s ", WH, R)) - msgText, _ := readLine(conn) - msgText = strings.TrimSpace(msgText) - if msgText != "" { - if len(msgText) > 200 { - msgText = msgText[:200] - } - ts := time.Now().Format("01-02 15:04") - mu.Lock() - messages = append(messages, Message{User: username, Timestamp: ts, Text: msgText}) - mu.Unlock() - send(conn, fmt.Sprintf("\r\n%s✓ Elküldve!%s\r\n", GR, R)) - } else { - send(conn, fmt.Sprintf("\r\n%s(Üres – nem küldtük el)%s\r\n", GY, R)) - } - } -} - -func showWikiList(conn net.Conn, tag string, color string, title string) { - send(conn, boxHeader(title, color)) - send(conn, fmt.Sprintf(" %sTöltés folyamatban...%s\r\n", GY, R)) - - pages, err := fetchWikiList(tag) - if err != nil { - send(conn, fmt.Sprintf("\r\n%sKapcsolati hiba: %v%s\r\n", RD, err, R)) - pause(conn) - return - } - - // clear "Töltés" line - send(conn, "\r\033[A\033[2K") - - if len(pages) == 0 { - send(conn, fmt.Sprintf(" %sNincs találat a '%s' tag-re.%s\r\n", GY, tag, R)) - pause(conn) - return - } - - maxIdx := 25 - if len(pages) < maxIdx { - maxIdx = len(pages) - } - - for i, p := range pages[:maxIdx] { - date := fmtDate(p.CreatedAt) - if date == "–" { - date = 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] - } - send(conn, fmt.Sprintf(" %s%2d%s %s%s%s\r\n", color, i+1, R, WH, titleP, R)) - if desc != "" { - send(conn, fmt.Sprintf(" %s%s %s%s%s\r\n", GY, date, DIM, desc, R)) - } else { - send(conn, fmt.Sprintf(" %s%s%s\r\n", GY, date, R)) - } - } - - send(conn, fmt.Sprintf("\r\n%sSzám a tartalom megnyitásához, ENTER a visszalépéshez:%s ", GY, R)) - choice, _ := readLine(conn) - choice = strings.TrimSpace(choice) - idx, err := strconv.Atoi(choice) - if err != nil || idx < 1 || idx > len(pages) { - return - } - - page := pages[idx-1] - send(conn, fmt.Sprintf("\r\n%sTartalom betöltése...%s", GY, R)) - content, err := fetchWikiContent(page.ID) - if err != nil { - send(conn, fmt.Sprintf("\r\n%sHiba: %v%s\r\n", RD, err, R)) - pause(conn) - return - } - - send(conn, "\r\033[A\033[2K") - send(conn, fmt.Sprintf("\r\n%s\r\n", hr("═", color))) - send(conn, fmt.Sprintf(" %s%s%s%s\r\n", WH, B, page.Title, R)) - url := fmt.Sprintf("%s/%s/%s", WikiJSBaseURL, page.Locale, page.Path) - send(conn, fmt.Sprintf(" %s%s %s%s%s\r\n", GY, fmtDate(page.CreatedAt), DIM, url, R)) - send(conn, fmt.Sprintf("%s\r\n\r\n", hr("─", GY))) - - body := wrapped(content, 2, 5000) - for _, line := range strings.Split(body, "\r\n") { - send(conn, line+"\r\n") - } - - send(conn, fmt.Sprintf("\r\n%s\r\n", hr("─", GY))) - pause(conn) -} - -func showGames(conn net.Conn) { - send(conn, boxHeader("🎮 JÁTÉKKATALÓGUS", YL)) - send(conn, fmt.Sprintf(" %sTöltés folyamatban...%s\r\n", GY, R)) - - softwares, err := fetchGames() - if err != nil { - send(conn, fmt.Sprintf("\r\n%sKapcsolati hiba: %v%s\r\n", RD, err, R)) - pause(conn) - return - } - - send(conn, "\r\033[A\033[2K") - - if len(softwares) == 0 { - send(conn, fmt.Sprintf(" %sNincs elérhető játék.%s\r\n", GY, R)) - pause(conn) - return - } - - base := "https://games.teletype.hu" - - for i, entry := range softwares { - sw := entry.Software - lr := entry.LatestRelease - - send(conn, fmt.Sprintf("\r\n %s%s%s\r\n", YL, strings.Repeat("─", W-4), R)) - send(conn, 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 := wrapped(sw.Desc, 7, 1000) - for _, line := range strings.Split(wrappedDesc, "\r\n") { - send(conn, 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, " ") - } - send(conn, fmt.Sprintf(" %sLegújabb: v%s%s %s\r\n", GY, lr.Version, R, badgeStr)) - - if lr.HTMLFolderPath != "" { - url := base + lr.HTMLFolderPath - send(conn, fmt.Sprintf(" %s▶ %s%s\r\n", DIM, url, R)) - } - } - send(conn, fmt.Sprintf(" %s%d verzió elérhető%s\r\n", GY, len(entry.Releases), R)) - } - - send(conn, fmt.Sprintf("\r\n%s\r\n", hr("─", YL))) - send(conn, fmt.Sprintf(" %sTeljes katalógus: %s%s\r\n", GY, base, R)) - pause(conn) -} - -func showOnline(conn net.Conn, myAddr string) { - send(conn, boxHeader("👥 ONLINE FELHASZNÁLÓK", CY)) - mu.Lock() - snap := make(map[string]string) - for k, v := range onlineUsers { - snap[k] = v - } - 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 == myAddr { - marker = fmt.Sprintf(" %s← te%s", GR, R) - } - send(conn, fmt.Sprintf(" %s•%s %s%s%s%s\r\n", CY, R, WH, user, R, marker)) - } - send(conn, fmt.Sprintf("\r\n %sÖsszesen: %s%d%s fő online%s\r\n", GY, WH, len(snap), GY, R)) - pause(conn) -} - -func showSysinfo(conn net.Conn) { - send(conn, boxHeader("ℹ RENDSZERINFO", GY)) - mu.Lock() - uc := len(onlineUsers) - mc := len(messages) - mu.Unlock() - - now := time.Now().Format("2006-01-02 15:04:05") - rows := [][]string{ - {"Szerver ideje", now}, - {"Online userek", strconv.Itoa(uc)}, - {"Üzenetek száma", strconv.Itoa(mc)}, - {"Wiki URL", WikiJSBaseURL}, - {"Games API", GamesAPIURL}, - {"Token beállítva", func() string { - if wikiToken != "" { - return "igen" - } - return "nem" - }()}, - {"Platform", "Go BBS v2.0"}, - } - - for _, row := range rows { - send(conn, fmt.Sprintf(" %s%-18s%s %s%s%s\r\n", GY, row[0], R, WH, row[1], R)) - } - pause(conn) -}