code.pablomurad.com

bbs-hammer

SSH BBS with forums, chat, door games, retro terminals and a weather service, written in Go

feat(community): last callers
author
Pablo Murad
date
commit
b08d1bf9d3b105bb37746ee4ad011e3021f7166c
parent
faf9ce0c6e6a2456be721950bcae24ee10e60b37
changes
12 files +203 −2
@@ -0,0 +1,29 @@+package bbs
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestLastCallers(t *testing.T) {
+	srv := testServer(t, &Config{AllowAnonymous: true})
+	addr := listen(t, srv)
+
+	ana := login(t, addr, "ana", newSigner(t))
+	login(t, addr, "guest", nil)
+
+	ana.send("l")
+	ana.waitFor("LAST CALLERS")
+	ana.waitFor("guest-")
+	ana.waitFor("ssh")
+
+	client := dial(t, addr, newSigner(t))
+	out, code := run(t, client, "lastcallers output=text", "")
+	if code != 0 || !strings.Contains(out, "ana") || !strings.Contains(out, "guest-") {
+		t.Fatalf("lastcallers (exit %d):\n%s", code, out)
+	}
+	// The dispatcher call itself is not a visit.
+	if strings.Count(out, "\n") != 2 {
+		t.Fatalf("want two visits, got:\n%s", out)
+	}
+}
@@ -570,6 +570,31 @@ return &dispatch.Result{Data: online, Lines: lines}, nil 			},
 		},
 		{
+			Name:    "lastcallers",
+			Summary: "who called the BBS last, newest first",
+			Args:    []dispatch.Arg{{Name: "count", Kind: dispatch.KindInt, Min: 1, Max: 50, Help: "how many (default 10)"}},
+			Free:    true,
+			Run: func(_ context.Context, c *dispatch.Call) (*dispatch.Result, error) {
+				n := c.Int("count")
+				if n == 0 {
+					n = 10
+				}
+				callers, err := s.cstore.LastCallers(n)
+				if err != nil {
+					return nil, err
+				}
+				lines := make([]string, 0, len(callers))
+				for _, cl := range callers {
+					lines = append(lines, fmt.Sprintf("%4s  %-16s  %s", ago(time.Since(cl.At)), cl.Nick, cl.Via))
+				}
+				if callers == nil {
+					callers = []community.Caller{}
+				}
+
+				return &dispatch.Result{Data: callers, Lines: lines}, nil
+			},
+		},
+		{
 			Name:    "wall",
 			Summary: "the latest one-liners on the wall",
 			Args:    []dispatch.Arg{{Name: "count", Kind: dispatch.KindInt, Min: 1, Max: 50, Help: "how many (default 10)"}},
@@ -246,4 +246,8 @@ "BBS_THEME_RETRO90":    "retro 90s", 	"BBS_THEME_MODERN":     "retro-modern",
 	"BBS_THEME_PLAIN":      "plain",
 	"BBS_FX":               "modem effect",
+	"BBS_CALLERS":          "last callers",
+	"BBS_CALLERS_SUM":      "who came by",
+	"BBS_TITLE_CALLERS":    "LAST CALLERS",
+	"BBS_CALLERS_EMPTY":    "(nobody yet)",
 }
