package session import ( "fmt" "time" ) // Input error sentinels. These replace the TIMEOUT and NO_CARRIER // return codes that the original TAG-BBS passed through every function. // In Go, we use the context for cancellation and return these as errors // only when the caller needs to distinguish the reason. var ( ErrTimeout = fmt.Errorf("input timeout") ErrDisconnected = fmt.Errorf("disconnected") ) // ReadChar reads a single byte from the client with a timeout. // This is the direct replacement for ReadChar() in CONSOLE.C, which // was the central input function that Wait()'d on serial, console, // and timer signals simultaneously. // // In Go, the equivalent is a select on the input channel (fed by the // readLoop goroutine), a timer, and the context's Done channel. // // A timeout of 0 means no timeout (wait forever, or until disconnect). func (s *Session) ReadChar(timeout time.Duration) (byte, error) { // Check context first — if the session is already cancelled, // don't block on input. select { case <-s.ctx.Done(): return 0, ErrDisconnected default: } if timeout == 0 { // No timeout — block until input or disconnect select { case b, ok := <-s.inputCh: if !ok { return 0, ErrDisconnected } return b, nil case <-s.ctx.Done(): return 0, ErrDisconnected } } // With timeout — the original used SetTimer() and waited on // TimerSig alongside the input signals. Same concept here. timer := time.NewTimer(timeout) defer timer.Stop() select { case b, ok := <-s.inputCh: if !ok { return 0, ErrDisconnected } return b, nil case <-timer.C: return 0, ErrTimeout case <-s.ctx.Done(): return 0, ErrDisconnected } } // ReadLine reads a full line of input (terminated by Enter) with echo // and basic line editing. Returns the entered string, or an error on // timeout/disconnect. // // This replaces LineInput() from the original, which handled echo, // backspace, and had a pre-fill capability (the first argument was // a default string displayed in the input field). We keep the same // semantics. // // maxLen limits the input length (like the original's fixed char arrays). // A value of 0 means no limit. // timeout is the idle timeout — resets on each keypress. func (s *Session) ReadLine(prefill string, maxLen int, timeout time.Duration) (string, error) { buf := []byte(prefill) // Display the prefill text if any if len(prefill) > 0 { if err := s.WriteString(prefill); err != nil { return "", err } } for { ch, err := s.ReadChar(timeout) if err != nil { return string(buf), err } switch { case ch == '\r' || ch == '\n': // Enter — return the line s.WriteString("\r\n") return string(buf), nil case ch == 127 || ch == 8: // Backspace or DEL — erase last character // Same as the original: PutStr("\b \b") if len(buf) > 0 { buf = buf[:len(buf)-1] s.WriteString("\b \b") } case ch == 24: // Ctrl-X — erase entire line // Same as the original editor's line-kill for len(buf) > 0 { buf = buf[:len(buf)-1] s.WriteString("\b \b") } case ch == 23: // Ctrl-W — erase last word // Delete trailing spaces first, then back to previous space. // Same logic as the original EDIT.C's Ctrl-W handler. for len(buf) > 0 && buf[len(buf)-1] == ' ' { buf = buf[:len(buf)-1] s.WriteString("\b \b") } for len(buf) > 0 && buf[len(buf)-1] != ' ' { buf = buf[:len(buf)-1] s.WriteString("\b \b") } case ch >= 32 && ch < 127: // Printable character if maxLen > 0 && len(buf) >= maxLen { continue // At limit, ignore } buf = append(buf, ch) s.Write([]byte{ch}) default: // Control characters — ignore continue } } } // ReadLineNoEcho reads a line of input without echoing characters. // Used for password entry. Displays asterisks instead of the actual // characters. func (s *Session) ReadLineNoEcho(maxLen int, timeout time.Duration) (string, error) { var buf []byte for { ch, err := s.ReadChar(timeout) if err != nil { return string(buf), err } switch { case ch == '\r' || ch == '\n': s.WriteString("\r\n") return string(buf), nil case ch == 127 || ch == 8: if len(buf) > 0 { buf = buf[:len(buf)-1] s.WriteString("\b \b") } case ch >= 32 && ch < 127: if maxLen > 0 && len(buf) >= maxLen { continue } buf = append(buf, ch) s.Write([]byte{'*'}) default: continue } } } // ReadKey reads a single keypress and returns it immediately without // waiting for Enter. Used for single-character menu selections. // This is the classic BBS "press a key" input — the original's // ReadChar(120L) in the menu dispatch loops. func (s *Session) ReadKey(timeout time.Duration) (byte, error) { for { ch, err := s.ReadChar(timeout) if err != nil { return 0, err } // Skip null bytes (can come from function key sequences) if ch == 0 { continue } return ch, nil } } // WaitForKey displays a prompt and waits for any keypress. // The classic "Press any key to continue..." pattern. func (s *Session) WaitForKey(prompt string, timeout time.Duration) error { if prompt != "" { if err := s.WriteString(prompt); err != nil { return err } } _, err := s.ReadKey(timeout) return err } // Confirm asks a yes/no question and returns the result. // Keeps prompting until Y or N is pressed. func (s *Session) Confirm(prompt string, timeout time.Duration) (bool, error) { s.WriteString(prompt) for { ch, err := s.ReadKey(timeout) if err != nil { return false, err } switch ch { case 'y', 'Y': s.WriteString("Yes\r\n") return true, nil case 'n', 'N', '\r', '\n': s.WriteString("No\r\n") return false, nil } } }