refact round 3
This commit is contained in:
45
content/data.catalog.go
Normal file
45
content/data.catalog.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
66
content/data.message.go
Normal file
66
content/data.message.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
102
content/data.wiki.go
Normal file
102
content/data.wiki.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
108
content/handler.blog.go
Normal file
108
content/handler.blog.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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.BL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.GY, s.Lang["WikiLoading"], engine.R))
|
||||||
|
|
||||||
|
pages, err := repo.fetchList(tag)
|
||||||
|
if err != nil {
|
||||||
|
s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", engine.RD, s.Lang["WikiConnError"], err, engine.R))
|
||||||
|
s.Printer.Pause(s.Lang)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Printer.Send("\r\033[A\033[2K")
|
||||||
|
|
||||||
|
if len(pages) == 0 {
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, fmt.Sprintf(s.Lang["WikiNoResults"], tag), engine.R))
|
||||||
|
s.Printer.Pause(s.Lang)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxIdx := 25
|
||||||
|
if len(pages) < maxIdx {
|
||||||
|
maxIdx = len(pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, p := range pages[:maxIdx] {
|
||||||
|
date := s.Printer.FmtDate(p.CreatedAt)
|
||||||
|
if date == "–" {
|
||||||
|
date = s.Printer.FmtDate(p.UpdatedAt)
|
||||||
|
}
|
||||||
|
titleP := p.Title
|
||||||
|
if titleP == "" {
|
||||||
|
titleP = p.Path
|
||||||
|
}
|
||||||
|
if len(titleP) > 55 {
|
||||||
|
titleP = titleP[:55]
|
||||||
|
}
|
||||||
|
desc := p.Description
|
||||||
|
if len(desc) > 60 {
|
||||||
|
desc = desc[:60]
|
||||||
|
}
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%2d%s %s%s%s\r\n", color, i+1, engine.R, engine.WH, titleP, engine.R))
|
||||||
|
if desc != "" {
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.GY, date, engine.DIM, desc, engine.R))
|
||||||
|
} else {
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, date, engine.R))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", engine.GY, s.Lang["WikiEnterNum"], engine.R))
|
||||||
|
choice, _ := s.Printer.ReadLine()
|
||||||
|
choice = strings.TrimSpace(choice)
|
||||||
|
idx, err := strconv.Atoi(choice)
|
||||||
|
if err != nil || idx < 1 || idx > len(pages) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := pages[idx-1]
|
||||||
|
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s", engine.GY, s.Lang["WikiFetchContent"], engine.R))
|
||||||
|
pageContent, err := repo.fetchContent(page.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.Printer.Send(fmt.Sprintf("\r\n%sHiba: %v%s\r\n", engine.RD, err, engine.R))
|
||||||
|
s.Printer.Pause(s.Lang)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Printer.Send("\r\033[A\033[2K")
|
||||||
|
s.Printer.Send("\r\n")
|
||||||
|
s.Printer.HR("═", color)
|
||||||
|
s.Printer.Send("\r\n")
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%s%s%s\r\n", engine.WH, engine.B, page.Title, engine.R))
|
||||||
|
url := fmt.Sprintf("%s/%s/%s", WikiJSBaseURL, page.Locale, page.Path)
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.GY, s.Printer.FmtDate(page.CreatedAt), engine.DIM, url, engine.R))
|
||||||
|
s.Printer.HR("─", engine.GY)
|
||||||
|
s.Printer.Send("\r\n\r\n")
|
||||||
|
|
||||||
|
body := s.Printer.Wrapped(pageContent, 2, 5000)
|
||||||
|
for _, line := range strings.Split(body, "\r\n") {
|
||||||
|
s.Printer.Send(line + "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Printer.Send("\r\n")
|
||||||
|
s.Printer.HR("─", engine.GY)
|
||||||
|
s.Printer.Send("\r\n")
|
||||||
|
s.Printer.Pause(s.Lang)
|
||||||
|
}
|
||||||
@@ -2,52 +2,11 @@ package content
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bbs-server/engine"
|
"bbs-server/engine"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const GamesAPIURL = "https://games.teletype.hu/api/software"
|
// CatalogHandler handles the Game Catalog menu item
|
||||||
|
|
||||||
type softwareEntry struct {
|
|
||||||
Software struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
Desc string `json:"desc"`
|
|
||||||
} `json:"software"`
|
|
||||||
Releases []interface{} `json:"releases"`
|
|
||||||
LatestRelease *struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
HTMLFolderPath string `json:"htmlFolderPath"`
|
|
||||||
CartridgePath string `json:"cartridgePath"`
|
|
||||||
SourcePath string `json:"sourcePath"`
|
|
||||||
DocsFolderPath string `json:"docsFolderPath"`
|
|
||||||
} `json:"latestRelease"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type catalogRepository struct{}
|
|
||||||
|
|
||||||
func (r *catalogRepository) fetchGames() ([]softwareEntry, error) {
|
|
||||||
client := &http.Client{Timeout: 12 * time.Second}
|
|
||||||
resp, err := client.Get(GamesAPIURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Softwares []softwareEntry `json:"softwares"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result.Softwares, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CatalogHandler renders the game catalog
|
|
||||||
type CatalogHandler struct {
|
type CatalogHandler struct {
|
||||||
repo *catalogRepository
|
repo *catalogRepository
|
||||||
}
|
}
|
||||||
@@ -56,8 +15,7 @@ func NewCatalogHandler() *CatalogHandler {
|
|||||||
return &CatalogHandler{repo: &catalogRepository{}}
|
return &CatalogHandler{repo: &catalogRepository{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show displays the game catalog
|
func (h *CatalogHandler) Handle(s *engine.Session) {
|
||||||
func (h *CatalogHandler) Show(s *engine.Session) {
|
|
||||||
s.Printer.BoxHeader(s.Lang["CatTitle"], engine.YL)
|
s.Printer.BoxHeader(s.Lang["CatTitle"], engine.YL)
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, s.Lang["WikiLoading"], engine.R))
|
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, s.Lang["WikiLoading"], engine.R))
|
||||||
|
|
||||||
8
content/handler.go
Normal file
8
content/handler.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package content
|
||||||
|
|
||||||
|
import "bbs-server/engine"
|
||||||
|
|
||||||
|
// Handler is the common interface for all BBS menu handlers
|
||||||
|
type Handler interface {
|
||||||
|
Handle(s *engine.Session)
|
||||||
|
}
|
||||||
16
content/handler.howtos.go
Normal file
16
content/handler.howtos.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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.MG)
|
||||||
|
}
|
||||||
40
content/handler.messageboard.index.go
Normal file
40
content/handler.messageboard.index.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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.GR)
|
||||||
|
|
||||||
|
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.GY, s.Lang["MsgNoMessages"], engine.R))
|
||||||
|
} else {
|
||||||
|
start := 0
|
||||||
|
if len(snap) > 30 {
|
||||||
|
start = len(snap) - 30
|
||||||
|
}
|
||||||
|
for i, msg := range snap[start:] {
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%02d%s %s%s%s %s%s:%s %s\r\n",
|
||||||
|
engine.GR, i+1, engine.R,
|
||||||
|
engine.GY, msg.Timestamp, engine.R,
|
||||||
|
engine.WH, msg.User, engine.R, msg.Text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Printer.Pause(s.Lang)
|
||||||
|
}
|
||||||
38
content/handler.messageboard.new.go
Normal file
38
content/handler.messageboard.new.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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.WH, s.Lang["MsgEnterText"], engine.R))
|
||||||
|
msgText, _ := s.Printer.ReadLine()
|
||||||
|
msgText = strings.TrimSpace(msgText)
|
||||||
|
if msgText != "" {
|
||||||
|
if len(msgText) > 200 {
|
||||||
|
msgText = msgText[:200]
|
||||||
|
}
|
||||||
|
ts := time.Now().Format("01-02 15:04")
|
||||||
|
msg := message{Timestamp: ts, User: s.Username, Text: msgText}
|
||||||
|
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.GR, s.Lang["MsgSent"], engine.R))
|
||||||
|
} else {
|
||||||
|
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", engine.GY, s.Lang["MsgEmpty"], engine.R))
|
||||||
|
}
|
||||||
|
s.Printer.Pause(s.Lang)
|
||||||
|
}
|
||||||
@@ -6,8 +6,14 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Online displays currently logged-in users
|
// OnlineHandler displays currently logged-in users
|
||||||
func Online(s *engine.Session) {
|
type OnlineHandler struct{}
|
||||||
|
|
||||||
|
func NewOnlineHandler() *OnlineHandler {
|
||||||
|
return &OnlineHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OnlineHandler) Handle(s *engine.Session) {
|
||||||
s.Printer.BoxHeader(s.Lang["OnlineTitle"], engine.CY)
|
s.Printer.BoxHeader(s.Lang["OnlineTitle"], engine.CY)
|
||||||
|
|
||||||
snap := s.State.Snapshot()
|
snap := s.State.Snapshot()
|
||||||
27
content/handler.sysinfo.go
Normal file
27
content/handler.sysinfo.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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.GY)
|
||||||
|
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%-15s%s %d\r\n", engine.GY, s.Lang["SysUsers"], engine.WH, s.State.UserCount()))
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%-15s%s %d\r\n", engine.GY, s.Lang["SysMessages"], engine.WH, h.board.Count()))
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%-15s%s %s\r\n", engine.GY, s.Lang["SysOS"], engine.WH, runtime.GOOS))
|
||||||
|
s.Printer.Send(fmt.Sprintf(" %s%-15s%s %s\r\n", engine.GY, s.Lang["SysArch"], engine.WH, runtime.GOARCH))
|
||||||
|
|
||||||
|
s.Printer.Pause(s.Lang)
|
||||||
|
}
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package content
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bbs-server/engine"
|
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type message struct {
|
|
||||||
Timestamp string
|
|
||||||
User string
|
|
||||||
Text string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MessageBoard holds message board data and its handler
|
|
||||||
type MessageBoard struct {
|
|
||||||
messages []message
|
|
||||||
mu sync.Mutex
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMessageBoard loads messages from the given .dat file (if it exists)
|
|
||||||
func NewMessageBoard(path string) *MessageBoard {
|
|
||||||
b := &MessageBoard{path: path}
|
|
||||||
b.load()
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *MessageBoard) load() {
|
|
||||||
f, err := os.Open(b.path)
|
|
||||||
if err != nil {
|
|
||||||
return // file does not exist yet, start empty
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
r := csv.NewReader(f)
|
|
||||||
records, err := r.ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, row := range records {
|
|
||||||
if len(row) != 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.messages = append(b.messages, message{Timestamp: row[0], User: row[1], Text: row[2]})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *MessageBoard) append(msg message) error {
|
|
||||||
f, err := os.OpenFile(b.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
w := csv.NewWriter(f)
|
|
||||||
err = w.Write([]string{msg.Timestamp, msg.User, msg.Text})
|
|
||||||
w.Flush()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count returns the number of messages in a thread-safe manner
|
|
||||||
func (b *MessageBoard) Count() int {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
return len(b.messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show displays the message board and handles new message input
|
|
||||||
func (b *MessageBoard) Show(s *engine.Session) {
|
|
||||||
s.Printer.BoxHeader(s.Lang["MsgBoardTitle"], engine.GR)
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
snap := make([]message, len(b.messages))
|
|
||||||
copy(snap, b.messages)
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
if len(snap) == 0 {
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, s.Lang["MsgNoMessages"], engine.R))
|
|
||||||
} else {
|
|
||||||
start := 0
|
|
||||||
if len(snap) > 30 {
|
|
||||||
start = len(snap) - 30
|
|
||||||
}
|
|
||||||
for i, msg := range snap[start:] {
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%02d%s %s%s%s %s%s:%s %s\r\n",
|
|
||||||
engine.GR, i+1, engine.R,
|
|
||||||
engine.GY, msg.Timestamp, engine.R,
|
|
||||||
engine.WH, msg.User, engine.R, msg.Text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Printer.Send(fmt.Sprintf("\r\n%s[N]%s %s %s[ENTER]%s %s → ",
|
|
||||||
engine.GY, engine.R, s.Lang["MsgNew"],
|
|
||||||
engine.GY, engine.R, s.Lang["MsgBack"]))
|
|
||||||
|
|
||||||
choice, _ := s.Printer.ReadLine()
|
|
||||||
if strings.ToUpper(strings.TrimSpace(choice)) == "N" {
|
|
||||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", engine.WH, s.Lang["MsgEnterText"], engine.R))
|
|
||||||
msgText, _ := s.Printer.ReadLine()
|
|
||||||
msgText = strings.TrimSpace(msgText)
|
|
||||||
if msgText != "" {
|
|
||||||
if len(msgText) > 200 {
|
|
||||||
msgText = msgText[:200]
|
|
||||||
}
|
|
||||||
ts := time.Now().Format("01-02 15:04")
|
|
||||||
msg := message{Timestamp: ts, User: s.Username, Text: msgText}
|
|
||||||
b.mu.Lock()
|
|
||||||
b.messages = append(b.messages, msg)
|
|
||||||
b.mu.Unlock()
|
|
||||||
b.append(msg)
|
|
||||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", engine.GR, s.Lang["MsgSent"], engine.R))
|
|
||||||
} else {
|
|
||||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", engine.GY, s.Lang["MsgEmpty"], engine.R))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package content
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bbs-server/engine"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sysinfo returns a HandlerFunc that displays system information
|
|
||||||
func Sysinfo(board *MessageBoard) engine.HandlerFunc {
|
|
||||||
return func(s *engine.Session) {
|
|
||||||
s.Printer.BoxHeader(s.Lang["SysInfoTitle"], engine.GY)
|
|
||||||
|
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
|
||||||
|
|
||||||
rows := [][]string{
|
|
||||||
{s.Lang["SysServerTime"], now},
|
|
||||||
{s.Lang["SysOnlineUsers"], strconv.Itoa(s.State.UserCount())},
|
|
||||||
{s.Lang["SysMsgCount"], strconv.Itoa(board.Count())},
|
|
||||||
{s.Lang["SysWikiURL"], WikiJSBaseURL},
|
|
||||||
{s.Lang["SysGamesAPI"], GamesAPIURL},
|
|
||||||
{s.Lang["SysPlatform"], "Go BBS v2.0"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%-18s%s %s%s%s\r\n",
|
|
||||||
engine.GY, row[0], engine.R, engine.WH, row[1], engine.R))
|
|
||||||
}
|
|
||||||
s.Printer.Pause(s.Lang)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
203
content/wiki.go
203
content/wiki.go
@@ -1,203 +0,0 @@
|
|||||||
package content
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bbs-server/engine"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const WikiJSBaseURL = "https://wiki.teletype.hu"
|
|
||||||
|
|
||||||
type wikiPage struct {
|
|
||||||
ID interface{} `json:"id"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
CreatedAt string `json:"createdAt"`
|
|
||||||
UpdatedAt string `json:"updatedAt"`
|
|
||||||
Locale string `json:"locale"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type wikiRepository struct {
|
|
||||||
token string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *wikiRepository) graphql(query string) (map[string]interface{}, error) {
|
|
||||||
payload := map[string]string{"query": query}
|
|
||||||
data, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", WikiJSBaseURL+"/graphql", bytes.NewBuffer(data))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
if r.token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 12 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *wikiRepository) fetchList(tag string) ([]wikiPage, error) {
|
|
||||||
q := fmt.Sprintf(`{ pages { list(orderBy: CREATED, orderByDirection: DESC, tags: ["%s"]) { id path title description createdAt updatedAt locale } } }`, tag)
|
|
||||||
res, err := r.graphql(q)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, ok := res["data"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid response format")
|
|
||||||
}
|
|
||||||
pages, ok := data["pages"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid response format")
|
|
||||||
}
|
|
||||||
listRaw, ok := pages["list"].([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid response format")
|
|
||||||
}
|
|
||||||
|
|
||||||
var list []wikiPage
|
|
||||||
jsonBytes, _ := json.Marshal(listRaw)
|
|
||||||
json.Unmarshal(jsonBytes, &list)
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *wikiRepository) fetchContent(pageID interface{}) (string, error) {
|
|
||||||
var idStr string
|
|
||||||
switch v := pageID.(type) {
|
|
||||||
case string:
|
|
||||||
idStr = v
|
|
||||||
case float64:
|
|
||||||
idStr = fmt.Sprintf("%.0f", v)
|
|
||||||
default:
|
|
||||||
idStr = fmt.Sprintf("%v", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`{ pages { single(id: %s) { content } } }`, idStr)
|
|
||||||
res, err := r.graphql(q)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
data := res["data"].(map[string]interface{})
|
|
||||||
pages := data["pages"].(map[string]interface{})
|
|
||||||
single := pages["single"].(map[string]interface{})
|
|
||||||
return single["content"].(string), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WikiHandler renders Wiki.js content in the BBS
|
|
||||||
type WikiHandler struct {
|
|
||||||
repo *wikiRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWikiHandler(token string) *WikiHandler {
|
|
||||||
return &WikiHandler{repo: &wikiRepository{token: token}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns a HandlerFunc that lists pages with the given tag
|
|
||||||
func (h *WikiHandler) List(tag, title, color string) engine.HandlerFunc {
|
|
||||||
return func(s *engine.Session) {
|
|
||||||
s.Printer.BoxHeader(title, color)
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, s.Lang["WikiLoading"], engine.R))
|
|
||||||
|
|
||||||
pages, err := h.repo.fetchList(tag)
|
|
||||||
if err != nil {
|
|
||||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", engine.RD, s.Lang["WikiConnError"], err, engine.R))
|
|
||||||
s.Printer.Pause(s.Lang)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Printer.Send("\r\033[A\033[2K")
|
|
||||||
|
|
||||||
if len(pages) == 0 {
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, fmt.Sprintf(s.Lang["WikiNoResults"], tag), engine.R))
|
|
||||||
s.Printer.Pause(s.Lang)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
maxIdx := 25
|
|
||||||
if len(pages) < maxIdx {
|
|
||||||
maxIdx = len(pages)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, p := range pages[:maxIdx] {
|
|
||||||
date := s.Printer.FmtDate(p.CreatedAt)
|
|
||||||
if date == "–" {
|
|
||||||
date = s.Printer.FmtDate(p.UpdatedAt)
|
|
||||||
}
|
|
||||||
titleP := p.Title
|
|
||||||
if titleP == "" {
|
|
||||||
titleP = p.Path
|
|
||||||
}
|
|
||||||
if len(titleP) > 55 {
|
|
||||||
titleP = titleP[:55]
|
|
||||||
}
|
|
||||||
desc := p.Description
|
|
||||||
if len(desc) > 60 {
|
|
||||||
desc = desc[:60]
|
|
||||||
}
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%2d%s %s%s%s\r\n", color, i+1, engine.R, engine.WH, titleP, engine.R))
|
|
||||||
if desc != "" {
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.GY, date, engine.DIM, desc, engine.R))
|
|
||||||
} else {
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.GY, date, engine.R))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", engine.GY, s.Lang["WikiEnterNum"], engine.R))
|
|
||||||
choice, _ := s.Printer.ReadLine()
|
|
||||||
choice = strings.TrimSpace(choice)
|
|
||||||
idx, err := strconv.Atoi(choice)
|
|
||||||
if err != nil || idx < 1 || idx > len(pages) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
page := pages[idx-1]
|
|
||||||
s.Printer.Send(fmt.Sprintf("\r\n%s%s%s", engine.GY, s.Lang["WikiFetchContent"], engine.R))
|
|
||||||
pageContent, err := h.repo.fetchContent(page.ID)
|
|
||||||
if err != nil {
|
|
||||||
s.Printer.Send(fmt.Sprintf("\r\n%sHiba: %v%s\r\n", engine.RD, err, engine.R))
|
|
||||||
s.Printer.Pause(s.Lang)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Printer.Send("\r\033[A\033[2K")
|
|
||||||
s.Printer.Send("\r\n")
|
|
||||||
s.Printer.HR("═", color)
|
|
||||||
s.Printer.Send("\r\n")
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%s%s%s\r\n", engine.WH, engine.B, page.Title, engine.R))
|
|
||||||
url := fmt.Sprintf("%s/%s/%s", WikiJSBaseURL, page.Locale, page.Path)
|
|
||||||
s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.GY, s.Printer.FmtDate(page.CreatedAt), engine.DIM, url, engine.R))
|
|
||||||
s.Printer.HR("─", engine.GY)
|
|
||||||
s.Printer.Send("\r\n\r\n")
|
|
||||||
|
|
||||||
body := s.Printer.Wrapped(pageContent, 2, 5000)
|
|
||||||
for _, line := range strings.Split(body, "\r\n") {
|
|
||||||
s.Printer.Send(line + "\r\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Printer.Send("\r\n")
|
|
||||||
s.Printer.HR("─", engine.GY)
|
|
||||||
s.Printer.Send("\r\n")
|
|
||||||
s.Printer.Pause(s.Lang)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
main.go
26
main.go
@@ -22,7 +22,7 @@ const banner = `
|
|||||||
╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗███████║
|
╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗███████║
|
||||||
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝
|
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝
|
||||||
|
|
||||||
░░ BBS v2.0 ░░ teletype.hu ░░
|
░░ BBS v2.0 ░░ games.teletype.hu ░░
|
||||||
Welcome to the Teletype community bulletin board!
|
Welcome to the Teletype community bulletin board!
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -42,9 +42,14 @@ func main() {
|
|||||||
fmt.Printf("Games API: %s\n", content.GamesAPIURL)
|
fmt.Printf("Games API: %s\n", content.GamesAPIURL)
|
||||||
fmt.Printf("Token: %s\n", tokenStatus)
|
fmt.Printf("Token: %s\n", tokenStatus)
|
||||||
|
|
||||||
wiki := content.NewWikiHandler(wikiToken)
|
messageBoard := content.NewMessageBoard(boardPath)
|
||||||
cat := content.NewCatalogHandler()
|
blogHandler := content.NewBlogHandler(wikiToken)
|
||||||
board := content.NewMessageBoard(boardPath)
|
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{
|
bbs := engine.New(engine.Config{
|
||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
@@ -55,12 +60,13 @@ func main() {
|
|||||||
|
|
||||||
bbs.Menu(func(m *engine.Menu) {
|
bbs.Menu(func(m *engine.Menu) {
|
||||||
m.Title("MAIN MENU")
|
m.Title("MAIN MENU")
|
||||||
m.Item("1", "Message Board", engine.GR, board.Show)
|
m.Item("1", "Message Board", engine.GR, messageBoardIndexHandler.Handle)
|
||||||
m.Item("2", "Blog Posts", engine.BL, wiki.List("blog", "Blog Posts", engine.BL))
|
m.Item("N", "New Message", engine.WH, messageBoardNewHandler.Handle)
|
||||||
m.Item("3", "HowTo Guides", engine.MG, wiki.List("howto", "HowTo Guides", engine.MG))
|
m.Item("2", "Blog Posts", engine.BL, blogHandler.Handle)
|
||||||
m.Item("4", "Game Catalog", engine.YL, cat.Show)
|
m.Item("3", "HowTo Guides", engine.MG, howtoHandler.Handle)
|
||||||
m.Item("5", "Online Users", engine.CY, content.Online)
|
m.Item("4", "Game Catalog", engine.YL, catalogHandler.Handle)
|
||||||
m.Item("6", "System Info", engine.GY, content.Sysinfo(board))
|
m.Item("5", "Online Users", engine.CY, onlineHandler.Handle)
|
||||||
|
m.Item("6", "System Info", engine.GY, sysinfoHandler.Handle)
|
||||||
m.Item("Q", "Exit", engine.RD, engine.Exit)
|
m.Item("Q", "Exit", engine.RD, engine.Exit)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user