code.pablomurad.com

bbs-hammer

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

feat(bbs): forums on the menu and the dispatcher
author
Pablo Murad
date
commit
83e410bb094a8794b13ad57ec71479d0da4cb9ea
parent
756b56f8e88aa8e2c7e1aa173111e71961a0f9f1
changes
13 files +1237 −227
@@ -58,6 +58,8 @@ 	Tools *ToolsConfig `yaml:"tools,omitempty"`
 	// Games runs NES games through the nesdoor door (see games.go).
 	Games *GamesConfig `yaml:"games,omitempty"`
+	// Forums lists the message areas (see forums.go).
+	Forums *ForumsConfig `yaml:"forums,omitempty"`
 
 	// Version is the binary version, injected by main; not a config key.
 	Version string `yaml:"-"`
@@ -162,6 +164,9 @@ } 		}
 	}
 	if err := c.Games.validate(); err != nil {
+		return err
+	}
+	if err := c.Forums.validate(); err != nil {
 		return err
 	}
 	if c.Files != nil && (c.Files.QuotaBytes < 0 || c.Files.MaxFileInput < 0) {
@@ -17,6 +17,7 @@ 	"github.com/runawaydevil/hammer/internal/community"
 	"github.com/runawaydevil/hammer/internal/dispatch"
 	"github.com/runawaydevil/hammer/internal/filearea"
+	"github.com/runawaydevil/hammer/internal/forum"
 	"github.com/runawaydevil/hammer/internal/localization"
 	"github.com/runawaydevil/hammer/internal/tools/agecrypt"
 	"github.com/runawaydevil/hammer/internal/tools/b64"
@@ -567,6 +568,110 @@ online = []community.Online{} 				}
 
 				return &dispatch.Result{Data: online, Lines: lines}, nil
