98 lines
3.4 KiB
Go
98 lines
3.4 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"
|
|
"path/filepath"
|
|
"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")
|
|
}
|
|
|
|
// statePath joins a filename under the configured state directory.
|
|
func (c *Config) statePath(name string) string {
|
|
return filepath.Join(c.StateDir, name)
|
|
}
|