vojo/apps/ai-bot/main.go

91 lines
3.3 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"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
)
func main() {
logger := log.New(os.Stderr, "", log.LstdFlags|log.LUTC)
// `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.Fatalf("BOT_MXID is required to generate the registration")
}
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.Fatalf("generate-registration: %v", err)
}
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.Fatalf("config error: %v", err)
}
// 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.Fatalf("cannot read SYSTEM_PROMPT_PATH (%s): %v", cfg.SystemPromptPath, err)
}
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.Fatalf("cannot create STATE_DIR (%s): %v", cfg.StateDir, err)
}
logger.Printf("starting\n%s", cfg.Summary())
// 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.Fatalf("startup failed: %v", err)
}
defer bot.Close()
if err := bot.Run(ctx); err != nil && ctx.Err() == nil {
logger.Fatalf("appservice server exited with error: %v", err)
}
logger.Printf("shut down cleanly")
}
// statePath joins a filename under the configured state directory.
func (c *Config) statePath(name string) string {
return filepath.Join(c.StateDir, name)
}