@@ -58,6 +58,7 @@ } 	lines = append(lines,
 		st.menuEntry("w", st.t("BBS_WALL"), st.t("BBS_WALL_SUM")),
 		st.menuEntry("c", st.t("BBS_CHAT"), st.t("BBS_CHAT_SUM")),
+		st.menuEntry("l", st.t("BBS_CALLERS"), st.t("BBS_CALLERS_SUM")),
 	)
 	if st.gamesOn() {
 		lines = append(lines, st.menuEntry("g", st.t("BBS_GAMES"), st.t("BBS_GAMES_SUM")))
@@ -4,6 +4,7 @@ import ( 	"errors"
 	"fmt"
 	"hash/fnv"
+	"log"
 	"strconv"
 	"strings"
 	"time"
@@ -203,3 +204,29 @@ st.th.Good + st.tf("BBS_ONLINE", st.srv.presence.Count()) + st.th.Reset 	st.screen(st.th.Box(title, rows, 48))
 	st.pause("")
 }
+
+// lastCallersScreen lists the latest visits: who, how they came in, when.
+func (st *session) lastCallersScreen() {
+	st.clear()
+
+	rows := []string{""}
+	callers, err := st.srv.cstore.LastCallers(max(st.height0()-10, 5))
+	if err != nil {
+		log.Println("bbs: last callers:", err)
+	}
+	for _, c := range callers {
+		nick := st.colorNick(c.Nick)
+		if c.Anonymous {
+			nick = st.th.Dim + c.Nick + st.th.Reset
+		}
+		rows = append(rows, " "+st.th.Dim+fmt.Sprintf("%5s", ago(time.Since(c.At)))+st.th.Reset+"  "+
+			ui.PadRightANSI(nick, 18)+st.th.Accent+c.Via+st.th.Reset)
+	}
+	if len(callers) == 0 {
+		rows = append(rows, " "+st.th.Dim+st.t("BBS_CALLERS_EMPTY")+st.th.Reset)
+	}
+	rows = append(rows, "")
+
+	st.screen(st.th.Box(st.th.Heading(st.t("BBS_TITLE_CALLERS")), rows, 48))
+	st.pause("")
+}
@@ -58,6 +58,7 @@ communityCfg communitySettings 	presence     *community.Presence
 	wall         *community.Wall
 	chat         *community.Hub
+	cstore       *community.Store // bans, chat log, last callers
 
 	games     *gamesSettings // nil when games are off or the door is missing
 	gameSlots *door.Slots    // nodes, one per person playing
@@ -116,6 +117,7 @@ if err != nil { 		return nil, fmt.Errorf("community store: %w", err)
 	}
 	cc := s.communityCfg
+	s.cstore = cst
 	s.wall = community.NewWall(cst, cc.wallMaxLen, cc.wallCooldown)
 	s.chat = community.NewHub(cst, cc.chatMaxLen, cc.chatPerMinute, cc.chatBurst)
 	s.chat.SetKeep(cc.chatKeep)
@@ -161,6 +161,8 @@ case 'w', 'W': 			st.wallMenu()
 		case 'c', 'C':
 			st.chatRoom()
+		case 'l', 'L':
+			st.lastCallersScreen()
 		case 'g', 'G':
 			if st.gamesOn() {
 				st.gamesMenu()
@@ -73,6 +73,11 @@ } 
 	st.pres = s.presence.Join(identity, profile.Nick, st.anon)
 	defer st.pres.Leave()
+	if s.cstore != nil {
+		if err := s.cstore.LogCall(identity, profile.Nick, info.Via, st.anon); err != nil {
+			log.Println("bbs: last callers:", err)
+		}
+	}
 
 	done := make(chan struct{})
 	defer close(done)
@@ -433,3 +433,37 @@ if len(hist) != 5 || hist[0].Text != "7" || hist[4].Text != "11" { 		t.Fatalf("history = %+v", hist)
 	}
 }
+
+func TestLastCallers(t *testing.T) {
+	st := testStore(t)
+	clock := time.Unix(1_800_000_000, 0)
+	st.now = func() time.Time { return clock }
+
+	if err := st.LogCall("fp:a", "ana", "ssh", false); err != nil {
+		t.Fatal(err)
+	}
+	clock = clock.Add(time.Minute)
+	if err := st.LogCall("anon:1", "guest-beef", "telnet", true); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := st.LastCallers(10)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(got) != 2 || got[0].Nick != "guest-beef" || got[0].Via != "telnet" || !got[0].Anonymous ||
+		got[1].Nick != "ana" || got[1].Anonymous || !got[1].At.Equal(time.Unix(1_800_000_000, 0)) {
+		t.Fatalf("last callers = %+v", got)
+	}
+
+	for i := 0; i < callersKeep+5; i++ {
+		_ = st.LogCall("fp:a", "ana", "ssh", false)
+	}
+	var n int
+	if err := st.db.QueryRow(`select count(*) from callers`).Scan(&n); err != nil {
+		t.Fatal(err)
+	}
+	if n != callersKeep {
+		t.Fatalf("%d calls kept, want %d", n, callersKeep)
+	}
+}
@@ -26,6 +26,14 @@ identity text    not null, 	nick     text    not null,
 	text     text    not null
 );