+			},
+		},
+		{
+			Name:    "forum.areas",
+			Summary: "the message areas, with topic and post counts",
+			Free:    true,
+			Run: func(_ context.Context, c *dispatch.Call) (*dispatch.Result, error) {
+				areas, err := s.forums.Areas(forumReader(callerOf(c)))
+				if err != nil {
+					return nil, err
+				}
+				lines := make([]string, 0, len(areas))
+				for _, a := range areas {
+					lines = append(lines, fmt.Sprintf("%-12s  %4d topics  %4d new  %s", a.Tag, a.Topics, a.Unread, a.Name))
+				}
+				if areas == nil {
+					areas = []forum.Area{}
+				}
+
+				return &dispatch.Result{Data: areas, Lines: lines}, nil
+			},
+		},
+		{
+			Name:    "forum.topics",
+			Summary: "the topics in an area, newest activity first",
+			Args: []dispatch.Arg{
+				{Name: "area", Kind: dispatch.KindString, Required: true, Max: 32, Help: "area tag, see forum.areas"},
+				{Name: "page", Kind: dispatch.KindInt, Min: 1, Max: 10000, Help: "page of 20 (default 1)"},
+			},
+			Free: true,
+			Run: func(_ context.Context, c *dispatch.Call) (*dispatch.Result, error) {
+				reader := forumReader(callerOf(c))
+				a, err := s.forums.Area(c.Str("area"), reader)
+				if errors.Is(err, forum.ErrNotFound) {
+					return nil, dispatch.Errf(dispatch.CodeNotFound, "no area %q", c.Str("area"))
+				}
+				if err != nil {
+					return nil, err
+				}
+				page := max(c.Int("page"), 1)
+				topics, total, err := s.forums.Topics(a.ID, reader, (page-1)*20, 20)
+				if err != nil {
+					return nil, err
+				}
+				lines := make([]string, 0, len(topics))
+				for _, t := range topics {
+					lines = append(lines, fmt.Sprintf("%6d  %4s  %-16s  %3d posts  %s", t.ID, ago(time.Since(t.LastPostAt)), t.Nick, t.Posts, t.Title))
+				}
+				if topics == nil {
+					topics = []forum.Topic{}
+				}
+
+				return &dispatch.Result{Data: map[string]any{"area": a.Tag, "page": page, "total": total, "topics": topics}, Lines: lines}, nil
+			},
+		},
+		{
+			Name:    "forum.read",
+			Summary: "the posts in a topic, oldest first",
+			Args: []dispatch.Arg{
+				{Name: "topic", Kind: dispatch.KindInt, Required: true, Min: 1, Help: "topic id, see forum.topics"},
+				{Name: "page", Kind: dispatch.KindInt, Min: 1, Max: 10000, Help: "page of 20 (default 1)"},
+			},
+			Free: true,
+			Run: func(_ context.Context, c *dispatch.Call) (*dispatch.Result, error) {
+				reader := forumReader(callerOf(c))
+				t, err := s.forums.Topic(int64(c.Int("topic")), reader)
+				if errors.Is(err, forum.ErrNotFound) {
+					return nil, dispatch.Errf(dispatch.CodeNotFound, "no topic %d", c.Int("topic"))
+				}
+				if err != nil {
+					return nil, err
+				}
+				page := max(c.Int("page"), 1)
+				posts, total, err := s.forums.Posts(t.ID, (page-1)*20, 20)
+				if err != nil {
+					return nil, err
+				}
+				if len(posts) > 0 {
+					_ = s.forums.MarkRead(reader, t.ID, posts[len(posts)-1].ID)
+				}
+				lines := []string{t.Title, ""}
+				for _, p := range posts {
+					lines = append(lines, fmt.Sprintf("— %s, %s", p.Nick, p.Created.Format("2006-01-02 15:04")))
+					lines = append(lines, strings.Split(p.Body, "\n")...)
+					lines = append(lines, "")
+				}
+				if posts == nil {
+					posts = []forum.Post{}
+				}
+
+				return &dispatch.Result{Data: map[string]any{"topic": t, "page": page, "total": total, "posts": posts}, Lines: lines}, nil
+			},
+		},
+		{
+			Name:    "forum.post",
+			Summary: "start a topic (area + title) or reply (topic); the post is input=, file= or stdin; needs an SSH key",
+			Args: []dispatch.Arg{
+				{Name: "area", Kind: dispatch.KindString, Max: 32, Help: "area tag, to start a topic"},
+				{Name: "title", Kind: dispatch.KindString, Max: 200, Help: "title of the new topic"},
+				{Name: "topic", Kind: dispatch.KindInt, Min: 1, Help: "topic id, to reply"},
+			},
+			Input: true,
+			Run: func(_ context.Context, c *dispatch.Call) (*dispatch.Result, error) {
+				return s.forumPost(c)
 			},
 		},
 		{
@@ -0,0 +1,163 @@+package bbs
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/runawaydevil/hammer/internal/community"
+	"github.com/runawaydevil/hammer/internal/dispatch"
+	"github.com/runawaydevil/hammer/internal/forum"
+)
+
+// ForumsConfig is the optional "bbs.forums" block: the message areas and
+// what a post may hold.
+type ForumsConfig struct {
+	// Areas are created, or renamed, at startup in this order. With none,
+	// a single "general" area exists. Removing an area here hides nothing:
+	// its posts stay in the database.
+	Areas []ForumAreaConfig `yaml:"areas"`
+	// MaxTitle is cells in a topic title (72).
+	MaxTitle int `yaml:"maxTitle"`
+	// MaxBody is bytes in a post (8192).
+	MaxBody int `yaml:"maxBody"`
+	// PostCooldown is the wait between two posts of one key (30s).
+	PostCooldown string `yaml:"postCooldown"`
+}
+
+// ForumAreaConfig is one message area.
+type ForumAreaConfig struct {
+	Tag         string `yaml:"tag"` // short, unique, used by scripts: "general"
+	Name        string `yaml:"name"`
+	Description string `yaml:"description"`
+	ReadOnly    bool   `yaml:"readOnly"` // only moderators post (news, rules)
+}
+
+const defaultPostCooldown = 30 * time.Second
+
+func (f *ForumsConfig) validate() error {
+	if f == nil {
+		return nil
+	}
+	seen := map[string]bool{}
+	for _, a := range f.Areas {
+		tag := strings.ToLower(strings.TrimSpace(a.Tag))
+		if tag == "" || strings.ContainsAny(tag, " \t") {
+			return fmt.Errorf("bbs.forums.areas: %q is not a valid tag (one word)", a.Tag)
+		}
+		if seen[tag] {
+			return fmt.Errorf("bbs.forums.areas: %q is listed twice", a.Tag)
+		}
+		seen[tag] = true
+	}
+	if f.MaxTitle < 0 || f.MaxBody < 0 {
+		return fmt.Errorf("bbs.forums.maxTitle and maxBody must be >= 0")
+	}
+	if f.PostCooldown != "" {
+		if _, err := time.ParseDuration(f.PostCooldown); err != nil {
+			return fmt.Errorf("bbs.forums.postCooldown %q is not a valid duration: %w", f.PostCooldown, err)
+		}
+	}
+
+	return nil
+}
+
+// openForums creates the forum tables and the configured areas.
+func openForums(db *sql.DB, f *ForumsConfig) (*forum.Store, error) {
+	opt := forum.Options{Cooldown: defaultPostCooldown}
+	areas := []ForumAreaConfig{{Tag: "general", Name: "General", Description: "Talk about anything"}}
+	if f != nil {
+		opt.MaxTitle, opt.MaxBody = f.MaxTitle, f.MaxBody
+		if d, err := time.ParseDuration(f.PostCooldown); err == nil {
+			opt.Cooldown = d
+		}
+		if len(f.Areas) > 0 {
+			areas = f.Areas
+		}
+	}
+
+	st, err := forum.NewStore(db, opt)
+	if err != nil {
+		return nil, err
+	}
+	for i, a := range areas {
+		if err := st.EnsureArea(a.Tag, a.Name, a.Description, i, a.ReadOnly); err != nil {
+			return nil, err
+		}
+	}
+
+	return st, nil
+}
+
+// forumReader is who a dispatcher caller reads as: nobody when keyless.
+func forumReader(c *caller) string {
+	if isAnonymous(c.identity) {
+		return ""
+	}
+
+	return c.identity
+}
+
+// forumPost starts a topic (area and title) or replies to one (topic) for a
+// dispatcher caller. The post itself is the call's input.
+func (s *Server) forumPost(c *dispatch.Call) (*dispatch.Result, error) {
+	who := callerOf(c)
+	switch {
+	case isAnonymous(who.identity):
+		return nil, dispatch.Errf(dispatch.CodeForbidden, "posting needs an SSH key")
+	case s.cstore != nil && s.cstore.Banned(who.identity):
+		return nil, dispatch.Errf(dispatch.CodeForbidden, "%v", community.ErrBanned)
+	}
+	nick := ""
+	if who.profile != nil {
+		nick = who.profile.Nick
+	}
+	body := string(c.Input)
+
+	var (
+		data any
+		line string
+		err  error
+	)
+	switch {
+	case c.Has("topic") && !c.Has("area") && !c.Has("title"):
+		var p *forum.Post
+		p, err = s.forums.Reply(int64(c.Int("topic")), who.identity, nick, body, 0)
+		if err == nil {
+			data, line = p, fmt.Sprintf("posted #%d in topic %d", p.ID, p.TopicID)
+		}
+	case c.Has("area") && c.Has("title") && !c.Has("topic"):
+		var a *forum.Area
+		if a, err = s.forums.Area(c.Str("area"), ""); err != nil {
+			break
+		}
+		var t *forum.Topic
+		t, _, err = s.forums.NewTopic(a.ID, who.identity, nick, c.Str("title"), body)
+		if err == nil {
+			data, line = t, fmt.Sprintf("topic %d started in %s", t.ID, a.Tag)
+		}
+	default:
+		return nil, dispatch.Errf(dispatch.CodeBadArgs, "give area and title to start a topic, or topic to reply")
+	}
+
+	var cd *forum.CooldownError
+	switch {
+	case err == nil:
+		return &dispatch.Result{Data: data, Lines: []string{line}}, nil
+	case errors.As(err, &cd):
+		e := dispatch.Errf(dispatch.CodeRateLimited, "%v", err)
+		e.RetryAfter = cd.Wait.Seconds()
+
+		return nil, e
+	case errors.Is(err, forum.ErrNotFound):
+		return nil, dispatch.Errf(dispatch.CodeNotFound, "%v", err)
+	case errors.Is(err, forum.ErrEmpty):
+		return nil, dispatch.Errf(dispatch.CodeBadArgs, "%v", err)
+	case errors.Is(err, forum.ErrLocked), errors.Is(err, forum.ErrReadOnly):
+		return nil, dispatch.Errf(dispatch.CodeForbidden, "%v", err)
+	}
+
+	return nil, err
+}
@@ -0,0 +1,119 @@+package bbs
+
+import (
+	"strings"
+	"testing"
+
+	gossh "golang.org/x/crypto/ssh"
+)
+
+func TestForumsOverTheMenu(t *testing.T) {
+	modKey := newSigner(t)
+	srv := testServer(t, &Config{
+		AllowAnonymous: true,
+		Community:      &CommunityConfig{Admins: []string{gossh.FingerprintSHA256(modKey.PublicKey())}},
+		Forums: &ForumsConfig{Areas: []ForumAreaConfig{
+			{Tag: "general", Name: "General", Description: "talk about anything"},
+			{Tag: "news", Name: "News", ReadOnly: true},
+		}},
+	})
+	addr := listen(t, srv)
+
+	// ana starts a topic.
+	ana := login(t, addr, "ana", newSigner(t))
+	ana.send("f")
+	ana.waitFor("FORUMS")
+	ana.waitFor("talk about anything")
+	ana.send("1\r")
+	ana.waitFor("no topics yet")
+	ana.send("t\r")
+	ana.waitFor("title:")
+	ana.send("Primeiro tópico\r")
+	ana.waitFor("write your post")
+	ana.send("linha um\r")
+	ana.send("linha dois\x1b[31m\r")
+	ana.send(".\r")
+	ana.waitFor("Posted")
+	ana.waitFor("Primeiro tópico")
+
+	// bia sees it as new, reads it, and replies.
+	bia := login(t, addr, "bia", newSigner(t))
+	bia.send("f")
+	bia.waitFor("1 new")
+	bia.send("1\r")
+	bia.waitFor("Primeiro tópico")
+	bia.send("1\r")
+	bia.waitFor("linha dois")
+	bia.send("r\r")
+	bia.waitFor("write your post")
+	bia.send("concordo\r.\r")
+	bia.waitFor("Posted")
+	bia.waitFor("concordo")
+
+	// A keyless visitor reads but cannot post.
+	guest := login(t, addr, "guest", nil)
+	guest.send("f")
+	guest.waitFor("FORUMS")
+	guest.send("1\r1\r")
+	guest.waitFor("concordo")
+	guest.send("r\r")
+	guest.waitFor("Posting needs an SSH key")
+
+	// Read-only areas are for moderators.
+	ana.send("b\r")
+	ana.waitFor("FORUMS")
+	ana.send("2\r")
+	ana.waitFor("News")
+	ana.send("t\r")
+	ana.waitFor("Only moderators post")
+
+	// A moderator locks the topic; replies stop.
+	mod := login(t, addr, "mod", modKey)
+	mod.send("f")
+	mod.waitFor("FORUMS")
+	mod.send("1\r")
+	mod.waitFor("pin/lock/delete")
+	mod.send("l 1\r")
+	mod.waitFor("Done")
+	bia.send("b\r1\r")
+	bia.waitFor("This topic is locked")
+}
+
+func TestForumsOverTheDispatcher(t *testing.T) {
+	srv := testServer(t, &Config{AllowAnonymous: true})
+	addr := listen(t, srv)
+	client := dial(t, addr, newSigner(t))
+
+	out, code := run(t, client, `forum.post area=general title="Hello from a script"`, "a post sent on stdin")
+	if code != 0 {
+		t.Fatalf("forum.post: %s", out)
+	}
+	if r := decode(t, out); !r.OK {
+		t.Fatalf("forum.post = %+v", r)
+	}
+
+	out, _ = run(t, client, "forum.topics area=general output=text", "")
+	if !strings.Contains(out, "Hello from a script") {
+		t.Fatalf("forum.topics:\n%s", out)
+	}
+	out, _ = run(t, client, "forum.read topic=1 output=text", "")
+	if !strings.Contains(out, "a post sent on stdin") {
+		t.Fatalf("forum.read:\n%s", out)
+	}
+
+	// Posting again at once hits the cooldown.
+	out, code = run(t, client, "forum.post topic=1", "again")
+	if r := decode(t, out); code == 0 || r.Error == nil || r.Error.Code != "rate_limited" {
+		t.Fatalf("cooldown: %s", out)
+	}
+
+	// Keyless callers read but do not post.
+	anon := dial(t, addr, nil)
+	out, code = run(t, anon, "forum.post topic=1", "hi")
+	if r := decode(t, out); code == 0 || r.Error == nil || r.Error.Code != "forbidden" {
+		t.Fatalf("anonymous post: %s", out)
+	}
+	if out, code = run(t, anon, "forum.areas output=text", ""); code != 0 || !strings.Contains(out, "general") {
+		t.Fatalf("anonymous forum.areas: %s", out)
+	}
+}
@@ -29,228 +29,258 @@ } 
 // bbsEN is the English fallback when no localizer is wired (tests, early boot).
 var bbsEN = map[string]string{
-	"BBS_CONTINUE":         "press any key to continue...",
-	"BBS_MORE":             "── more ── (any key: next · q: back)",
-	"BBS_PASTE_END":        "end with a line containing only . or EOF  ·  Ctrl+C cancels",
-	"BBS_WEATHER":          "weather",
-	"BBS_SETTINGS":         "settings",
-	"BBS_ABOUT":            "about",
-	"BBS_CHOOSE":           "choose",
-	"BBS_QUIT":             "quit",
-	"BBS_BACK":             "back",
-	"BBS_CHANGE":           "change",
-	"BBS_NOT_SET":          "(not set)",
-	"BBS_UNKNOWN_MAIN":     "unknown key — try %s, w, c, s, a, m or q",
-	"BBS_SEE_YOU":          "⚡ hammer — see you",
-	"BBS_TAGLINE":          "tools over SSH",
-	"BBS_NOW":              "now",
-	"BBS_SEARCH_CITY":      "search city",
-	"BBS_MY_CITIES":        "my cities",
-	"BBS_TITLE_WEATHER":    "WEATHER",
-	"BBS_TITLE_SEARCH":     "SEARCH CITY",
-	"BBS_TITLE_CITIES":     "MY CITIES",
-	"BBS_TITLE_SETTINGS":   "SETTINGS",
-	"BBS_UNKNOWN_WX":       "unknown key — try 1-3, b or q",
-	"BBS_NO_DEFAULT":       "No default city set. Use [2] to search and save one.",
-	"BBS_FETCHING":         "fetching weather for %s...",
-	"BBS_NO_WEATHER":       "Could not get weather for %s.",
-	"BBS_SEARCH_HINT":      " type a city name — empty or b to go back",
-	"BBS_CITY_NAME":        "city name: ",
-	"BBS_LOOKUP":           "looking up %s...",
-	"BBS_NOT_FOUND":        "%s not found or unavailable. Try again.",
-	"BBS_ALREADY_SAVED":    "%s is already in your cities",
-	"BBS_SAVE_CITY":        "Save this city?",
-	"BBS_SET_DEFAULT":      "Set as default?",
-	"BBS_YN":               "[y/N]",
-	"BBS_SAVED_DEFAULT":    "Saved %s and set as default",
-	"BBS_SAVED":            "Saved %s",
-	"BBS_CITIES_PROMPT":    "cities> ",
-	"BBS_NO_CITY_NUM":      "no city #%s — use the numbers in the list",
-	"BBS_NO_CITIES":        "(no saved cities yet — use search to add one)",
-	"BBS_HINT_VIEW":        "view",
-	"BBS_HINT_DEFAULT":     "default",
-	"BBS_HINT_REMOVE":      "remove",
-	"BBS_STAR_DEFAULT":     "★ = default city",
-	"BBS_USAGE_DEFAULT":    "usage: d2 sets city #2 as default",
-	"BBS_USAGE_REMOVE":     "usage: r2 removes city #2",
-	"BBS_CONFIRM_REMOVE":   "Remove %s?",
-	"BBS_REMOVED":          "Removed %s",
-	"BBS_REMOVED_DEF":      "Removed %s (default cleared)",
-	"BBS_NOW_DEFAULT":      "%s is now your default",
-	"BBS_UNITS":            "units",
-	"BBS_LANGUAGE":         "language",
-	"BBS_VIEW":             "view",
-	"BBS_EMOJI":            "emoji",
-	"BBS_UNKNOWN_SET":      "unknown key — try 1-8 or b",
-	"BBS_CURRENT":          " current  ",
-	"BBS_LANG_CODE":        "language code: ",
-	"BBS_UNKNOWN_LANG":     "unknown language %s — try en, pt, de, es, fr...",
-	"BBS_LANG_SET":         "Language set to %s",
-	"BBS_UNITS_METRIC":     "metric (°C, km/h)",
-	"BBS_UNITS_IMPERIAL":   "imperial (°F, mph)",
-	"BBS_UNITS_MS":         "metric (°C, m/s wind)",
-	"BBS_VIEW_CARD":        "card (emoji)",
-	"BBS_VIEW_V1":          "classic (v1)",
-	"BBS_VIEW_V2":          "graphs (v2)",
-	"BBS_ON":               "on",
-	"BBS_OFF":              "off",
-	"BBS_ABOUT_BLURB":      "An interactive SSH front-end for tools.",
-	"BBS_VERSION":          "version",
-	"BBS_YOUR_KEY":         "your key",
-	"BBS_ANONYMOUS":        "anonymous (no key)",
-	"BBS_ANON_NOTE":        "anonymous session — settings last until you disconnect; connect with an SSH key to keep them",
-	"BBS_TIP_WIDE":         "(tip: maximize your terminal for the full layout)",
-	"BBS_PROMPT_WX":        "weather> ",
-	"BBS_PROMPT_SET":       "settings> ",
-	"TOOL_NET":             "net",
-	"TOOL_NET_SUM":         "DNS, TLS, HTTP, RDAP",
-	"TOOL_QR":              "qr",
-	"TOOL_QR_SUM":          "URL, phone, text",
-	"TOOL_HASH":            "hash",
-	"TOOL_HASH_SUM":        "SHA, entropy, type",
-	"TOOL_JQ":              "jq",
-	"TOOL_JQ_SUM":          "JSON/YAML transform",
-	"TOOL_AGE":             "age",
-	"TOOL_AGE_SUM":         "encrypt / decrypt",
-	"TOOL_LINKS":           "links",
-	"TOOL_LINKS_SUM":       "broken-link check",
-	"TOOL_DATA":            "data",
-	"TOOL_DATA_SUM":        "DuckDB queries",
-	"TOOL_PATH":            "path",
-	"TOOL_PATH_SUM":        "ping / MTR",
-	"BBS_MORE_TOOLS":       "more",
-	"BBS_MORE_TOOLS_SUM":   "csv, base64, uuid",
-	"BBS_MORE_PROMPT":      "more> ",
-	"BBS_UNKNOWN_MORE":     "unknown key — try %s or b",
-	"BBS_CSV":              "csv",
-	"BBS_CSV_SUM":          "rows, header, counts",
-	"BBS_PASTE_CSV":        "paste CSV",
-	"BBS_B64":              "base64",
-	"BBS_B64_SUM":          "encode / decode",
-	"BBS_B64_ENCODE":       "encode",
-	"BBS_B64_DECODE":       "decode",
-	"BBS_B64_PROMPT":       "b64> ",
-	"BBS_PASTE_B64":        "paste Base64",
-	"BBS_IDS":              "ids",
-	"BBS_IDS_SUM":          "UUID v4",
-	"BBS_HOST_URL":         "host or URL: ",
-	"BBS_PROBING":          "probing %s...",
-	"BBS_TEXT":             "text: ",
-	"BBS_PASTE_BYTES":      "paste bytes to inspect",
-	"BBS_PASTE_JSON":       "paste JSON or YAML",
-	"BBS_JQ_FILTER":        "jq filter (empty = .): ",
-	"BBS_INPUT":            "input",
-	"BBS_ENCRYPT":          "encrypt",
-	"BBS_DECRYPT":          "decrypt",
-	"BBS_GENERATE":         "generate",
-	"BBS_ENCRYPT_SUM":      "plaintext + recipient",
-	"BBS_DECRYPT_SUM":      "ciphertext + identity",
-	"BBS_GENERATE_SUM":     "keypair in memory",
-	"BBS_PASTE_PLAIN":      "paste plaintext",
-	"BBS_RECIPIENT":        "recipient (age1...): ",
-	"BBS_PASTE_CIPHER":     "paste ciphertext",
-	"BBS_IDENTITY":         "identity (AGE-SECRET-KEY-...): ",
-	"BBS_RCPT_SHARE":       "recipient (share this)",
-	"BBS_ID_PRIVATE":       "identity (private — shown once, not saved)",
-	"BBS_NO_LYCHEE":        "lychee is not installed on this host",
-	"BBS_PASTE_URL":        "paste a URL or markdown",
-	"BBS_CHECKING":         "checking links...",
-	"BBS_NO_DUCKDB":        "duckdb is not installed, or datasetsDir is missing",
-	"BBS_RUN":              "run",
-	"BBS_NO_QUERY":         "no query #%s",
-	"BBS_RUNNING":          "running %s...",
-	"BBS_NO_PING":          "neither ping nor mtr is installed on this host",
-	"BBS_HOST":             "host: ",
-	"BBS_QUERY_PROMPT":     "query> ",
-	"BBS_AGE_PROMPT":       "age> ",
-	"BBS_QR_PROMPT":        "qr> ",
-	"BBS_QR_TEXT":          "text",
-	"BBS_QR_TEXT_SUM":      "any payload",
-	"BBS_QR_URL":           "url",
-	"BBS_QR_URL_SUM":       "opens in the camera browser",
-	"BBS_QR_PHONE":         "phone",
-	"BBS_QR_PHONE_SUM":     "tel: link for the dialer",
-	"BBS_QR_URL_PROMPT":    "url: ",
-	"BBS_QR_PHONE_PROMPT":  "phone: ",
-	"BBS_NICK":             "nickname",
-	"BBS_NICK_PROMPT":      "new nickname (2-16: letters, digits, _ . -): ",
-	"BBS_NICK_SET":         "You are now %s",
-	"BBS_NICK_TAKEN":       "%s already belongs to someone else",
-	"BBS_NICK_FORMAT":      "Nicknames are 2-16 characters: letters, digits, _ . -",
-	"BBS_NICK_RESERVED":    "%s is reserved; pick another",
-	"BBS_NICK_NEED_KEY":    "Nicknames need an SSH key; anonymous sessions get a guest name",
-	"BBS_WALL":             "wall",
-	"BBS_WALL_SUM":         "one-liners",
-	"BBS_CHAT":             "chat",
-	"BBS_CHAT_SUM":         "live room",
-	"BBS_ONLINE":           "%d online",
-	"BBS_TITLE_WALL":       "WALL",
-	"BBS_TITLE_WHO":        "ONLINE NOW",
-	"BBS_WALL_EMPTY":       "(the wall is empty — be the first)",
-	"BBS_WALL_POST":        "post",
-	"BBS_WALL_WHO":         "who's online",
-	"BBS_WALL_DELETE":      "delete #n",
-	"BBS_WALL_PROMPT":      "wall> ",
-	"BBS_WALL_WRITE":       "your line (up to %d characters): ",
-	"BBS_WALL_POSTED":      "Posted to the wall",
-	"BBS_WALL_COOLDOWN":    "You can post again in %s",
-	"BBS_WALL_NEED_KEY":    "Posting needs an SSH key — anonymous sessions can only read",
-	"BBS_WALL_DELETED":     "Deleted",
-	"BBS_WALL_NO_LINE":     "no line #%s",
-	"BBS_UNKNOWN_WALL":     "unknown option — try p, o or b",
-	"BBS_BANNED":           "You are banned from posting",
-	"BBS_WHERE_MENU":       "menu",
-	"BBS_WHERE_CHAT":       "in the chat",
-	"BBS_GAMES":            "games",
-	"BBS_GAMES_SUM":        "NES in sixel",
-	"BBS_TITLE_GAMES":      "GAMES",
-	"BBS_GAMES_INTRO1":     "Homebrew NES games, drawn in sixel graphics.",
-	"BBS_GAMES_INTRO2":     "Needs a sixel terminal: SyncTERM, IcyTerm, WezTerm, foot, iTerm2.",
-	"BBS_GAMES_INTRO3":     "Sound and held keys work in SyncTERM and IcyTerm.",
-	"BBS_GAMES_INTRO4":     "Up to %d minutes a sitting; leaving saves your game.",
-	"BBS_GAMES_PLAY":       "play",
-	"BBS_GAMES_ANON":       "Games need an SSH key — connect with one to play",
-	"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_EMPTY":      "No games on the shelf yet — check back soon",
-	"BBS_GAMES_FAILED":     "The games could not start — try again later",
-	"BBS_WHERE_GAME":       "playing",
-	"BBS_ANON_TAG":         "anonymous",
-	"BBS_CONNS":            "%d connections",
-	"BBS_CHAT_TITLE":       "CHAT · #lobby",
-	"BBS_CHAT_HINT":        "/help for commands · /quit to leave",
-	"BBS_CHAT_READONLY":    "read only — connect with an SSH key to talk",
-	"BBS_CHAT_JOIN":        "→ %s joined",
-	"BBS_CHAT_LEAVE":       "← %s left",
-	"BBS_CHAT_NICK":        "%s is now %s",
-	"BBS_CHAT_KICK":        "%s was removed by %s",
-	"BBS_CHAT_MUTE":        "%s was muted for %s",
-	"BBS_CHAT_BAN":         "%s was banned by %s",
-	"BBS_CHAT_KICKED":      "You were removed from the room — press any key",
-	"BBS_CHAT_MUTED":       "You are muted for %s",
-	"BBS_CHAT_SLOW":        "Slow down — try again in %s",
-	"BBS_CHAT_WHO":         "In the room (%d): %s",
-	"BBS_CHAT_NOT_ADMIN":   "That command is for moderators",
-	"BBS_CHAT_NO_NICK":     "Nobody called %s is in the room",
-	"BBS_CHAT_UNKNOWN_CMD": "Unknown command %s — try /help",
-	"BBS_CHAT_UNBANNED":    "%s can post again",
-	"BBS_CHAT_NOT_BANNED":  "%s was not banned",
-	"BBS_CHAT_USAGE":       "usage: %s",
-	"BBS_CHAT_HELP_ME":     "/me action     describe what you are doing",
-	"BBS_CHAT_HELP_NICK":   "/nick name     change your nickname",
-	"BBS_CHAT_HELP_WHO":    "/who           who is in the room",
-	"BBS_CHAT_HELP_QUIT":   "/quit          back to the menu (Ctrl+C too)",
-	"BBS_CHAT_HELP_MOD":    "/mute nick [10m] · /kick nick · /ban nick [reason] · /unban nick",
-	"BBS_THEME":            "theme",
-	"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)",
-	"BBS_NOTICES":          "arrivals",
-	"BBS_NOTICE_ARRIVED":   "→ %s arrived",
-	"BBS_NOTICE_LEFT":      "← %s left",
+	"BBS_CONTINUE":             "press any key to continue...",
+	"BBS_MORE":                 "── more ── (any key: next · q: back)",
+	"BBS_PASTE_END":            "end with a line containing only . or EOF  ·  Ctrl+C cancels",
+	"BBS_WEATHER":              "weather",
+	"BBS_SETTINGS":             "settings",
+	"BBS_ABOUT":                "about",
+	"BBS_CHOOSE":               "choose",
+	"BBS_QUIT":                 "quit",
+	"BBS_BACK":                 "back",
+	"BBS_CHANGE":               "change",
+	"BBS_NOT_SET":              "(not set)",
+	"BBS_UNKNOWN_MAIN":         "unknown key — try %s, w, c, s, a, m or q",
+	"BBS_SEE_YOU":              "⚡ hammer — see you",
+	"BBS_TAGLINE":              "tools over SSH",
+	"BBS_NOW":                  "now",
+	"BBS_SEARCH_CITY":          "search city",
+	"BBS_MY_CITIES":            "my cities",
+	"BBS_TITLE_WEATHER":        "WEATHER",
+	"BBS_TITLE_SEARCH":         "SEARCH CITY",
+	"BBS_TITLE_CITIES":         "MY CITIES",
+	"BBS_TITLE_SETTINGS":       "SETTINGS",
+	"BBS_UNKNOWN_WX":           "unknown key — try 1-3, b or q",
+	"BBS_NO_DEFAULT":           "No default city set. Use [2] to search and save one.",
+	"BBS_FETCHING":             "fetching weather for %s...",
+	"BBS_NO_WEATHER":           "Could not get weather for %s.",
+	"BBS_SEARCH_HINT":          " type a city name — empty or b to go back",
+	"BBS_CITY_NAME":            "city name: ",
+	"BBS_LOOKUP":               "looking up %s...",
+	"BBS_NOT_FOUND":            "%s not found or unavailable. Try again.",
+	"BBS_ALREADY_SAVED":        "%s is already in your cities",
+	"BBS_SAVE_CITY":            "Save this city?",
+	"BBS_SET_DEFAULT":          "Set as default?",
+	"BBS_YN":                   "[y/N]",
+	"BBS_SAVED_DEFAULT":        "Saved %s and set as default",
+	"BBS_SAVED":                "Saved %s",
+	"BBS_CITIES_PROMPT":        "cities> ",
+	"BBS_NO_CITY_NUM":          "no city #%s — use the numbers in the list",
+	"BBS_NO_CITIES":            "(no saved cities yet — use search to add one)",
+	"BBS_HINT_VIEW":            "view",
+	"BBS_HINT_DEFAULT":         "default",
+	"BBS_HINT_REMOVE":          "remove",
+	"BBS_STAR_DEFAULT":         "★ = default city",
+	"BBS_USAGE_DEFAULT":        "usage: d2 sets city #2 as default",
+	"BBS_USAGE_REMOVE":         "usage: r2 removes city #2",
+	"BBS_CONFIRM_REMOVE":       "Remove %s?",
+	"BBS_REMOVED":              "Removed %s",
+	"BBS_REMOVED_DEF":          "Removed %s (default cleared)",
+	"BBS_NOW_DEFAULT":          "%s is now your default",
+	"BBS_UNITS":                "units",
+	"BBS_LANGUAGE":             "language",
+	"BBS_VIEW":                 "view",
+	"BBS_EMOJI":                "emoji",
+	"BBS_UNKNOWN_SET":          "unknown key — try 1-8 or b",
+	"BBS_CURRENT":              " current  ",
+	"BBS_LANG_CODE":            "language code: ",
+	"BBS_UNKNOWN_LANG":         "unknown language %s — try en, pt, de, es, fr...",
+	"BBS_LANG_SET":             "Language set to %s",
+	"BBS_UNITS_METRIC":         "metric (°C, km/h)",
+	"BBS_UNITS_IMPERIAL":       "imperial (°F, mph)",
+	"BBS_UNITS_MS":             "metric (°C, m/s wind)",
+	"BBS_VIEW_CARD":            "card (emoji)",
+	"BBS_VIEW_V1":              "classic (v1)",
+	"BBS_VIEW_V2":              "graphs (v2)",
+	"BBS_ON":                   "on",
+	"BBS_OFF":                  "off",
+	"BBS_ABOUT_BLURB":          "An interactive SSH front-end for tools.",
+	"BBS_VERSION":              "version",
+	"BBS_YOUR_KEY":             "your key",
+	"BBS_ANONYMOUS":            "anonymous (no key)",
+	"BBS_ANON_NOTE":            "anonymous session — settings last until you disconnect; connect with an SSH key to keep them",
+	"BBS_TIP_WIDE":             "(tip: maximize your terminal for the full layout)",
+	"BBS_PROMPT_WX":            "weather> ",
+	"BBS_PROMPT_SET":           "settings> ",
+	"TOOL_NET":                 "net",
+	"TOOL_NET_SUM":             "DNS, TLS, HTTP, RDAP",
+	"TOOL_QR":                  "qr",
+	"TOOL_QR_SUM":              "URL, phone, text",
+	"TOOL_HASH":                "hash",
+	"TOOL_HASH_SUM":            "SHA, entropy, type",
+	"TOOL_JQ":                  "jq",
+	"TOOL_JQ_SUM":              "JSON/YAML transform",
+	"TOOL_AGE":                 "age",
+	"TOOL_AGE_SUM":             "encrypt / decrypt",
+	"TOOL_LINKS":               "links",
+	"TOOL_LINKS_SUM":           "broken-link check",
+	"TOOL_DATA":                "data",
+	"TOOL_DATA_SUM":            "DuckDB queries",
+	"TOOL_PATH":                "path",
+	"TOOL_PATH_SUM":            "ping / MTR",
+	"BBS_MORE_TOOLS":           "more",
+	"BBS_MORE_TOOLS_SUM":       "csv, base64, uuid",
+	"BBS_MORE_PROMPT":          "more> ",
+	"BBS_UNKNOWN_MORE":         "unknown key — try %s or b",
+	"BBS_CSV":                  "csv",
+	"BBS_CSV_SUM":              "rows, header, counts",
+	"BBS_PASTE_CSV":            "paste CSV",
+	"BBS_B64":                  "base64",
+	"BBS_B64_SUM":              "encode / decode",
+	"BBS_B64_ENCODE":           "encode",
+	"BBS_B64_DECODE":           "decode",
+	"BBS_B64_PROMPT":           "b64> ",
+	"BBS_PASTE_B64":            "paste Base64",
+	"BBS_IDS":                  "ids",
+	"BBS_IDS_SUM":              "UUID v4",
+	"BBS_HOST_URL":             "host or URL: ",
+	"BBS_PROBING":              "probing %s...",
+	"BBS_TEXT":                 "text: ",
+	"BBS_PASTE_BYTES":          "paste bytes to inspect",
+	"BBS_PASTE_JSON":           "paste JSON or YAML",
+	"BBS_JQ_FILTER":            "jq filter (empty = .): ",
+	"BBS_INPUT":                "input",
+	"BBS_ENCRYPT":              "encrypt",
+	"BBS_DECRYPT":              "decrypt",
+	"BBS_GENERATE":             "generate",
+	"BBS_ENCRYPT_SUM":          "plaintext + recipient",
+	"BBS_DECRYPT_SUM":          "ciphertext + identity",
+	"BBS_GENERATE_SUM":         "keypair in memory",
+	"BBS_PASTE_PLAIN":          "paste plaintext",
+	"BBS_RECIPIENT":            "recipient (age1...): ",
+	"BBS_PASTE_CIPHER":         "paste ciphertext",
+	"BBS_IDENTITY":             "identity (AGE-SECRET-KEY-...): ",
+	"BBS_RCPT_SHARE":           "recipient (share this)",
+	"BBS_ID_PRIVATE":           "identity (private — shown once, not saved)",
+	"BBS_NO_LYCHEE":            "lychee is not installed on this host",
+	"BBS_PASTE_URL":            "paste a URL or markdown",
+	"BBS_CHECKING":             "checking links...",
+	"BBS_NO_DUCKDB":            "duckdb is not installed, or datasetsDir is missing",
+	"BBS_RUN":                  "run",
+	"BBS_NO_QUERY":             "no query #%s",
+	"BBS_RUNNING":              "running %s...",
+	"BBS_NO_PING":              "neither ping nor mtr is installed on this host",
+	"BBS_HOST":                 "host: ",
+	"BBS_QUERY_PROMPT":         "query> ",
+	"BBS_AGE_PROMPT":           "age> ",
+	"BBS_QR_PROMPT":            "qr> ",
+	"BBS_QR_TEXT":              "text",
+	"BBS_QR_TEXT_SUM":          "any payload",
+	"BBS_QR_URL":               "url",
+	"BBS_QR_URL_SUM":           "opens in the camera browser",
+	"BBS_QR_PHONE":             "phone",
+	"BBS_QR_PHONE_SUM":         "tel: link for the dialer",
+	"BBS_QR_URL_PROMPT":        "url: ",
+	"BBS_QR_PHONE_PROMPT":      "phone: ",
+	"BBS_NICK":                 "nickname",
+	"BBS_NICK_PROMPT":          "new nickname (2-16: letters, digits, _ . -): ",
+	"BBS_NICK_SET":             "You are now %s",
+	"BBS_NICK_TAKEN":           "%s already belongs to someone else",
+	"BBS_NICK_FORMAT":          "Nicknames are 2-16 characters: letters, digits, _ . -",
+	"BBS_NICK_RESERVED":        "%s is reserved; pick another",
+	"BBS_NICK_NEED_KEY":        "Nicknames need an SSH key; anonymous sessions get a guest name",
+	"BBS_WALL":                 "wall",
+	"BBS_WALL_SUM":             "one-liners",
+	"BBS_CHAT":                 "chat",
+	"BBS_CHAT_SUM":             "live room",
+	"BBS_ONLINE":               "%d online",
+	"BBS_TITLE_WALL":           "WALL",
+	"BBS_TITLE_WHO":            "ONLINE NOW",
+	"BBS_WALL_EMPTY":           "(the wall is empty — be the first)",
+	"BBS_WALL_POST":            "post",
+	"BBS_WALL_WHO":             "who's online",
+	"BBS_WALL_DELETE":          "delete #n",
+	"BBS_WALL_PROMPT":          "wall> ",
+	"BBS_WALL_WRITE":           "your line (up to %d characters): ",
+	"BBS_WALL_POSTED":          "Posted to the wall",
+	"BBS_WALL_COOLDOWN":        "You can post again in %s",
+	"BBS_WALL_NEED_KEY":        "Posting needs an SSH key — anonymous sessions can only read",
+	"BBS_WALL_DELETED":         "Deleted",
+	"BBS_WALL_NO_LINE":         "no line #%s",
+	"BBS_UNKNOWN_WALL":         "unknown option — try p, o or b",
+	"BBS_BANNED":               "You are banned from posting",
+	"BBS_WHERE_MENU":           "menu",
+	"BBS_WHERE_CHAT":           "in the chat",
+	"BBS_GAMES":                "games",
+	"BBS_GAMES_SUM":            "NES in sixel",
+	"BBS_TITLE_GAMES":          "GAMES",
+	"BBS_GAMES_INTRO1":         "Homebrew NES games, drawn in sixel graphics.",
+	"BBS_GAMES_INTRO2":         "Needs a sixel terminal: SyncTERM, IcyTerm, WezTerm, foot, iTerm2.",
+	"BBS_GAMES_INTRO3":         "Sound and held keys work in SyncTERM and IcyTerm.",
+	"BBS_GAMES_INTRO4":         "Up to %d minutes a sitting; leaving saves your game.",
+	"BBS_GAMES_PLAY":           "play",
+	"BBS_GAMES_ANON":           "Games need an SSH key — connect with one to play",
+	"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_EMPTY":          "No games on the shelf yet — check back soon",
+	"BBS_GAMES_FAILED":         "The games could not start — try again later",
+	"BBS_WHERE_GAME":           "playing",
+	"BBS_ANON_TAG":             "anonymous",
+	"BBS_CONNS":                "%d connections",
+	"BBS_CHAT_TITLE":           "CHAT · #lobby",
+	"BBS_CHAT_HINT":            "/help for commands · /quit to leave",
+	"BBS_CHAT_READONLY":        "read only — connect with an SSH key to talk",
+	"BBS_CHAT_JOIN":            "→ %s joined",
+	"BBS_CHAT_LEAVE":           "← %s left",
+	"BBS_CHAT_NICK":            "%s is now %s",
+	"BBS_CHAT_KICK":            "%s was removed by %s",
+	"BBS_CHAT_MUTE":            "%s was muted for %s",
+	"BBS_CHAT_BAN":             "%s was banned by %s",
+	"BBS_CHAT_KICKED":          "You were removed from the room — press any key",
+	"BBS_CHAT_MUTED":           "You are muted for %s",
+	"BBS_CHAT_SLOW":            "Slow down — try again in %s",
+	"BBS_CHAT_WHO":             "In the room (%d): %s",
+	"BBS_CHAT_NOT_ADMIN":       "That command is for moderators",
+	"BBS_CHAT_NO_NICK":         "Nobody called %s is in the room",
+	"BBS_CHAT_UNKNOWN_CMD":     "Unknown command %s — try /help",
+	"BBS_CHAT_UNBANNED":        "%s can post again",
+	"BBS_CHAT_NOT_BANNED":      "%s was not banned",
+	"BBS_CHAT_USAGE":           "usage: %s",
+	"BBS_CHAT_HELP_ME":         "/me action     describe what you are doing",
+	"BBS_CHAT_HELP_NICK":       "/nick name     change your nickname",
+	"BBS_CHAT_HELP_WHO":        "/who           who is in the room",
+	"BBS_CHAT_HELP_QUIT":       "/quit          back to the menu (Ctrl+C too)",
+	"BBS_CHAT_HELP_MOD":        "/mute nick [10m] · /kick nick · /ban nick [reason] · /unban nick",
+	"BBS_THEME":                "theme",
+	"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)",
+	"BBS_NOTICES":              "arrivals",
+	"BBS_NOTICE_ARRIVED":       "→ %s arrived",
+	"BBS_NOTICE_LEFT":          "← %s left",
+	"BBS_FORUMS":               "forums",
+	"BBS_FORUMS_SUM":           "message areas",
+	"BBS_TITLE_FORUMS":         "FORUMS",
+	"BBS_FORUM_TOPICS":         "%d topics",
+	"BBS_FORUM_NEW":            "%d new",
+	"BBS_FORUM_NO_TOPICS":      "(no topics yet — start one with t)",
+	"BBS_FORUM_PAGE":           "page %d of %d · %d topics",
+	"BBS_FORUM_READ":           "read",
+	"BBS_FORUM_NEW_TOPIC":      "new topic",
+	"BBS_FORUM_PAGES":          "pages",
+	"BBS_FORUM_MOD":            "pin/lock/delete",
+	"BBS_FORUM_REPLY":          "reply",
+	"BBS_FORUM_MORE":           "next posts",
+	"BBS_FORUM_EARLIER":        "earlier posts",
+	"BBS_FORUM_POSTS_RANGE":    "posts %d–%d of %d",
+	"BBS_FORUM_LOCKED":         "This topic is locked",
+	"BBS_FORUM_READONLY":       "Only moderators post in this area",
+	"BBS_FORUM_GONE":           "That topic is gone",
+	"BBS_FORUM_UNKNOWN":        "unknown option — type a number, or b to go back",
+	"BBS_FORUM_ERROR":          "The forums are unavailable right now",
+	"BBS_FORUM_NEED_KEY":       "Posting needs an SSH key — keyless visitors can only read",
+	"BBS_FORUM_TITLE":          "title: ",
+	"BBS_FORUM_WRITE":          "write your post (up to %d bytes)",
+	"BBS_FORUM_POSTED":         "Posted",
+	"BBS_FORUM_CANCELLED":      "Nothing posted",
+	"BBS_FORUM_TOO_LONG":       "That post is too long",
+	"BBS_FORUM_COOLDOWN":       "You can post again in %s",
+	"BBS_FORUM_DONE":           "Done",
+	"BBS_FORUM_CONFIRM_DELETE": "Delete the topic \"%s\" and all its posts?",
+	"BBS_FORUM_CONFIRM_POST":   "Delete post #%d?",
 }
