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 }