+create table if not exists callers (
+	id        integer primary key autoincrement,
+	identity  text    not null,
+	nick      text    not null,
+	via       text    not null,
+	anonymous integer not null default 0,
+	at        integer not null
+);
 create table if not exists bans (
 	identity text    not null primary key,
 	nick     text    not null default '',
@@ -135,3 +143,59 @@ } 
 	return out, top, rows.Err()
 }
+
+// callersKeep is how many calls the last-callers log remembers.
+const callersKeep = 500
+
+// Caller is one visit in the last-callers log.
+type Caller struct {
+	Nick      string    `json:"nick"`
+	Via       string    `json:"via"`
+	Anonymous bool      `json:"anonymous,omitempty"`
+	At        time.Time `json:"at"`
+}
+
+// LogCall records a visit: who, how they came in (ssh, telnet, web) and when.
+func (s *Store) LogCall(identity, nick, via string, anonymous bool) error {
+	anon := 0
+	if anonymous {
+		anon = 1
+	}
+	res, err := s.db.Exec(`insert into callers(identity, nick, via, anonymous, at) values(?,?,?,?,?)`,
+		identity, nick, via, anon, s.now().Unix())
+	if err != nil {
+		return err
+	}
+	if id, err := res.LastInsertId(); err == nil && id > callersKeep {
+		_, err = s.db.Exec(`delete from callers where id <= ?`, id-callersKeep)
+
+		return err
+	}
+
+	return nil
+}
+
+// LastCallers returns the newest n visits, newest first.
+func (s *Store) LastCallers(n int) ([]Caller, error) {
+	rows, err := s.db.Query(`select nick, via, anonymous, at from callers order by id desc limit ?`, n)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var out []Caller
+	for rows.Next() {
+		var (
+			c    Caller
+			anon int
+			at   int64
+		)
+		if err := rows.Scan(&c.Nick, &c.Via, &anon, &at); err != nil {
+			return nil, err
+		}
+		c.Anonymous, c.At = anon == 1, time.Unix(at, 0)
+		out = append(out, c)
+	}
+
+	return out, rows.Err()
+}
@@ -240,5 +240,9 @@ "BBS_GAMES_FULL": "All %d game slots are taken — try again soon",   "BBS_GAMES_LATE": "This connection is about to time out — reconnect to play",
   "BBS_GAMES_FAILED": "The games could not start — try again later",
   "BBS_WHERE_GAME": "playing",
-  "BBS_GAMES_EMPTY": "No games on the shelf yet — check back soon"
+  "BBS_GAMES_EMPTY": "No games on the shelf yet — check back soon",
+  "BBS_CALLERS": "last callers",
+  "BBS_CALLERS_SUM": "who came by",
+  "BBS_TITLE_CALLERS": "LAST CALLERS",
+  "BBS_CALLERS_EMPTY": "(nobody yet)"
 }
@@ -240,5 +240,9 @@ "BBS_GAMES_FULL": "As %d vagas de jogo estão ocupadas — tente daqui a pouco",   "BBS_GAMES_LATE": "Esta conexão está perto do limite de tempo — reconecte para jogar",
   "BBS_GAMES_FAILED": "Os jogos não puderam iniciar — tente mais tarde",
   "BBS_WHERE_GAME": "jogando",
-  "BBS_GAMES_EMPTY": "Ainda não há jogos na estante — volte daqui a pouco"
+  "BBS_GAMES_EMPTY": "Ainda não há jogos na estante — volte daqui a pouco",
+  "BBS_CALLERS": "visitantes",
+  "BBS_CALLERS_SUM": "quem passou por aqui",
+  "BBS_TITLE_CALLERS": "ÚLTIMOS VISITANTES",
+  "BBS_CALLERS_EMPTY": "(ninguém ainda)"
 }