@@ -0,0 +1,409 @@+package bbs
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/runawaydevil/hammer/internal/community"
+	"github.com/runawaydevil/hammer/internal/forum"
+	"github.com/runawaydevil/hammer/internal/ui"
+)
+
+// postsPerScreen is how many posts of a topic are loaded at a time.
+const postsPerScreen = 20
+
+// readerID is who the forums count unread posts for: nobody when keyless.
+func (st *session) readerID() string {
+	if st.anon {
+		return ""
+	}
+
+	return st.fp
+}
+
+// forumsMenu lists the message areas.
+func (st *session) forumsMenu() {
+	for st.alive() {
+		areas, err := st.srv.forums.Areas(st.readerID())
+		if err != nil {
+			log.Println("bbs: forums:", err)
+			st.note = st.t("BBS_FORUM_ERROR")
+
+			return
+		}
+
+		st.clear()
+		rows := []string{""}
+		for i, a := range areas {
+			extra := st.tf("BBS_FORUM_TOPICS", a.Topics)
+			if a.Unread > 0 {
+				extra += st.th.Reset + st.th.Dim + " · " + st.th.Reset + st.th.Good + st.tf("BBS_FORUM_NEW", a.Unread)
+			}
+			rows = append(rows, st.menuEntry(strconv.Itoa(i+1), a.Name, extra))
+			if a.Description != "" {
+				rows = append(rows, "       "+st.th.Dim+ui.Truncate(a.Description, 48)+st.th.Reset)
+			}
+		}
+		rows = append(rows, "")
+
+		box := st.th.Box(st.th.Heading(st.t("BBS_TITLE_FORUMS")), rows, 56)
+		box = append(box, st.th.HintBar([][2]string{{fmt.Sprintf("1-%d", len(areas)), st.t("BBS_CHOOSE")}, {"b", st.t("BBS_BACK")}}))
+		st.screen(box)
+
+		in := strings.ToLower(strings.TrimSpace(st.readLine("forums> ")))
+		if in == "" || in == "b" || in == "q" {
+			return
+		}
+		if n, err := strconv.Atoi(in); err == nil && n >= 1 && n <= len(areas) {
+			st.areaView(areas[n-1])
+
+			continue
+		}
+		st.note = st.t("BBS_FORUM_UNKNOWN")
+	}
+}
+
+// areaView lists an area's topics a page at a time.
+func (st *session) areaView(a forum.Area) {
+	admin := st.srv.isAdmin(st.fp)
+	page := 0
+
+	for st.alive() {
+		size := max(st.height0()-14, 5)
+		topics, total, err := st.srv.forums.Topics(a.ID, st.readerID(), page*size, size)
+		if err != nil {
+			log.Println("bbs: forums:", err)
+			st.note = st.t("BBS_FORUM_ERROR")
+
+			return
+		}
+		pages := max((total+size-1)/size, 1)
+		page = min(page, pages-1)
+
+		st.clear()
+		rows := []string{""}
+		titleW := max(st.width0()-46, 20)
+		for i, t := range topics {
+			mark := " "
+			switch {
+			case t.Sticky:
+				mark = st.th.Accent + "♦" + st.th.Reset
+			case t.Locked:
+				mark = st.th.Dim + "■" + st.th.Reset
+			}
+			unread := ""
+			if t.Unread > 0 {
+				unread = " " + st.th.Good + st.tf("BBS_FORUM_NEW", t.Unread) + st.th.Reset
+			}
+			rows = append(rows, fmt.Sprintf(" %s%3d.%s %s %s %s%-5s%s %s%s",
+				st.th.Prompt, page*size+i+1, st.th.Reset, mark,
+				ui.PadRightANSI(st.th.Value+ui.Truncate(t.Title, titleW)+st.th.Reset, titleW),
+				st.th.Dim, ago(time.Since(t.LastPostAt)), st.th.Reset,
+				ui.PadRightANSI(st.colorNick(t.Nick), 14), unread))
+		}
+		if len(topics) == 0 {
+			rows = append(rows, " "+st.th.Dim+st.t("BBS_FORUM_NO_TOPICS")+st.th.Reset)
+		}
+		rows = append(rows, "", " "+st.th.Dim+st.tf("BBS_FORUM_PAGE", page+1, pages, total)+st.th.Reset)
+
+		hints := [][2]string{{"#", st.t("BBS_FORUM_READ")}}
+		if !a.ReadOnly || admin {
+			hints = append(hints, [2]string{"t", st.t("BBS_FORUM_NEW_TOPIC")})
+		}
+		if pages > 1 {
+			hints = append(hints, [2]string{"< >", st.t("BBS_FORUM_PAGES")})
+		}
+		hints = append(hints, [2]string{"b", st.t("BBS_BACK")})
+		if admin {
+			hints = append(hints, [2]string{"s/l/d #", st.t("BBS_FORUM_MOD")})
+		}
+
+		st.screen(append(st.th.Box(st.th.Heading(a.Name), rows, 60), st.th.HintBar(hints)))
+
+		in := strings.TrimSpace(st.readLine("topics> "))
+		cmd, arg, _ := strings.Cut(strings.ToLower(in), " ")
+		pick := func(s string) (forum.Topic, bool) {
+			n, err := strconv.Atoi(strings.TrimSpace(s))
+			if err != nil || n < page*size+1 || n > page*size+len(topics) {
+				st.note = st.t("BBS_FORUM_UNKNOWN")
+
+				return forum.Topic{}, false
+			}
+
+			return topics[n-1-page*size], true
+		}
+
+		switch {
+		case cmd == "" || cmd == "b" || cmd == "q":
+			return
+		case cmd == ">" || cmd == ".":
+			if page+1 < pages {
+				page++
+			}
+		case cmd == "<" || cmd == ",":
+			if page > 0 {
+				page--
+			}
+		case cmd == "t":
+			if a.ReadOnly && !admin {
+				st.note = st.t("BBS_FORUM_READONLY")
+
+				continue
+			}
+			st.newTopic(a)
+		case admin && (cmd == "s" || cmd == "l" || cmd == "d"):
+			t, ok := pick(arg)
+			if !ok {
+				continue
+			}
+			st.moderateTopic(cmd, t)
+		default:
+			if t, ok := pick(cmd); ok {
+				st.topicView(t)
+			}
+		}
+	}
+}
+
+// moderateTopic pins (s), locks (l) or deletes (d) a topic.
+func (st *session) moderateTopic(cmd string, t forum.Topic) {
+	var err error
+	switch cmd {
+	case "s":
+		err = st.srv.forums.SetSticky(t.ID, !t.Sticky)
+	case "l":
+		err = st.srv.forums.SetLocked(t.ID, !t.Locked)
+	case "d":
+		if !st.confirm(st.tf("BBS_FORUM_CONFIRM_DELETE", t.Title)) {
+			return
+		}
+		err = st.srv.forums.DeleteTopic(t.ID)
+	}
+	if err != nil {
+		log.Println("bbs: forums:", err)
+		st.note = st.t("BBS_FORUM_ERROR")
+
+		return
+	}
+	st.note = st.t("BBS_FORUM_DONE")
+}
+
+// topicView reads a topic, starting at the first post the caller has not
+// read, postsPerScreen posts at a time.
+func (st *session) topicView(t forum.Topic) {
+	admin := st.srv.isAdmin(st.fp)
+	start := 0
+	if first, err := st.srv.forums.FirstUnread(t.ID, st.readerID()); err == nil {
+		start = (first / postsPerScreen) * postsPerScreen
+	}
+
+	for st.alive() {
+		// The topic may have changed: replies, a lock, a deletion.
+		cur, err := st.srv.forums.Topic(t.ID, st.readerID())
+		if errors.Is(err, forum.ErrNotFound) {
+			st.note = st.t("BBS_FORUM_GONE")
+
+			return
+		}
+		if err != nil {
+			log.Println("bbs: forums:", err)
+			st.note = st.t("BBS_FORUM_ERROR")
+
+			return
+		}
+		t = *cur
+
+		posts, total, err := st.srv.forums.Posts(t.ID, start, postsPerScreen)
+		if err != nil {
+			log.Println("bbs: forums:", err)
+			st.note = st.t("BBS_FORUM_ERROR")
+
+			return
+		}
+
+		st.clear()
+		w := min(st.width0()-2, 100)
+		status := ""
+		if t.Locked {
+			status = "  " + st.th.Dim + "■ " + st.t("BBS_FORUM_LOCKED") + st.th.Reset
+		}
+		lines := []string{
+			"",
+			" " + st.th.Heading(ui.Truncate(t.Title, w-2)) + status,
+			" " + st.th.Dim + st.tf("BBS_FORUM_POSTS_RANGE", start+1, start+len(posts), total) + st.th.Reset,
+			"",
+		}
+		for i, p := range posts {
+			header := fmt.Sprintf(" %s#%d%s  %s  %s%s%s", st.th.Prompt, start+i+1, st.th.Reset,
+				st.colorNick(p.Nick), st.th.Dim, p.Created.Format("2006-01-02 15:04"), st.th.Reset)
+			lines = append(lines, header, " "+st.th.Frame+strings.Repeat("─", min(w-2, 60))+st.th.Reset)
+			for _, l := range ui.Wrap(p.Body, w-3) {
+				lines = append(lines, "  "+st.th.Value+l+st.th.Reset)
+			}
+			lines = append(lines, "")
+		}
+		st.page(lines)
+		if len(posts) > 0 {
+			if err := st.srv.forums.MarkRead(st.readerID(), t.ID, posts[len(posts)-1].ID); err != nil {
+				log.Println("bbs: forums:", err)
+			}
+		}
+
+		more := start+postsPerScreen < total
+		hints := [][2]string{}
+		if !t.Locked {
+			hints = append(hints, [2]string{"r", st.t("BBS_FORUM_REPLY")})
+		}
+		if more {
+			hints = append(hints, [2]string{">", st.t("BBS_FORUM_MORE")})
+		}
+		if start > 0 {
+			hints = append(hints, [2]string{"<", st.t("BBS_FORUM_EARLIER")})
+		}
+		hints = append(hints, [2]string{"b", st.t("BBS_BACK")})
+		if admin {
+			hints = append(hints, [2]string{"s/l/d #", st.t("BBS_FORUM_MOD")})
+		}
+		if st.note != "" {
+			st.write([]string{" " + st.th.Dim + st.note + st.th.Reset})
+			st.note = ""
+		}
+		st.write([]string{" " + st.th.HintBar(hints)})
+
+		in := strings.TrimSpace(st.readLine("topic> "))
+		cmd, arg, _ := strings.Cut(strings.ToLower(in), " ")
+		switch {
+		case cmd == "" || cmd == "b" || cmd == "q":
+			return
+		case cmd == ">" || cmd == ".":
+			if more {
+				start += postsPerScreen
+			}
+		case cmd == "<" || cmd == ",":
+			start = max(start-postsPerScreen, 0)
+		case cmd == "r":
+			if t.Locked {
+				st.note = st.t("BBS_FORUM_LOCKED")
+
+				continue
+			}
+			if st.reply(t) {
+				// Jump to the end, where the new post is.
+				start = (total / postsPerScreen) * postsPerScreen
+			}
+		case admin && (cmd == "s" || cmd == "l"):
+			st.moderateTopic(cmd, t)
+		case admin && cmd == "d":
+			n, err := strconv.Atoi(strings.TrimSpace(arg))
+			if err != nil || n < start+1 || n > start+len(posts) {
+				st.note = st.t("BBS_FORUM_UNKNOWN")
+
+				continue
+			}
+			if !st.confirm(st.tf("BBS_FORUM_CONFIRM_POST", n)) {
+				continue
+			}
+			if err := st.srv.forums.DeletePost(posts[n-1-start].ID); err != nil {
+				log.Println("bbs: forums:", err)
+				st.note = st.t("BBS_FORUM_ERROR")
+			}
+		default:
+			st.note = st.t("BBS_FORUM_UNKNOWN")
+		}
+	}
+}
+
+// canPost tells a caller who may not post why, and reports whether they may.
+func (st *session) canPost() bool {
+	switch {
+	case st.anon:
+		st.note = st.t("BBS_FORUM_NEED_KEY")
+	case st.srv.cstore != nil && st.srv.cstore.Banned(st.fp):
+		st.note = st.t("BBS_BANNED")
+	default:
+		return true
+	}
+
+	return false
+}
+
+// newTopic asks for a title and a first post, and starts a topic.
+func (st *session) newTopic(a forum.Area) {
+	if !st.canPost() {
+		return
+	}
+	opt := st.srv.forums.Options()
+	st.write([]string{""})
+	title := strings.TrimSpace(st.readLineMax(st.t("BBS_FORUM_TITLE"), opt.MaxTitle))
+	if title == "" {
+		return
+	}
+	body, err := st.readMultiline(st.tf("BBS_FORUM_WRITE", opt.MaxBody))
+	if err != nil {
+		st.note = st.pasteError(err)
+
+		return
+	}
+	if _, _, err := st.srv.forums.NewTopic(a.ID, st.fp, st.profile.Nick, title, body); err != nil {
+		st.note = st.postError(err)
+
+		return
+	}
+	st.note = st.t("BBS_FORUM_POSTED")
+}
+
+// reply asks for a post and adds it to the topic. It reports success.
+func (st *session) reply(t forum.Topic) bool {
+	if !st.canPost() {
+		return false
+	}
+	st.write([]string{""})
+	body, err := st.readMultiline(st.tf("BBS_FORUM_WRITE", st.srv.forums.Options().MaxBody))
+	if err != nil {
+		st.note = st.pasteError(err)
+
+		return false
+	}
+	if _, err := st.srv.forums.Reply(t.ID, st.fp, st.profile.Nick, body, 0); err != nil {
+		st.note = st.postError(err)
+
+		return false
+	}
+	st.note = st.t("BBS_FORUM_POSTED")
+
+	return true
+}
+
+func (st *session) pasteError(err error) string {
+	if errors.Is(err, errPasteTooLarge) {
+		return st.t("BBS_FORUM_TOO_LONG")
+	}
+
+	return st.t("BBS_FORUM_CANCELLED")
+}
+
+// postError says, in the caller's language, why a post was refused.
+func (st *session) postError(err error) string {
+	var cd *forum.CooldownError
+	switch {
+	case errors.As(err, &cd):
+		return st.tf("BBS_FORUM_COOLDOWN", cd.Wait.Round(time.Second))
+	case errors.Is(err, forum.ErrEmpty):
+		return st.t("BBS_FORUM_CANCELLED")
+	case errors.Is(err, forum.ErrLocked):
+		return st.t("BBS_FORUM_LOCKED")
+	case errors.Is(err, forum.ErrReadOnly):
+		return st.t("BBS_FORUM_READONLY")
+	case errors.Is(err, forum.ErrNotFound):
+		return st.t("BBS_FORUM_GONE")
+	case errors.Is(err, community.ErrBanned):
+		return st.t("BBS_BANNED")
+	}
+	log.Println("bbs: forums:", err)
+
+	return st.t("BBS_FORUM_ERROR")
+}
@@ -56,6 +56,7 @@ for _, it := range st.srv.tools.Items() { 		lines = append(lines, st.menuEntry(string(it.Key), st.t("TOOL_"+strings.ToUpper(it.ID)), st.t("TOOL_"+strings.ToUpper(it.ID)+"_SUM")))
 	}
 	lines = append(lines,
+		st.menuEntry("f", st.t("BBS_FORUMS"), st.t("BBS_FORUMS_SUM")),
 		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")),
@@ -19,6 +19,7 @@ "github.com/runawaydevil/hammer/internal/community" 	"github.com/runawaydevil/hammer/internal/dispatch"
 	"github.com/runawaydevil/hammer/internal/domain"
 	"github.com/runawaydevil/hammer/internal/door"
+	"github.com/runawaydevil/hammer/internal/forum"
 	"github.com/runawaydevil/hammer/internal/jobs"
 	"github.com/runawaydevil/hammer/internal/localization"
 	"github.com/runawaydevil/hammer/internal/ratelimit"
@@ -59,6 +60,7 @@ presence     *community.Presence 	wall         *community.Wall
 	chat         *community.Hub
 	cstore       *community.Store // bans, chat log, last callers
+	forums       *forum.Store
 
 	games     *gamesSettings // nil when games are off or the door is missing
 	gameSlots *door.Slots    // nodes, one per person playing
@@ -121,6 +123,9 @@ 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)
+	if s.forums, err = openForums(store.SQL(), cfg.Forums); err != nil {
+		return nil, fmt.Errorf("forums: %w", err)
+	}
 
 	s.setGames(resolveGames(cfg.Games))
 
