mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Video calls (PairUX→ASCII), agent@ chat, and finger routes
ssh video-<code>@ joins a PairUX/LiveKit call rendered as truecolor ASCII (▀ half-blocks, 2 pixels per cell); video@ prompts for a code. Codes are minted by PairUX only. Pipeline: VP8 RTP → PLI keyframe requests → ivfwriter remux → ffmpeg decode/scale → RGB24 → ANSI → bubbletea over the SSH PTY. Subscriber-only, no audio in v1. ssh agent@ opens a persisted chat with the operator's agent — AGENTBBS_AGENT_CMD runs per message (stdin→stdout), e.g. `claude -p`. ssh <member>@ with someone else's name prints a classic finger card (.plan, member since, last seen) and disconnects; your own name still enters the hub. cmd/lkpublish: dev publisher for testing (explicit -fps pacing; lksdk IVF replay mispaces from file timebase alone, measured 1fps from a 15fps file; dimensions required or dynacast pauses the track). Verified end-to-end against livekit-server --dev: 128k truecolor cells / 20k distinct colors streamed over a real SSH session; chat round-trip and finger card verified over SSH. Go toolchain pinned to 1.26 via mise.toml (lksdk requirement). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f3b085a08f
commit
9b0f465946
14 changed files with 1397 additions and 46 deletions
|
|
@ -37,6 +37,8 @@ import (
|
|||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/calls"
|
||||
"github.com/profullstack/agentbbs/internal/chat"
|
||||
"github.com/profullstack/agentbbs/internal/hub"
|
||||
"github.com/profullstack/agentbbs/internal/payments"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
|
|
@ -138,11 +140,18 @@ func (a *app) router() wish.Middleware {
|
|||
hubHandler := activeterm.Middleware()(btMw(next))
|
||||
return func(s ssh.Session) {
|
||||
user := strings.ToLower(s.User())
|
||||
code, isVideo := calls.RouteCode(user)
|
||||
switch {
|
||||
case auth.IsJoinName(user):
|
||||
a.handleJoin(s)
|
||||
case auth.IsPodName(user):
|
||||
a.handlePod(s)
|
||||
case isVideo:
|
||||
a.handleVideo(s, code)
|
||||
case user == "agent":
|
||||
a.handleChat(s)
|
||||
case a.handleFinger(s, user):
|
||||
// fingered an existing account that isn't the caller's; done.
|
||||
default:
|
||||
hubHandler(s)
|
||||
}
|
||||
|
|
@ -280,6 +289,80 @@ func (a *app) handlePod(s ssh.Session) {
|
|||
}
|
||||
}
|
||||
|
||||
// handleVideo joins a PairUX call rendered as ASCII (docs/video.md).
|
||||
// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
|
||||
// minted by PairUX — starting a call requires already having one.
|
||||
func (a *app) handleVideo(s ssh.Session, code string) {
|
||||
identity := "ssh-guest"
|
||||
if fp := auth.Fingerprint(s.PublicKey()); fp != "" {
|
||||
if u, found, _ := a.st.UserByFingerprint(fp); found {
|
||||
identity = "ssh-" + u.Name
|
||||
}
|
||||
}
|
||||
sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "video")
|
||||
defer func() { _ = a.st.EndSession(sessID) }()
|
||||
if err := calls.Handle(s, code, identity); err != nil {
|
||||
wish.Println(s, "video: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// handleChat is the agent@ surface: talk to the operator's agent.
|
||||
func (a *app) handleChat(s ssh.Session) {
|
||||
u := auth.User{Name: "guest-" + remoteIP(s), Kind: auth.Guest}
|
||||
if fp := auth.Fingerprint(s.PublicKey()); fp != "" {
|
||||
if su, found, _ := a.st.UserByFingerprint(fp); found {
|
||||
u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID}
|
||||
}
|
||||
}
|
||||
sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "agent")
|
||||
defer func() { _ = a.st.EndSession(sessID) }()
|
||||
if err := chat.Handle(s, a.st, u); err != nil {
|
||||
wish.Println(s, "chat: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// handleFinger prints a classic finger card when someone ssh's to an
|
||||
// existing account name that isn't their own (e.g. ssh anthony@host).
|
||||
// Returns false when the route should fall through to the hub.
|
||||
func (a *app) handleFinger(s ssh.Session, username string) bool {
|
||||
if auth.IsGuestName(username) {
|
||||
return false
|
||||
}
|
||||
u, found, err := a.st.UserByName(username)
|
||||
if err != nil || !found {
|
||||
return false // unclaimed name → hub (claim flow)
|
||||
}
|
||||
if fp := auth.Fingerprint(s.PublicKey()); fp != "" && fp == u.PubKeyFP {
|
||||
return false // it's them → hub
|
||||
}
|
||||
|
||||
lastSeen := "never"
|
||||
if t, ok, _ := a.st.LastSeen(u.ID); ok {
|
||||
lastSeen = t.Local().Format("2006-01-02 15:04 MST")
|
||||
}
|
||||
plan := "no plan."
|
||||
for _, p := range []string{
|
||||
filepath.Join(a.dataDir, "users", u.Name, ".plan"),
|
||||
filepath.Join(a.dataDir, "users", u.Name, "plan.txt"),
|
||||
} {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
plan = strings.TrimSpace(string(b))
|
||||
break
|
||||
}
|
||||
}
|
||||
_, _ = a.st.RecordSession(0, s.User(), remoteIP(s), "finger")
|
||||
wish.Println(s, strings.Join([]string{
|
||||
"",
|
||||
" Login: " + u.Name + " Kind: " + u.Kind,
|
||||
" Member since: " + u.CreatedAt.Format("2006-01-02") + " Last seen: " + lastSeen,
|
||||
" Plan:",
|
||||
" " + strings.ReplaceAll(plan, "\n", "\n "),
|
||||
"",
|
||||
}, "\n"))
|
||||
_ = s.Exit(0)
|
||||
return true
|
||||
}
|
||||
|
||||
func grantPod(st store.Store, args []string) {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: agentbbs grant-pod <username> <months>")
|
||||
|
|
|
|||
68
cmd/lkpublish/main.go
Normal file
68
cmd/lkpublish/main.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Command lkpublish is a dev tool: it publishes a VP8 IVF file into a
|
||||
// LiveKit room so the video-<code>@ SSH route has something to render.
|
||||
//
|
||||
// ffmpeg -f lavfi -i testsrc=size=320x240:rate=15 -t 60 -c:v libvpx test.ivf
|
||||
// go run ./cmd/lkpublish -room test123 -file test.ivf
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
lksdk "github.com/livekit/server-sdk-go/v2"
|
||||
)
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
room := flag.String("room", "test123", "room/call code to publish into")
|
||||
file := flag.String("file", "test.ivf", "VP8 IVF file to stream")
|
||||
fps := flag.Int("fps", 15, "frame rate to pace the file at")
|
||||
flag.Parse()
|
||||
|
||||
url := env("LIVEKIT_URL", "ws://localhost:7880")
|
||||
key := env("LIVEKIT_API_KEY", "devkey")
|
||||
secret := env("LIVEKIT_API_SECRET", "secret")
|
||||
|
||||
r, err := lksdk.ConnectToRoom(url, lksdk.ConnectInfo{
|
||||
APIKey: key,
|
||||
APISecret: secret,
|
||||
RoomName: *room,
|
||||
ParticipantIdentity: "lkpublish",
|
||||
}, &lksdk.RoomCallback{})
|
||||
if err != nil {
|
||||
log.Fatal("connect", "err", err)
|
||||
}
|
||||
defer r.Disconnect()
|
||||
|
||||
// Explicit frame duration: don't trust IVF timebase interpretation.
|
||||
track, err := lksdk.NewLocalFileTrack(*file,
|
||||
lksdk.ReaderTrackWithFrameDuration(time.Second/time.Duration(*fps)))
|
||||
if err != nil {
|
||||
log.Fatal("track", "err", err)
|
||||
}
|
||||
// Width/height/source matter: without dimensions the SFU's dynacast has
|
||||
// no layer to subscribe to and pauses the track entirely.
|
||||
if _, err := r.LocalParticipant.PublishTrack(track, &lksdk.TrackPublicationOptions{
|
||||
Name: "test-pattern",
|
||||
Source: livekit.TrackSource_CAMERA,
|
||||
VideoWidth: 320,
|
||||
VideoHeight: 240,
|
||||
}); err != nil {
|
||||
log.Fatal("publish", "err", err)
|
||||
}
|
||||
log.Info("publishing", "room", *room, "file", *file)
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
<-sig
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue