vojo/apps/ai-bot/main.go

92 lines
3.2 KiB
Go

// Command ai-bot is a plaintext Matrix bot user (@ai) that answers xAI Grok
// completions in its rooms: @-mentions in group rooms and every message in a
// 1:1. It runs as a Synapse application service — Synapse pushes transactions to
// its HTTP endpoint — and talks the Matrix CS-API over plain HTTP (no Olm/Megolm,
// Vojo rooms are unencrypted by default), calling the xAI OpenAI-compatible Chat
// Completions API. See README.md and docs/plans/grok_bot.md.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
)
func main() {
logger := newLogger()
// `ai-bot generate-registration` writes a fresh registration.yaml with random
// tokens (the mautrix bridge idiom), then exits. Runs BEFORE LoadConfig — the
// tokens don't exist yet. Inputs: BOT_MXID (required), REGISTRATION_PATH
// (default /data/registration.yaml), AS_URL (default http://ai-bot:8009).
if len(os.Args) > 1 && os.Args[1] == "generate-registration" {
mxid := getenv("BOT_MXID", "")
if mxid == "" {
logger.Error("BOT_MXID is required to generate the registration")
os.Exit(1)
}
path := getenv("REGISTRATION_PATH", "/data/registration.yaml")
asURL := getenv("AS_URL", "http://ai-bot:8009")
if err := GenerateRegistration(path, asURL, localpartOf(mxid), serverOf(mxid)); err != nil {
logger.Error("generate-registration failed", "err", err)
os.Exit(1)
}
fmt.Printf("wrote %s\n", path)
fmt.Println("Next: mount this file into Synapse, add it to app_service_config_files,")
fmt.Println("and restart Synapse. The bot reads its tokens from this file (set")
fmt.Println("REGISTRATION_PATH to the same path in the bot's environment).")
return
}
cfg, err := LoadConfig()
if err != nil {
logger.Error("config error", "err", err)
os.Exit(1)
}
// Load the system prompt up front so a missing/unreadable file fails fast
// at startup rather than on the first message.
promptBytes, err := os.ReadFile(cfg.SystemPromptPath)
if err != nil {
logger.Error("cannot read system prompt", "path", cfg.SystemPromptPath, "err", err)
os.Exit(1)
}
cfg.SystemPrompt = string(promptBytes)
// `ai-bot check-config` validates env + prompt + state dir and exits 0.
// Used by the A1 acceptance check ("container starts, reads env") and as a
// cheap operator smoke test without touching the homeserver.
if len(os.Args) > 1 && os.Args[1] == "check-config" {
fmt.Println(cfg.Summary())
fmt.Printf(" SYSTEM_PROMPT = loaded (%d bytes)\n", len(cfg.SystemPrompt))
fmt.Println("config OK")
return
}
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil {
logger.Error("cannot create state dir", "path", cfg.StateDir, "err", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "%s\n", cfg.Summary())
logger.Info("starting Vojo AI bot")
// Cancel on SIGINT/SIGTERM so the transaction server shuts down cleanly.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
bot, err := NewBot(ctx, cfg, logger)
if err != nil {
logger.Error("startup failed", "err", err)
os.Exit(1)
}
defer bot.Close()
if err := bot.Run(ctx); err != nil && ctx.Err() == nil {
logger.Error("appservice server exited", "err", err)
os.Exit(1)
}
logger.Info("shut down cleanly")
}