@@ -169,6 +169,8 @@ case 'c', 'C': 			st.chatRoom()
 		case 'l', 'L':
 			st.lastCallersScreen()
+		case 'f', 'F':
+			st.forumsMenu()
 		case 'g', 'G':
 			if st.gamesOn() {
 				st.gamesMenu()
@@ -146,3 +146,84 @@ s = strings.ReplaceAll(s, "\r\n", "\n") 
 	return strings.ReplaceAll(s, "\n", "\r\n")
 }
+
+// Wrap breaks plain text (no escape sequences) into lines of at most w
+// terminal cells, at spaces where it can and inside a word when a word is
+// longer than a line. Existing line breaks are kept, and so is the
+// indentation at the start of each line.
+func Wrap(s string, w int) []string {
+	if w < 1 {
+		w = 1
+	}
+
+	var out []string
+	for _, para := range strings.Split(s, "\n") {
+		if para == "" {
+			out = append(out, "")
+
+			continue
+		}
+		indent := para[:len(para)-len(strings.TrimLeft(para, " "))]
+		if VisibleWidth(indent) >= w {
+			indent = ""
+		}
+
+		line := indent
+		lineW := VisibleWidth(indent)
+		fresh := true // nothing but the indent on the line yet
+		for _, word := range strings.Fields(para) {
+			for word != "" {
+				ww := VisibleWidth(word)
+				gap := 1
+				if fresh {
+					gap = 0
+				}
+				if lineW+gap+ww <= w {
+					if !fresh {
+						line += " "
+					}
+					line += word
+					lineW += gap + ww
+					fresh = false
+					word = ""
+
+					continue
+				}
+				if !fresh {
+					out = append(out, line)
+					line, lineW, fresh = indent, VisibleWidth(indent), true
+
+					continue
+				}
+				// A word longer than a whole line: cut it.
+				room := w - lineW
+				head, rest := cutCells(word, room)
+				out = append(out, line+head)
+				line, lineW, fresh = indent, VisibleWidth(indent), true
+				word = rest
+			}
+		}
+		switch {
+		case !fresh:
+			out = append(out, line)
+		case strings.TrimSpace(para) == "":
+			out = append(out, "")
+		}
+	}
+
+	return out
+}
+
+// cutCells splits s after at most n cells, taking at least one character.
+func cutCells(s string, n int) (string, string) {
+	width := 0
+	for i, r := range s {
+		rw := VisibleWidth(string(r))
+		if width+rw > n && i > 0 {
+			return s[:i], s[i:]
+		}
+		width += rw
+	}
+
+	return s, ""
+}
@@ -1,6 +1,9 @@ package ui
 
