9 "github.com/gdamore/tcell/v2"
10 "github.com/rivo/tview"
11 "github.com/stretchr/testify/mock"
23 trackerListPage = "trackerList"
26 //go:embed banners/*.txt
27 var bannerDir embed.FS
29 type Bookmark struct {
30 Name string `yaml:"Name"`
31 Addr string `yaml:"Addr"`
32 Login string `yaml:"Login"`
33 Password string `yaml:"Password"`
36 type ClientPrefs struct {
37 Username string `yaml:"Username"`
38 IconID int `yaml:"IconID"`
39 Bookmarks []Bookmark `yaml:"Bookmarks"`
40 Tracker string `yaml:"Tracker"`
43 func (cp *ClientPrefs) IconBytes() []byte {
44 iconBytes := make([]byte, 2)
45 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
49 func readConfig(cfgPath string) (*ClientPrefs, error) {
50 fh, err := os.Open(cfgPath)
55 prefs := ClientPrefs{}
56 decoder := yaml.NewDecoder(fh)
57 decoder.SetStrict(true)
58 if err := decoder.Decode(&prefs); err != nil {
76 Logger *zap.SugaredLogger
77 activeTasks map[uint32]*Transaction
81 Handlers map[uint16]clientTHandler
85 outbox chan *Transaction
86 Inbox chan *Transaction
89 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
93 activeTasks: make(map[uint32]*Transaction),
94 Handlers: clientHandlers,
98 prefs, err := readConfig(cfgPath)
100 fmt.Printf("unable to read config file")
101 logger.Fatal("unable to read config file", "path", cfgPath)
109 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
110 type DebugBuffer struct {
111 TextView *tview.TextView
114 func (db *DebugBuffer) Write(p []byte) (int, error) {
115 return db.TextView.Write(p)
118 // Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
119 func (db *DebugBuffer) Sync() error {
123 func randomBanner() string {
124 rand.Seed(time.Now().UnixNano())
126 bannerFiles, _ := bannerDir.ReadDir("banners")
127 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
129 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
136 type clientTransaction struct {
138 Handler func(*Client, *Transaction) ([]Transaction, error)
141 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
142 return ch.Handler(cc, t)
145 type clientTHandler interface {
146 Handle(*Client, *Transaction) ([]Transaction, error)
149 type mockClientHandler struct {
153 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
154 args := mh.Called(cc, t)
155 return args.Get(0).([]Transaction), args.Error(1)
158 var clientHandlers = map[uint16]clientTHandler{
160 tranChatMsg: clientTransaction{
162 Handler: handleClientChatMsg,
164 tranLogin: clientTransaction{
166 Handler: handleClientTranLogin,
168 tranShowAgreement: clientTransaction{
169 Name: "tranShowAgreement",
170 Handler: handleClientTranShowAgreement,
172 tranUserAccess: clientTransaction{
173 Name: "tranUserAccess",
174 Handler: handleClientTranUserAccess,
176 tranGetUserNameList: clientTransaction{
177 Name: "tranGetUserNameList",
178 Handler: handleClientGetUserNameList,
180 tranNotifyChangeUser: clientTransaction{
181 Name: "tranNotifyChangeUser",
182 Handler: handleNotifyChangeUser,
184 tranNotifyDeleteUser: clientTransaction{
185 Name: "tranNotifyDeleteUser",
186 Handler: handleNotifyDeleteUser,
188 tranGetMsgs: clientTransaction{
189 Name: "tranNotifyDeleteUser",
190 Handler: handleGetMsgs,
194 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
195 newsText := string(t.GetField(fieldData).Data)
196 newsText = strings.ReplaceAll(newsText, "\r", "\n")
198 newsTextView := tview.NewTextView().
200 SetDoneFunc(func(key tcell.Key) {
201 c.UI.Pages.SwitchToPage("serverUI")
202 c.UI.App.SetFocus(c.UI.chatInput)
204 newsTextView.SetBorder(true).SetTitle("News")
206 c.UI.Pages.AddPage("news", newsTextView, true, true)
207 c.UI.Pages.SwitchToPage("news")
208 c.UI.App.SetFocus(newsTextView)
214 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
216 ID: t.GetField(fieldUserID).Data,
217 Name: string(t.GetField(fieldUserName).Data),
218 Icon: t.GetField(fieldUserIconID).Data,
219 Flags: t.GetField(fieldUserFlags).Data,
223 // user is new to the server
224 // user is already on the server but has a new name
227 var newUserList []User
229 for _, u := range c.UserList {
230 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
231 if bytes.Equal(newUser.ID, u.ID) {
233 u.Name = newUser.Name
234 if u.Name != newUser.Name {
235 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
239 newUserList = append(newUserList, u)
243 newUserList = append(newUserList, newUser)
246 c.UserList = newUserList
253 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
254 exitUser := t.GetField(fieldUserID).Data
256 var newUserList []User
257 for _, u := range c.UserList {
258 if !bytes.Equal(exitUser, u.ID) {
259 newUserList = append(newUserList, u)
263 c.UserList = newUserList
270 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
272 func (c *Client) ReadLoop() error {
273 tranBuff := make([]byte, 0)
275 // Infinite loop where take action on incoming client requests until the connection is closed
277 buf := make([]byte, readBuffSize)
278 tranBuff = tranBuff[tReadlen:]
280 readLen, err := c.Connection.Read(buf)
284 tranBuff = append(tranBuff, buf[:readLen]...)
286 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
287 // into a slice of transactions
288 var transactions []Transaction
289 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
290 c.Logger.Errorw("Error handling transaction", "err", err)
293 // iterate over all of the transactions that were parsed from the byte slice and handle them
294 for _, t := range transactions {
295 if err := c.HandleTransaction(&t); err != nil {
296 c.Logger.Errorw("Error handling transaction", "err", err)
302 func (c *Client) GetTransactions() error {
303 tranBuff := make([]byte, 0)
306 buf := make([]byte, readBuffSize)
307 tranBuff = tranBuff[tReadlen:]
309 readLen, err := c.Connection.Read(buf)
313 tranBuff = append(tranBuff, buf[:readLen]...)
318 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
320 for _, field := range t.Fields {
321 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
322 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
323 // field type. Probably a good idea to do everywhere.
324 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
325 u, err := ReadUser(field.Data)
329 users = append(users, *u)
339 func (c *Client) renderUserList() {
340 c.UI.userList.Clear()
341 for _, u := range c.UserList {
342 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
343 if flagBitmap.Bit(userFlagAdmin) == 1 {
344 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
346 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
348 // TODO: fade if user is away
352 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
353 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
358 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
359 c.UserAccess = t.GetField(fieldUserAccess).Data
364 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
365 agreement := string(t.GetField(fieldData).Data)
366 agreement = strings.ReplaceAll(agreement, "\r", "\n")
368 c.UI.agreeModal = tview.NewModal().
370 AddButtons([]string{"Agree", "Disagree"}).
371 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
372 if buttonIndex == 0 {
376 NewField(fieldUserName, []byte(c.pref.Username)),
377 NewField(fieldUserIconID, c.pref.IconBytes()),
378 NewField(fieldUserFlags, []byte{0x00, 0x00}),
379 NewField(fieldOptions, []byte{0x00, 0x00}),
383 c.UI.Pages.HidePage("agreement")
384 c.UI.App.SetFocus(c.UI.chatInput)
387 c.UI.Pages.SwitchToPage("home")
392 c.Logger.Debug("show agreement page")
393 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
394 c.UI.Pages.ShowPage("agreement ")
400 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
401 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
402 errMsg := string(t.GetField(fieldError).Data)
403 errModal := tview.NewModal()
404 errModal.SetText(errMsg)
405 errModal.AddButtons([]string{"Oh no"})
406 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
407 c.UI.Pages.RemovePage("errModal")
409 c.UI.Pages.RemovePage("joinServer")
410 c.UI.Pages.AddPage("errModal", errModal, false, true)
412 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
414 c.Logger.Error(string(t.GetField(fieldError).Data))
415 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
417 c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true)
418 c.UI.App.SetFocus(c.UI.chatInput)
420 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
421 c.Logger.Errorw("err", "err", err)
426 // JoinServer connects to a Hotline server and completes the login flow
427 func (c *Client) JoinServer(address, login, passwd string) error {
428 // Establish TCP connection to server
429 if err := c.connect(address); err != nil {
433 // Send handshake sequence
434 if err := c.Handshake(); err != nil {
438 // Authenticate (send tranLogin 107)
439 if err := c.LogIn(login, passwd); err != nil {
446 // connect establishes a connection with a Server by sending handshake sequence
447 func (c *Client) connect(address string) error {
449 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
456 var ClientHandshake = []byte{
457 0x54, 0x52, 0x54, 0x50, // TRTP
458 0x48, 0x4f, 0x54, 0x4c, // HOTL
463 var ServerHandshake = []byte{
464 0x54, 0x52, 0x54, 0x50, // TRTP
465 0x00, 0x00, 0x00, 0x00, // ErrorCode
468 func (c *Client) Handshake() error {
469 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
470 //Sub-protocol ID 4 User defined
471 //Version 2 1 Currently 1
472 //Sub-version 2 User defined
473 if _, err := c.Connection.Write(ClientHandshake); err != nil {
474 return fmt.Errorf("handshake write err: %s", err)
477 replyBuf := make([]byte, 8)
478 _, err := c.Connection.Read(replyBuf)
483 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
487 // In the case of an error, client and server close the connection.
488 return fmt.Errorf("handshake response err: %s", err)
491 func (c *Client) LogIn(login string, password string) error {
495 NewField(fieldUserName, []byte(c.pref.Username)),
496 NewField(fieldUserIconID, c.pref.IconBytes()),
497 NewField(fieldUserLogin, []byte(NegatedUserString([]byte(login)))),
498 NewField(fieldUserPassword, []byte(NegatedUserString([]byte(password)))),
499 NewField(fieldVersion, []byte{0, 2}),
504 func (c *Client) Send(t Transaction) error {
505 requestNum := binary.BigEndian.Uint16(t.Type)
506 tID := binary.BigEndian.Uint32(t.ID)
508 //handler := TransactionHandlers[requestNum]
510 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
512 c.activeTasks[tID] = &t
517 if n, err = c.Connection.Write(t.Payload()); err != nil {
520 c.Logger.Debugw("Sent Transaction",
521 "IsReply", t.IsReply,
528 func (c *Client) HandleTransaction(t *Transaction) error {
529 var origT Transaction
531 requestID := binary.BigEndian.Uint32(t.ID)
532 origT = *c.activeTasks[requestID]
536 requestNum := binary.BigEndian.Uint16(t.Type)
538 "Received Transaction",
539 "RequestType", requestNum,
542 if handler, ok := c.Handlers[requestNum]; ok {
543 outT, _ := handler.Handle(c, t)
544 for _, t := range outT {
549 "Unimplemented transaction type received",
550 "RequestID", requestNum,
551 "TransactionID", t.ID,
558 func (c *Client) Connected() bool {
559 fmt.Printf("Agreed: %v UserAccess: %v\n", c.Agreed, c.UserAccess)
560 // c.Agreed == true &&
561 if c.UserAccess != nil {
567 func (c *Client) Disconnect() error {
568 err := c.Connection.Close()