290 lines
7.6 KiB
Go
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
|
|
}
|