-import "testing"
+import (
+	"strings"
+	"testing"
+)
 
 func TestBoxLinesAreRectangular(t *testing.T) {
 	lines := BoxLines([]string{"short", "a much longer line", "mid"}, 0)
@@ -67,3 +70,30 @@ t.Errorf("line %d = %q, want %q", i, got[i], want[i]) 		}
 	}
 }
+
+func TestWrap(t *testing.T) {
+	cases := []struct {
+		in   string
+		w    int
+		want []string
+	}{
+		{"the quick brown fox", 9, []string{"the quick", "brown fox"}},
+		{"one\n\ntwo", 10, []string{"one", "", "two"}},
+		{"  indented text here", 10, []string{"  indented", "  text", "  here"}},
+		{"abcdefghij", 4, []string{"abcd", "efgh", "ij"}},
+		{"ação é bom", 6, []string{"ação é", "bom"}},
+		{"日本語の文章", 6, []string{"日本語", "の文章"}},
+		{"   ", 5, []string{""}},
+	}
+	for _, c := range cases {
+		got := Wrap(c.in, c.w)
+		if strings.Join(got, "|") != strings.Join(c.want, "|") {
+			t.Errorf("Wrap(%q, %d) = %q, want %q", c.in, c.w, got, c.want)
+		}
+		for _, l := range got {
+			if CellWidth(l) > c.w {
+				t.Errorf("Wrap(%q, %d): %q is wider than %d", c.in, c.w, l, c.w)
+			}
+		}
+	}
+}
@@ -247,5 +247,35 @@ "BBS_TITLE_CALLERS": "LAST CALLERS",   "BBS_CALLERS_EMPTY": "(nobody yet)",
   "BBS_NOTICES": "arrivals",
   "BBS_NOTICE_ARRIVED": "→ %s arrived",
