This commit is contained in:
2026-03-10 20:23:54 +01:00
parent b8e6df3a04
commit e837a9a04e
18 changed files with 985 additions and 659 deletions

31
.air.toml Normal file
View File

@@ -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

2
.gitignore vendored
View File

@@ -1 +1,3 @@
.env
bbs-server
tmp

31
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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"]

69
README.md Normal file
View File

@@ -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 <repository-url>
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
```

18
docker-compose.prod.yaml Normal file
View File

@@ -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

View File

@@ -7,7 +7,7 @@ services:
ports:
- "2323:2323"
volumes:
- ./:/app/:ro
- ./:/app/
environment:
- WEBAPP_WIKIJS_TOKEN=${WEBAPP_WIKIJS_TOKEN:-}
restart: unless-stopped

76
lib/menu.catalog.go Normal file
View File

@@ -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)
}

33
lib/menu.online.go Normal file
View File

@@ -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)
}

31
lib/menu.sysinfo.go Normal file
View File

@@ -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)
}

47
lib/menu.uzenopal.go Normal file
View File

@@ -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))
}
}
}

93
lib/menu.wiki.go Normal file
View File

@@ -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)
}

49
lib/repository.catalog.go Normal file
View File

@@ -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
}

106
lib/repository.wiki.go Normal file
View File

@@ -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
}

102
lib/sys.header.go Normal file
View File

@@ -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"],
)
}

49
lib/sys.i18n.go Normal file
View File

@@ -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",
}

202
lib/sys.print.go Normal file
View File

@@ -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")
}

700
main.go
View File

@@ -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)
}