urit/internal/menu/chat.go
2026-05-02 21:11:50 -04:00

290 lines
7.6 KiB
Go

package menu
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/urit/urit/internal/session"
)
// cmdChat is the main entry point for inter-node communication.
//
// The original TAG-BBS was single-node (Amiga serial), so there was
// no inter-node chat. Multi-node BBSes of the era (TBBS, Major BBS,
// Wildcat) typically offered "page" (one-shot notification) and
// "chat" (real-time line-by-line messaging between two nodes).
//
// Our implementation provides both:
// [P] Page — send a one-line message to another node's screen
// [C] Chat — enter real-time chat mode with another node
// [B] Broadcast — sysop sends a message to all nodes (sysop only)
func cmdChat(ctx *Context) error {
if ctx.Chat == nil || ctx.Nodes == nil {
ctx.Sess.WriteString(" Chat is not available.\r\n")
return nil
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Inter-Node Chat\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
// Show who's online
nodes := ctx.Nodes.ActiveNodes()
otherCount := 0
for _, n := range nodes {
if n.Node == ctx.Sess.Node {
continue
}
name := n.UserName
if name == "" {
name = "(connecting)"
}
ctx.Sess.Printf(" Node %-3d %s\r\n", n.Node, name)
otherCount++
}
if otherCount == 0 {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" No other users online.\r\n")
ctx.Sess.Color(session.AnsiReset)
return nil
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" [P]age [C]hat [B]roadcast [Q]uit\r\n")
ctx.Sess.Color(session.AnsiReset)
key, err := ctx.Sess.ReadKey(30 * time.Second)
if err != nil {
return nil
}
switch key | 0x20 { // lowercase
case 'p':
return chatPage(ctx, nodes)
case 'c':
return chatEnter(ctx, nodes)
case 'b':
if ctx.User.IsSysop() {
return chatBroadcast(ctx)
}
ctx.Sess.WriteString(" Broadcast is sysop-only.\r\n")
}
return nil
}
// chatPage sends a one-shot message to another node's screen.
func chatPage(ctx *Context, nodes []NodeInfo) error {
ctx.Sess.NewLine()
ctx.Sess.WriteString(" Page which node? ")
input, err := ctx.Sess.ReadLine("", 5, 15*time.Second)
if err != nil || input == "" {
return nil
}
targetNode, err := strconv.Atoi(strings.TrimSpace(input))
if err != nil || targetNode == ctx.Sess.Node {
ctx.Sess.WriteString(" Invalid node.\r\n")
return nil
}
// Verify node exists and has a user
var targetName string
for _, n := range nodes {
if n.Node == targetNode && n.UserName != "" {
targetName = n.UserName
break
}
}
if targetName == "" {
ctx.Sess.WriteString(" That node is not available.\r\n")
return nil
}
ctx.Sess.WriteString(" Message: ")
msg, err := ctx.Sess.ReadLine("", 70, 30*time.Second)
if err != nil || msg == "" {
return nil
}
// Format and send the page
page := fmt.Sprintf(
"\r\n\x1b[1;33m>> Page from %s (Node %d): %s\x1b[0m\r\n",
ctx.User.Name, ctx.Sess.Node, msg)
if err := ctx.Nodes.SendToNode(targetNode, page); err != nil {
ctx.Sess.WriteString(" Could not reach that node.\r\n")
return nil
}
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Page sent to %s.\r\n", targetName)
ctx.Sess.Color(session.AnsiReset)
return nil
}
// chatEnter puts the user into real-time chat mode with another node.
//
// The chat is line-by-line: the user types a line, it appears on the
// partner's screen, and vice versa. A background goroutine receives
// incoming messages and injects them into the terminal between the
// user's own input prompts. This is the classic BBS chat style —
// messages interleave naturally rather than using a split screen.
func chatEnter(ctx *Context, nodes []NodeInfo) error {
ctx.Sess.NewLine()
ctx.Sess.WriteString(" Chat with which node? ")
input, err := ctx.Sess.ReadLine("", 5, 15*time.Second)
if err != nil || input == "" {
return nil
}
targetNode, err := strconv.Atoi(strings.TrimSpace(input))
if err != nil || targetNode == ctx.Sess.Node {
ctx.Sess.WriteString(" Invalid node.\r\n")
return nil
}
// Verify node exists
var targetName string
for _, n := range nodes {
if n.Node == targetNode && n.UserName != "" {
targetName = n.UserName
break
}
}
if targetName == "" {
ctx.Sess.WriteString(" That node is not available.\r\n")
return nil
}
// Enter chat mode — get our incoming message channel
incoming := ctx.Chat.EnterChat(ctx.Sess.Node)
// Try to link with the target if they're also in chat mode
linked := ctx.Chat.LinkChat(ctx.Sess.Node, targetNode)
// Send page notification to the target
notification := fmt.Sprintf(
"\r\n\x1b[1;33m>> %s (Node %d) wants to chat! Press [C] to respond.\x1b[0m\r\n",
ctx.User.Name, ctx.Sess.Node)
ctx.Nodes.SendToNode(targetNode, notification)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("── Chat Mode ──\r\n")
ctx.Sess.Color(session.AnsiReset)
if linked {
ctx.Sess.Printf(" Connected with %s. Type /quit to exit.\r\n", targetName)
} else {
ctx.Sess.Printf(" Waiting for %s to join... Type /quit to exit.\r\n", targetName)
}
ctx.Sess.NewLine()
// Background goroutine: receive messages and display them.
// Stops when the channel is closed (EndChat) or context cancelled.
done := make(chan struct{})
go func() {
defer close(done)
for msg := range incoming {
if msg == "" {
// Empty string = partner disconnected
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString("\r\n ** Partner has left chat **\r\n")
ctx.Sess.Color(session.AnsiReset)
return
}
// Display the incoming message
ctx.Sess.WriteString("\r" + msg)
}
}()
// Main loop: read user input and send to partner
for {
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.Printf("%s> ", ctx.User.Name)
ctx.Sess.Color(session.AnsiReset)
line, err := ctx.Sess.ReadLine("", 200, 120*time.Second)
if err != nil {
break
}
if strings.EqualFold(strings.TrimSpace(line), "/quit") {
break
}
if line == "" {
continue
}
// Check if we're linked now (partner may have joined since we started)
if ctx.Chat.ChatPartner(ctx.Sess.Node) == 0 {
// Not linked yet — try again
if ctx.Chat.IsInChat(targetNode) {
ctx.Chat.LinkChat(ctx.Sess.Node, targetNode)
}
}
// Format and send
formatted := fmt.Sprintf("\x1b[1;36m%s>\x1b[0m %s\r\n",
ctx.User.Name, line)
if !ctx.Chat.SendChat(ctx.Sess.Node, formatted) {
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" (not connected — waiting for partner)\r\n")
ctx.Sess.Color(session.AnsiReset)
}
}
// Clean up
ctx.Chat.EndChat(ctx.Sess.Node)
// Wait for receiver goroutine to finish
select {
case <-done:
case <-time.After(time.Second):
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" Chat ended.\r\n")
ctx.Sess.Color(session.AnsiReset)
return nil
}
// chatBroadcast sends a message to all connected nodes (sysop only).
func chatBroadcast(ctx *Context) error {
ctx.Sess.NewLine()
ctx.Sess.WriteString(" Broadcast message: ")
msg, err := ctx.Sess.ReadLine("", 70, 30*time.Second)
if err != nil || msg == "" {
return nil
}
broadcast := fmt.Sprintf(
"\r\n\x1b[1;31m>> SYSOP BROADCAST: %s\x1b[0m\r\n", msg)
nodes := ctx.Nodes.ActiveNodes()
sent := 0
for _, n := range nodes {
if n.Node == ctx.Sess.Node {
continue
}
if err := ctx.Nodes.SendToNode(n.Node, broadcast); err == nil {
sent++
}
}
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Broadcast sent to %d node(s).\r\n", sent)
ctx.Sess.Color(session.AnsiReset)
return nil
}