-  "BBS_NOTICE_LEFT": "← %s left"
+  "BBS_NOTICE_LEFT": "← %s left",
+  "BBS_FORUMS": "forums",
+  "BBS_FORUMS_SUM": "message areas",
+  "BBS_TITLE_FORUMS": "FORUMS",
+  "BBS_FORUM_TOPICS": "%d topics",
+  "BBS_FORUM_NEW": "%d new",
+  "BBS_FORUM_NO_TOPICS": "(no topics yet — start one with t)",
+  "BBS_FORUM_PAGE": "page %d of %d · %d topics",
+  "BBS_FORUM_READ": "read",
+  "BBS_FORUM_NEW_TOPIC": "new topic",
+  "BBS_FORUM_PAGES": "pages",
+  "BBS_FORUM_MOD": "pin/lock/delete",
+  "BBS_FORUM_REPLY": "reply",
+  "BBS_FORUM_MORE": "next posts",
+  "BBS_FORUM_EARLIER": "earlier posts",
+  "BBS_FORUM_POSTS_RANGE": "posts %d–%d of %d",
+  "BBS_FORUM_LOCKED": "This topic is locked",
+  "BBS_FORUM_READONLY": "Only moderators post in this area",
+  "BBS_FORUM_GONE": "That topic is gone",
+  "BBS_FORUM_UNKNOWN": "unknown option — type a number, or b to go back",
+  "BBS_FORUM_ERROR": "The forums are unavailable right now",
+  "BBS_FORUM_NEED_KEY": "Posting needs an SSH key — keyless visitors can only read",
+  "BBS_FORUM_TITLE": "title: ",
+  "BBS_FORUM_WRITE": "write your post (up to %d bytes)",
+  "BBS_FORUM_POSTED": "Posted",
+  "BBS_FORUM_CANCELLED": "Nothing posted",
+  "BBS_FORUM_TOO_LONG": "That post is too long",
+  "BBS_FORUM_COOLDOWN": "You can post again in %s",
+  "BBS_FORUM_DONE": "Done",
+  "BBS_FORUM_CONFIRM_DELETE": "Delete the topic \"%s\" and all its posts?",
+  "BBS_FORUM_CONFIRM_POST": "Delete post #%d?"
 }
