]> git.r.bdr.sh - rbdr/mobius/blame_incremental - hotline/client.go
Convert more bespoke methods to io.Reader/io.Writer interfaces
[rbdr/mobius] / hotline / client.go
... / ...
CommitLineData
1package hotline
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "embed"
8 "encoding/binary"
9 "errors"
10 "fmt"
11 "github.com/gdamore/tcell/v2"
12 "github.com/rivo/tview"
13 "gopkg.in/yaml.v3"
14 "log/slog"
15 "math/big"
16 "math/rand"
17 "net"
18 "os"
19 "strings"
20 "time"
21)
22
23const (
24 trackerListPage = "trackerList"
25 serverUIPage = "serverUI"
26)
27
28//go:embed banners/*.txt
29var bannerDir embed.FS
30
31type Bookmark struct {
32 Name string `yaml:"Name"`
33 Addr string `yaml:"Addr"`
34 Login string `yaml:"Login"`
35 Password string `yaml:"Password"`
36}
37
38type ClientPrefs struct {
39 Username string `yaml:"Username"`
40 IconID int `yaml:"IconID"`
41 Bookmarks []Bookmark `yaml:"Bookmarks"`
42 Tracker string `yaml:"Tracker"`
43 EnableBell bool `yaml:"EnableBell"`
44}
45
46func (cp *ClientPrefs) IconBytes() []byte {
47 iconBytes := make([]byte, 2)
48 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
49 return iconBytes
50}
51
52func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) {
53 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
54}
55
56func readConfig(cfgPath string) (*ClientPrefs, error) {
57 fh, err := os.Open(cfgPath)
58 if err != nil {
59 return nil, err
60 }
61
62 prefs := ClientPrefs{}
63 decoder := yaml.NewDecoder(fh)
64 if err := decoder.Decode(&prefs); err != nil {
65 return nil, err
66 }
67 return &prefs, nil
68}
69
70type Client struct {
71 cfgPath string
72 DebugBuf *DebugBuffer
73 Connection net.Conn
74 UserAccess []byte
75 filePath []string
76 UserList []User
77 Logger *slog.Logger
78 activeTasks map[uint32]*Transaction
79 serverName string
80
81 Pref *ClientPrefs
82
83 Handlers map[uint16]ClientHandler
84
85 UI *UI
86
87 Inbox chan *Transaction
88}
89
90type ClientHandler func(context.Context, *Client, *Transaction) ([]Transaction, error)
91
92func (c *Client) HandleFunc(transactionID uint16, handler ClientHandler) {
93 c.Handlers[transactionID] = handler
94}
95
96func NewClient(username string, logger *slog.Logger) *Client {
97 c := &Client{
98 Logger: logger,
99 activeTasks: make(map[uint32]*Transaction),
100 Handlers: make(map[uint16]ClientHandler),
101 }
102 c.Pref = &ClientPrefs{Username: username}
103
104 return c
105}
106
107func NewUIClient(cfgPath string, logger *slog.Logger) *Client {
108 c := &Client{
109 cfgPath: cfgPath,
110 Logger: logger,
111 activeTasks: make(map[uint32]*Transaction),
112 Handlers: clientHandlers,
113 }
114 c.UI = NewUI(c)
115
116 prefs, err := readConfig(cfgPath)
117 if err != nil {
118 logger.Error(fmt.Sprintf("unable to read config file %s\n", cfgPath))
119 os.Exit(1)
120 }
121 c.Pref = prefs
122
123 return c
124}
125
126// DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
127type DebugBuffer struct {
128 TextView *tview.TextView
129}
130
131func (db *DebugBuffer) Write(p []byte) (int, error) {
132 return db.TextView.Write(p)
133}
134
135// Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
136func (db *DebugBuffer) Sync() error {
137 return nil
138}
139
140func randomBanner() string {
141 rand.Seed(time.Now().UnixNano())
142
143 bannerFiles, _ := bannerDir.ReadDir("banners")
144 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
145
146 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
147}
148
149type ClientTransaction struct {
150 Name string
151 Handler func(*Client, *Transaction) ([]Transaction, error)
152}
153
154func (ch ClientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
155 return ch.Handler(cc, t)
156}
157
158type ClientTHandler interface {
159 Handle(*Client, *Transaction) ([]Transaction, error)
160}
161
162var clientHandlers = map[uint16]ClientHandler{
163 TranChatMsg: handleClientChatMsg,
164 TranLogin: handleClientTranLogin,
165 TranShowAgreement: handleClientTranShowAgreement,
166 TranUserAccess: handleClientTranUserAccess,
167 TranGetUserNameList: handleClientGetUserNameList,
168 TranNotifyChangeUser: handleNotifyChangeUser,
169 TranNotifyDeleteUser: handleNotifyDeleteUser,
170 TranGetMsgs: handleGetMsgs,
171 TranGetFileNameList: handleGetFileNameList,
172 TranServerMsg: handleTranServerMsg,
173 TranKeepAlive: func(ctx context.Context, client *Client, transaction *Transaction) (t []Transaction, err error) {
174 return t, err
175 },
176}
177
178func handleTranServerMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
179 now := time.Now().Format(time.RFC850)
180
181 msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n")
182 msg += "\n\nAt " + now
183 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(FieldUserName).Data)
184
185 msgBox := tview.NewTextView().SetScrollable(true)
186 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
187 msgBox.SetTitle(title).SetBorder(true)
188 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
189 switch event.Key() {
190 case tcell.KeyEscape:
191 c.UI.Pages.RemovePage("serverMsgModal" + now)
192 }
193 return event
194 })
195
196 centeredFlex := tview.NewFlex().
197 AddItem(nil, 0, 1, false).
198 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
199 AddItem(nil, 0, 1, false).
200 AddItem(msgBox, 0, 2, true).
201 AddItem(nil, 0, 1, false), 0, 2, true).
202 AddItem(nil, 0, 1, false)
203
204 c.UI.Pages.AddPage("serverMsgModal"+now, centeredFlex, true, true)
205 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
206
207 return res, err
208}
209
210func (c *Client) showErrMsg(msg string) {
211 t := time.Now().Format(time.RFC850)
212
213 title := "| Error |"
214
215 msgBox := tview.NewTextView().SetScrollable(true)
216 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
217 msgBox.SetTitle(title).SetBorder(true)
218 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
219 switch event.Key() {
220 case tcell.KeyEscape:
221 c.UI.Pages.RemovePage("serverMsgModal" + t)
222 }
223 return event
224 })
225
226 centeredFlex := tview.NewFlex().
227 AddItem(nil, 0, 1, false).
228 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
229 AddItem(nil, 0, 1, false).
230 AddItem(msgBox, 0, 2, true).
231 AddItem(nil, 0, 1, false), 0, 2, true).
232 AddItem(nil, 0, 1, false)
233
234 c.UI.Pages.AddPage("serverMsgModal"+t, centeredFlex, true, true)
235 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
236}
237
238func handleGetFileNameList(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
239 if t.IsError() {
240 c.showErrMsg(string(t.GetField(FieldError).Data))
241 return res, err
242 }
243
244 fTree := tview.NewTreeView().SetTopLevel(1)
245 root := tview.NewTreeNode("Root")
246 fTree.SetRoot(root).SetCurrentNode(root)
247 fTree.SetBorder(true).SetTitle("| Files |")
248 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
249 switch event.Key() {
250 case tcell.KeyEscape:
251 c.UI.Pages.RemovePage("files")
252 c.filePath = []string{}
253 case tcell.KeyEnter:
254 selectedNode := fTree.GetCurrentNode()
255
256 if selectedNode.GetText() == "<- Back" {
257 c.filePath = c.filePath[:len(c.filePath)-1]
258 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
259
260 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
261 c.UI.HLClient.Logger.Error("err", "err", err)
262 }
263 return event
264 }
265
266 entry := selectedNode.GetReference().(*FileNameWithInfo)
267
268 if bytes.Equal(entry.Type[:], []byte("fldr")) {
269 c.Logger.Info("get new directory listing", "name", string(entry.name))
270
271 c.filePath = append(c.filePath, string(entry.name))
272 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
273
274 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
275 c.UI.HLClient.Logger.Error("err", "err", err)
276 }
277 } else {
278 // TODO: initiate file download
279 c.Logger.Info("download file", "name", string(entry.name))
280 }
281 }
282
283 return event
284 })
285
286 if len(c.filePath) > 0 {
287 node := tview.NewTreeNode("<- Back")
288 root.AddChild(node)
289 }
290
291 for _, f := range t.Fields {
292 var fn FileNameWithInfo
293 _, err = fn.Write(f.Data)
294 if err != nil {
295 return nil, nil
296 }
297
298 if bytes.Equal(fn.Type[:], []byte("fldr")) {
299 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
300 node.SetReference(&fn)
301 root.AddChild(node)
302 } else {
303 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
304
305 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
306 node.SetReference(&fn)
307 root.AddChild(node)
308 }
309 }
310
311 centerFlex := tview.NewFlex().
312 AddItem(nil, 0, 1, false).
313 AddItem(tview.NewFlex().
314 SetDirection(tview.FlexRow).
315 AddItem(nil, 0, 1, false).
316 AddItem(fTree, 20, 1, true).
317 AddItem(nil, 0, 1, false), 60, 1, true).
318 AddItem(nil, 0, 1, false)
319
320 c.UI.Pages.AddPage("files", centerFlex, true, true)
321 c.UI.App.Draw()
322
323 return res, err
324}
325
326func handleGetMsgs(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
327 newsText := string(t.GetField(FieldData).Data)
328 newsText = strings.ReplaceAll(newsText, "\r", "\n")
329
330 newsTextView := tview.NewTextView().
331 SetText(newsText).
332 SetDoneFunc(func(key tcell.Key) {
333 c.UI.Pages.SwitchToPage(serverUIPage)
334 c.UI.App.SetFocus(c.UI.chatInput)
335 })
336 newsTextView.SetBorder(true).SetTitle("News")
337
338 c.UI.Pages.AddPage("news", newsTextView, true, true)
339 // c.UI.Pages.SwitchToPage("news")
340 // c.UI.App.SetFocus(newsTextView)
341 c.UI.App.Draw()
342
343 return res, err
344}
345
346func handleNotifyChangeUser(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
347 newUser := User{
348 ID: t.GetField(FieldUserID).Data,
349 Name: string(t.GetField(FieldUserName).Data),
350 Icon: t.GetField(FieldUserIconID).Data,
351 Flags: t.GetField(FieldUserFlags).Data,
352 }
353
354 // Possible cases:
355 // user is new to the server
356 // user is already on the server but has a new name
357
358 var oldName string
359 var newUserList []User
360 updatedUser := false
361 for _, u := range c.UserList {
362 if bytes.Equal(newUser.ID, u.ID) {
363 oldName = u.Name
364 u.Name = newUser.Name
365 if u.Name != newUser.Name {
366 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
367 }
368 updatedUser = true
369 }
370 newUserList = append(newUserList, u)
371 }
372
373 if !updatedUser {
374 newUserList = append(newUserList, newUser)
375 }
376
377 c.UserList = newUserList
378
379 c.renderUserList()
380
381 return res, err
382}
383
384func handleNotifyDeleteUser(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
385 exitUser := t.GetField(FieldUserID).Data
386
387 var newUserList []User
388 for _, u := range c.UserList {
389 if !bytes.Equal(exitUser, u.ID) {
390 newUserList = append(newUserList, u)
391 }
392 }
393
394 c.UserList = newUserList
395
396 c.renderUserList()
397
398 return res, err
399}
400
401func handleClientGetUserNameList(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
402 var users []User
403 for _, field := range t.Fields {
404 // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300)
405 // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected
406 // field type. Probably a good idea to do everywhere.
407 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
408 var user User
409 if _, err := user.Write(field.Data); err != nil {
410 return res, fmt.Errorf("unable to read user data: %w", err)
411 }
412
413 users = append(users, user)
414 }
415 }
416 c.UserList = users
417
418 c.renderUserList()
419
420 return res, err
421}
422
423func (c *Client) renderUserList() {
424 c.UI.userList.Clear()
425 for _, u := range c.UserList {
426 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
427 if flagBitmap.Bit(UserFlagAdmin) == 1 {
428 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
429 } else {
430 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
431 }
432 // TODO: fade if user is away
433 }
434}
435
436func handleClientChatMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
437 if c.Pref.EnableBell {
438 fmt.Println("\a")
439 }
440
441 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
442
443 return res, err
444}
445
446func handleClientTranUserAccess(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
447 c.UserAccess = t.GetField(FieldUserAccess).Data
448
449 return res, err
450}
451
452func handleClientTranShowAgreement(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
453 agreement := string(t.GetField(FieldData).Data)
454 agreement = strings.ReplaceAll(agreement, "\r", "\n")
455
456 agreeModal := tview.NewModal().
457 SetText(agreement).
458 AddButtons([]string{"Agree", "Disagree"}).
459 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
460 if buttonIndex == 0 {
461 res = append(res,
462 *NewTransaction(
463 TranAgreed, nil,
464 NewField(FieldUserName, []byte(c.Pref.Username)),
465 NewField(FieldUserIconID, c.Pref.IconBytes()),
466 NewField(FieldUserFlags, []byte{0x00, 0x00}),
467 NewField(FieldOptions, []byte{0x00, 0x00}),
468 ),
469 )
470 c.UI.Pages.HidePage("agreement")
471 c.UI.App.SetFocus(c.UI.chatInput)
472 } else {
473 _ = c.Disconnect()
474 c.UI.Pages.SwitchToPage("home")
475 }
476 },
477 )
478
479 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
480
481 return res, err
482}
483
484func handleClientTranLogin(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
485 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
486 errMsg := string(t.GetField(FieldError).Data)
487 errModal := tview.NewModal()
488 errModal.SetText(errMsg)
489 errModal.AddButtons([]string{"Oh no"})
490 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
491 c.UI.Pages.RemovePage("errModal")
492 })
493 c.UI.Pages.RemovePage("joinServer")
494 c.UI.Pages.AddPage("errModal", errModal, false, true)
495
496 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
497
498 c.Logger.Error(string(t.GetField(FieldError).Data))
499 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
500 }
501 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
502 c.UI.App.SetFocus(c.UI.chatInput)
503
504 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
505 c.Logger.Error("err", "err", err)
506 }
507 return res, err
508}
509
510// JoinServer connects to a Hotline server and completes the login flow
511func (c *Client) Connect(address, login, passwd string) (err error) {
512 // Establish TCP connection to server
513 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
514 if err != nil {
515 return err
516 }
517
518 // Send handshake sequence
519 if err := c.Handshake(); err != nil {
520 return err
521 }
522
523 // Authenticate (send TranLogin 107)
524 if err := c.LogIn(login, passwd); err != nil {
525 return err
526 }
527
528 // start keepalive go routine
529 go func() { _ = c.keepalive() }()
530
531 return nil
532}
533
534const keepaliveInterval = 300 * time.Second
535
536func (c *Client) keepalive() error {
537 for {
538 time.Sleep(keepaliveInterval)
539 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
540 }
541}
542
543var ClientHandshake = []byte{
544 0x54, 0x52, 0x54, 0x50, // TRTP
545 0x48, 0x4f, 0x54, 0x4c, // HOTL
546 0x00, 0x01,
547 0x00, 0x02,
548}
549
550var ServerHandshake = []byte{
551 0x54, 0x52, 0x54, 0x50, // TRTP
552 0x00, 0x00, 0x00, 0x00, // ErrorCode
553}
554
555func (c *Client) Handshake() error {
556 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
557 // Sub-protocol ID 4 User defined
558 // Version 2 1 Currently 1
559 // Sub-version 2 User defined
560 if _, err := c.Connection.Write(ClientHandshake); err != nil {
561 return fmt.Errorf("handshake write err: %s", err)
562 }
563
564 replyBuf := make([]byte, 8)
565 _, err := c.Connection.Read(replyBuf)
566 if err != nil {
567 return err
568 }
569
570 if bytes.Equal(replyBuf, ServerHandshake) {
571 return nil
572 }
573
574 // In the case of an error, client and server close the connection.
575 return fmt.Errorf("handshake response err: %s", err)
576}
577
578func (c *Client) LogIn(login string, password string) error {
579 return c.Send(
580 *NewTransaction(
581 TranLogin, nil,
582 NewField(FieldUserName, []byte(c.Pref.Username)),
583 NewField(FieldUserIconID, c.Pref.IconBytes()),
584 NewField(FieldUserLogin, encodeString([]byte(login))),
585 NewField(FieldUserPassword, encodeString([]byte(password))),
586 ),
587 )
588}
589
590func (c *Client) Send(t Transaction) error {
591 requestNum := binary.BigEndian.Uint16(t.Type)
592
593 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
594 if t.IsReply == 0 {
595 c.activeTasks[binary.BigEndian.Uint32(t.ID)] = &t
596 }
597
598 b, err := t.MarshalBinary()
599 if err != nil {
600 return err
601 }
602
603 var n int
604 if n, err = c.Connection.Write(b); err != nil {
605 return err
606 }
607 c.Logger.Debug("Sent Transaction",
608 "IsReply", t.IsReply,
609 "type", requestNum,
610 "sentBytes", n,
611 )
612 return nil
613}
614
615func (c *Client) HandleTransaction(ctx context.Context, t *Transaction) error {
616 var origT Transaction
617 if t.IsReply == 1 {
618 requestID := binary.BigEndian.Uint32(t.ID)
619 origT = *c.activeTasks[requestID]
620 t.Type = origT.Type
621 }
622
623 if handler, ok := c.Handlers[binary.BigEndian.Uint16(t.Type)]; ok {
624 c.Logger.Debug(
625 "Received transaction",
626 "IsReply", t.IsReply,
627 "type", binary.BigEndian.Uint16(t.Type),
628 )
629 outT, err := handler(ctx, c, t)
630 if err != nil {
631 c.Logger.Error("error handling transaction", "err", err)
632 }
633 for _, t := range outT {
634 if err := c.Send(t); err != nil {
635 return err
636 }
637 }
638 } else {
639 c.Logger.Debug(
640 "Unimplemented transaction type",
641 "IsReply", t.IsReply,
642 "type", binary.BigEndian.Uint16(t.Type),
643 )
644 }
645
646 return nil
647}
648
649func (c *Client) Disconnect() error {
650 return c.Connection.Close()
651}
652
653func (c *Client) HandleTransactions(ctx context.Context) error {
654 // Create a new scanner for parsing incoming bytes into transaction tokens
655 scanner := bufio.NewScanner(c.Connection)
656 scanner.Split(transactionScanner)
657
658 // Scan for new transactions and handle them as they come in.
659 for scanner.Scan() {
660 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
661 // scanner re-uses the buffer for subsequent scans.
662 buf := make([]byte, len(scanner.Bytes()))
663 copy(buf, scanner.Bytes())
664
665 var t Transaction
666 _, err := t.Write(buf)
667 if err != nil {
668 break
669 }
670
671 if err := c.HandleTransaction(ctx, &t); err != nil {
672 c.Logger.Error("Error handling transaction", "err", err)
673 }
674 }
675
676 if scanner.Err() == nil {
677 return scanner.Err()
678 }
679 return nil
680}