agentbbs/internal/news/reader.go
Anthony Ettinger 3b6b9a4a78 feat: members-only Usenet (NNTP) + Forgejo git provisioning + founding-lifetime $99
WIP feature branch: NNTPS news server, per-member Forgejo accounts on email
confirm, and founding-lifetime pricing tier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:26:23 +00:00

114 lines
3 KiB
Go

package news
import (
"fmt"
"io"
"sort"
"strconv"
"strings"
"github.com/dustin/go-nntp"
nntpclient "github.com/dustin/go-nntp/client"
)
// Reader is a thin NNTP client used by the in-BBS `news@` TUI. It dials the
// loopback listener and authenticates as the member, so it exercises the same
// server path (auth, From-stamping, numbering) as external newsreaders.
type Reader struct {
c *nntpclient.Client
user string
}
// Dial connects to the loopback NNTP server at addr and authenticates as user.
// The password is ignored by the server (membership is the credential).
func Dial(addr, user string) (*Reader, error) {
c, err := nntpclient.New("tcp", addr)
if err != nil {
return nil, err
}
if _, err := c.Authenticate(user, "-"); err != nil {
_ = c.Close()
return nil, fmt.Errorf("auth: %w", err)
}
return &Reader{c: c, user: user}, nil
}
// Close ends the NNTP session.
func (r *Reader) Close() error { return r.c.Close() }
// User is the authenticated member name.
func (r *Reader) User() string { return r.user }
// Groups lists the available newsgroups, name-sorted.
func (r *Reader) Groups() ([]nntp.Group, error) {
gs, err := r.c.List("active")
if err != nil {
return nil, err
}
sort.Slice(gs, func(i, j int) bool { return gs[i].Name < gs[j].Name })
return gs, nil
}
// Select makes group current and returns its bounds.
func (r *Reader) Select(name string) (nntp.Group, error) { return r.c.Group(name) }
// Overview is one row of a group's article overview.
type Overview struct {
Num int64
Subject string
From string
Date string
MsgID string
Refs string
}
// Overview fetches the overview rows for [low,high] in the current group.
func (r *Reader) Overview(low, high int64) ([]Overview, error) {
if high < low || high == 0 {
return nil, nil
}
lines, err := r.c.Over(fmt.Sprintf("%d-%d", low, high))
if err != nil {
return nil, err
}
out := make([]Overview, 0, len(lines))
for _, l := range lines {
f := strings.Split(l, "\t")
if len(f) < 6 {
continue
}
num, _ := strconv.ParseInt(f[0], 10, 64)
out = append(out, Overview{
Num: num, Subject: f[1], From: f[2], Date: f[3], MsgID: f[4], Refs: f[5],
})
}
return out, nil
}
// Article returns the full article (headers and body) for a number in the
// current group.
func (r *Reader) Article(num int64) (string, error) {
_, _, rd, err := r.c.Article(strconv.FormatInt(num, 10))
if err != nil {
return "", err
}
b, err := io.ReadAll(rd)
if err != nil {
return "", err
}
return string(b), nil
}
// Post submits an article to group with the given subject/body. References ties
// a reply to its parent (its Message-ID). The server stamps the From header.
func (r *Reader) Post(group, subject, references, body string) error {
var b strings.Builder
fmt.Fprintf(&b, "Newsgroups: %s\r\n", group)
fmt.Fprintf(&b, "Subject: %s\r\n", subject)
if references != "" {
fmt.Fprintf(&b, "References: %s\r\n", references)
}
b.WriteString("\r\n")
b.WriteString(strings.ReplaceAll(body, "\n", "\r\n"))
return r.c.Post(strings.NewReader(b.String()))
}