@@ -247,5 +247,35 @@ "BBS_TITLE_CALLERS": "ÚLTIMOS VISITANTES",   "BBS_CALLERS_EMPTY": "(ninguém ainda)",
   "BBS_NOTICES": "avisos de entrada",
   "BBS_NOTICE_ARRIVED": "→ %s chegou",
-  "BBS_NOTICE_LEFT": "← %s saiu"
+  "BBS_NOTICE_LEFT": "← %s saiu",
+  "BBS_FORUMS": "fóruns",
+  "BBS_FORUMS_SUM": "áreas de mensagens",
+  "BBS_TITLE_FORUMS": "FÓRUNS",
+  "BBS_FORUM_TOPICS": "%d tópicos",
+  "BBS_FORUM_NEW": "%d novas",
+  "BBS_FORUM_NO_TOPICS": "(nenhum tópico ainda — comece um com t)",
+  "BBS_FORUM_PAGE": "página %d de %d · %d tópicos",
+  "BBS_FORUM_READ": "ler",
+  "BBS_FORUM_NEW_TOPIC": "novo tópico",
+  "BBS_FORUM_PAGES": "páginas",
+  "BBS_FORUM_MOD": "fixar/trancar/apagar",
+  "BBS_FORUM_REPLY": "responder",
+  "BBS_FORUM_MORE": "próximos posts",
+  "BBS_FORUM_EARLIER": "posts anteriores",
+  "BBS_FORUM_POSTS_RANGE": "posts %d–%d de %d",
+  "BBS_FORUM_LOCKED": "Este tópico está trancado",
+  "BBS_FORUM_READONLY": "Só moderadores postam nesta área",
+  "BBS_FORUM_GONE": "Esse tópico não existe mais",
+  "BBS_FORUM_UNKNOWN": "opção desconhecida — digite um número, ou b para voltar",
+  "BBS_FORUM_ERROR": "Os fóruns estão indisponíveis agora",
+  "BBS_FORUM_NEED_KEY": "Postar exige uma chave SSH — sem chave você só lê",
+  "BBS_FORUM_TITLE": "título: ",
+  "BBS_FORUM_WRITE": "escreva seu post (até %d bytes)",
+  "BBS_FORUM_POSTED": "Postado",
+  "BBS_FORUM_CANCELLED": "Nada foi postado",
+  "BBS_FORUM_TOO_LONG": "Esse post é longo demais",
+  "BBS_FORUM_COOLDOWN": "Você pode postar de novo em %s",
+  "BBS_FORUM_DONE": "Feito",
+  "BBS_FORUM_CONFIRM_DELETE": "Apagar o tópico \"%s\" e todos os posts?",
+  "BBS_FORUM_CONFIRM_POST": "Apagar o post #%d?"
 }