feat: add assistant bot with LLM integration and configuration
- Implemented the assistant bot with basic command handling and LLM routing. - Created configuration file for the assistant bot with personality, behavior, and LLM settings. - Added system prompt for the assistant bot to define its capabilities and limitations. - Developed registration script for creating Matrix bot users via Synapse admin API. - Introduced common development scripts for agent management (start, stop, list, logs). - Scaffolded new agent creation script to streamline the addition of new agents. - Implemented agent removal script to disable agents without deleting data.
This commit is contained in:
+352
-36
@@ -1,24 +1,55 @@
|
||||
// Command agentctl is a CLI for inspecting and managing agents.
|
||||
// Command agentctl manages Matrix agents: list, start, stop, remove.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// agentctl list # all agents with their status
|
||||
// agentctl start # start all enabled agents
|
||||
// agentctl start assistant-bot # start a specific agent
|
||||
// agentctl stop # stop all running agents
|
||||
// agentctl stop assistant-bot # stop a specific agent
|
||||
// agentctl remove assistant-bot # disable agent (keeps data)
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
runDir = "run" // PID + log files
|
||||
agentsGlob = "agents/*/config.yaml"
|
||||
)
|
||||
|
||||
// ── entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
var binPath string
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "agentctl",
|
||||
Short: "Manage and inspect agents",
|
||||
Short: "Manage Matrix agents",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return os.MkdirAll(runDir, 0o755)
|
||||
},
|
||||
}
|
||||
|
||||
root.PersistentFlags().StringVar(&binPath, "bin", "",
|
||||
"Launcher binary path. Defaults to ./bin/launcher, falls back to 'go run ./cmd/launcher'")
|
||||
|
||||
root.AddCommand(
|
||||
listCmd(),
|
||||
validateCmd(),
|
||||
startCmd(&binPath),
|
||||
stopCmd(),
|
||||
removeCmd(),
|
||||
)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
@@ -26,29 +57,31 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── list ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func listCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list [config.yaml...]",
|
||||
Short: "List agents from config files",
|
||||
Use: "list",
|
||||
Short: "List all agents and their current status",
|
||||
Aliases: []string{"ls"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("provide at least one config file")
|
||||
agents, err := scanAgents()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range args {
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)
|
||||
continue
|
||||
}
|
||||
enabled := "enabled"
|
||||
if !cfg.Agent.Enabled {
|
||||
enabled = "disabled"
|
||||
}
|
||||
fmt.Printf("%-20s %-10s %-10s %s\n",
|
||||
cfg.Agent.ID,
|
||||
cfg.Agent.Version,
|
||||
enabled,
|
||||
cfg.Agent.Description,
|
||||
if len(agents) == 0 {
|
||||
fmt.Println("No agents found under agents/*/config.yaml")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%-20s %-12s %-8s %s\n", "ID", "STATUS", "VERSION", "DESCRIPTION")
|
||||
fmt.Println(strings.Repeat("─", 72))
|
||||
for _, a := range agents {
|
||||
fmt.Printf("%-20s %-12s %-8s %s\n",
|
||||
a.ID,
|
||||
statusLabel(a),
|
||||
a.Version,
|
||||
truncate(a.Desc, 36),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
@@ -56,25 +89,308 @@ func listCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func validateCmd() *cobra.Command {
|
||||
// ── start ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func startCmd(binPath *string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "validate [config.yaml...]",
|
||||
Short: "Validate agent config files",
|
||||
Use: "start [agent-id...]",
|
||||
Short: "Start one or all enabled agents",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
allOK := true
|
||||
for _, path := range args {
|
||||
_, err := config.Load(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", path, err)
|
||||
allOK = false
|
||||
} else {
|
||||
fmt.Printf("OK %s\n", path)
|
||||
}
|
||||
agents, err := scanAgents()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allOK {
|
||||
os.Exit(1)
|
||||
|
||||
targets := filterTargets(agents, args)
|
||||
if len(targets) == 0 {
|
||||
return fmt.Errorf("no matching agents found")
|
||||
}
|
||||
|
||||
bin := resolvedBin(*binPath)
|
||||
started := 0
|
||||
|
||||
for _, a := range targets {
|
||||
if !a.Enabled {
|
||||
fmt.Printf("skip %-20s (disabled in config)\n", a.ID)
|
||||
continue
|
||||
}
|
||||
if isRunning(a.ID) {
|
||||
fmt.Printf("skip %-20s (already running, PID %d)\n", a.ID, readPID(a.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
if err := startAgent(a, bin); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fail %-20s %v\n", a.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("start %-20s PID %d log → %s\n",
|
||||
a.ID, readPID(a.ID), logPath(a.ID))
|
||||
started++
|
||||
}
|
||||
|
||||
if started == 0 {
|
||||
fmt.Println("Nothing started.")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── stop ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func stopCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop [agent-id...]",
|
||||
Short: "Stop one or all running agents",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
agents, err := scanAgents()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targets := filterTargets(agents, args)
|
||||
if len(targets) == 0 {
|
||||
return fmt.Errorf("no matching agents found")
|
||||
}
|
||||
|
||||
stopped := 0
|
||||
for _, a := range targets {
|
||||
pid := readPID(a.ID)
|
||||
if pid == 0 || !isRunning(a.ID) {
|
||||
fmt.Printf("skip %-20s (not running)\n", a.ID)
|
||||
continue
|
||||
}
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fail %-20s kill: %v\n", a.ID, err)
|
||||
continue
|
||||
}
|
||||
removePIDFile(a.ID)
|
||||
fmt.Printf("stop %-20s sent SIGTERM to PID %d\n", a.ID, pid)
|
||||
stopped++
|
||||
}
|
||||
|
||||
if stopped == 0 {
|
||||
fmt.Println("Nothing stopped.")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── remove ────────────────────────────────────────────────────────────────
|
||||
|
||||
func removeCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <agent-id>",
|
||||
Short: "Disable an agent (sets enabled: false). Does not delete data.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := args[0]
|
||||
|
||||
agents, err := scanAgents()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var target *agentInfo
|
||||
for i := range agents {
|
||||
if agents[i].ID == id {
|
||||
target = &agents[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return fmt.Errorf("agent %q not found", id)
|
||||
}
|
||||
|
||||
// Stop if running
|
||||
if isRunning(id) {
|
||||
pid := readPID(id)
|
||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||
removePIDFile(id)
|
||||
fmt.Printf("stop %-20s sent SIGTERM to PID %d\n", id, pid)
|
||||
}
|
||||
|
||||
// Disable in config (preserves comments)
|
||||
if err := setEnabled(target.ConfigPath, false); err != nil {
|
||||
return fmt.Errorf("update config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("ok %-20s marked as disabled in %s\n", id, target.ConfigPath)
|
||||
fmt.Printf(" Data preserved at agents/%s/data/\n", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── agent scanning ────────────────────────────────────────────────────────
|
||||
|
||||
type agentInfo struct {
|
||||
ID string
|
||||
Version string
|
||||
Enabled bool
|
||||
Desc string
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
func scanAgents() ([]agentInfo, error) {
|
||||
matches, err := filepath.Glob(agentsGlob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var agents []agentInfo
|
||||
for _, path := range matches {
|
||||
// Use LoadMeta so list works even when env vars aren't set.
|
||||
cfg, err := config.LoadMeta(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warn skipping %s: %v\n", path, err)
|
||||
continue
|
||||
}
|
||||
agents = append(agents, agentInfo{
|
||||
ID: cfg.Agent.ID,
|
||||
Version: cfg.Agent.Version,
|
||||
Enabled: cfg.Agent.Enabled,
|
||||
Desc: cfg.Agent.Description,
|
||||
ConfigPath: path,
|
||||
})
|
||||
}
|
||||
return agents, nil
|
||||
}
|
||||
|
||||
func filterTargets(agents []agentInfo, ids []string) []agentInfo {
|
||||
if len(ids) == 0 {
|
||||
return agents // no filter → all
|
||||
}
|
||||
set := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
set[id] = true
|
||||
}
|
||||
var out []agentInfo
|
||||
for _, a := range agents {
|
||||
if set[a.ID] {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── process management ────────────────────────────────────────────────────
|
||||
|
||||
func startAgent(a agentInfo, bin string) error {
|
||||
logFile, err := os.OpenFile(logPath(a.ID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log: %w", err)
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if strings.HasPrefix(bin, "go run") {
|
||||
// dev mode: go run ./cmd/launcher -c <config>
|
||||
cmd = exec.Command("go", "run", "./cmd/launcher", "-c", a.ConfigPath)
|
||||
} else {
|
||||
cmd = exec.Command(bin, "-c", a.ConfigPath)
|
||||
}
|
||||
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
// Detach from the parent process group so it keeps running after agentctl exits
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("exec: %w", err)
|
||||
}
|
||||
|
||||
// Write PID file — the subprocess owns its lifecycle now
|
||||
if err := os.WriteFile(pidPath(a.ID), []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil {
|
||||
return fmt.Errorf("write PID: %w", err)
|
||||
}
|
||||
|
||||
// Detach: don't wait for the process
|
||||
go func() { _ = cmd.Wait() }()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isRunning(id string) bool {
|
||||
pid := readPID(id)
|
||||
if pid == 0 {
|
||||
return false
|
||||
}
|
||||
err := syscall.Kill(pid, 0) // signal 0 checks existence without killing
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readPID(id string) int {
|
||||
raw, err := os.ReadFile(pidPath(id))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
pid, _ := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
return pid
|
||||
}
|
||||
|
||||
func removePIDFile(id string) {
|
||||
_ = os.Remove(pidPath(id))
|
||||
}
|
||||
|
||||
func pidPath(id string) string { return filepath.Join(runDir, id+".pid") }
|
||||
func logPath(id string) string { return filepath.Join(runDir, id+".log") }
|
||||
|
||||
// ── config editing ────────────────────────────────────────────────────────
|
||||
|
||||
// setEnabled flips `enabled: true/false` in the agent section of the YAML.
|
||||
// Uses text replacement to preserve all comments.
|
||||
func setEnabled(configPath string, enabled bool) error {
|
||||
raw, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current := "enabled: true"
|
||||
replacement := "enabled: false"
|
||||
if enabled {
|
||||
current = "enabled: false"
|
||||
replacement = "enabled: true"
|
||||
}
|
||||
|
||||
updated := strings.Replace(string(raw), current, replacement, 1)
|
||||
if updated == string(raw) {
|
||||
return nil // already in the desired state
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, []byte(updated), 0o644)
|
||||
}
|
||||
|
||||
// ── display helpers ───────────────────────────────────────────────────────
|
||||
|
||||
func statusLabel(a agentInfo) string {
|
||||
switch {
|
||||
case !a.Enabled:
|
||||
return "disabled"
|
||||
case isRunning(a.ID):
|
||||
return "● running"
|
||||
default:
|
||||
return "○ stopped"
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
|
||||
// resolvedBin returns the launcher binary path to use.
|
||||
// Priority: --bin flag > ./bin/launcher (if exists) > go run fallback.
|
||||
func resolvedBin(flagVal string) string {
|
||||
if flagVal != "" {
|
||||
return flagVal
|
||||
}
|
||||
if _, err := os.Stat("bin/launcher"); err == nil {
|
||||
return "bin/launcher"
|
||||
}
|
||||
return "go run ./cmd/launcher"
|
||||
}
|
||||
|
||||
+64
-38
@@ -1,4 +1,9 @@
|
||||
// Command launcher starts one or more agents from their config files.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/launcher # auto-discovers agents/*/config.yaml
|
||||
// go run ./cmd/launcher -c agents/assistant/config.yaml
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -13,30 +18,49 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/enmanuel/agents/agents"
|
||||
assistantagent "github.com/enmanuel/agents/agents/assistant"
|
||||
devopsagent "github.com/enmanuel/agents/agents/devops"
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
"github.com/enmanuel/agents/pkg/decision"
|
||||
)
|
||||
|
||||
// rulesRegistry maps agent IDs to their rule factories.
|
||||
// Add a new entry here when you create a new agent package.
|
||||
var rulesRegistry = map[string]func() []decision.Rule{
|
||||
"assistant-bot": assistantagent.Rules,
|
||||
"devops-bot": devopsagent.Rules,
|
||||
}
|
||||
|
||||
func main() {
|
||||
var configPaths []string
|
||||
var logLevel string
|
||||
var (
|
||||
configPaths []string
|
||||
logLevel string
|
||||
)
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "launcher",
|
||||
Short: "Start Matrix agents from config files",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
level := slog.LevelInfo
|
||||
if logLevel == "debug" {
|
||||
level = slog.LevelDebug
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(configPaths) == 0 {
|
||||
matches, _ := filepath.Glob("agents/*/config.yaml")
|
||||
configPaths = matches
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := newLogger(logLevel)
|
||||
|
||||
if len(configPaths) == 0 {
|
||||
logger.Warn("no agent configs found — nothing to start")
|
||||
return nil
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, path := range configPaths {
|
||||
path := path // capture
|
||||
path := path
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
logger.Error("failed to load config", "path", path, "err", err)
|
||||
@@ -47,10 +71,10 @@ func main() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Load agent-specific rules (extend here with your own rule builders)
|
||||
rules := loadRulesForAgent(cfg)
|
||||
rules := rulesFor(cfg.Agent.ID, logger)
|
||||
agentLogger := logger.With("agent", cfg.Agent.ID)
|
||||
|
||||
agent, err := agents.New(cfg, rules, logger.With("agent", cfg.Agent.ID))
|
||||
a, err := agents.New(cfg, rules, agentLogger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", err)
|
||||
continue
|
||||
@@ -59,47 +83,49 @@ func main() {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := agent.Run(ctx); err != nil {
|
||||
logger.Error("agent stopped", "id", cfg.Agent.ID, "err", err)
|
||||
agentLogger.Info("agent running")
|
||||
if err := a.Run(ctx); err != nil {
|
||||
agentLogger.Error("agent stopped with error", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
logger.Info("all agents stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
root.Flags().StringSliceVarP(&configPaths, "config", "c", nil, "Agent config files (comma-separated or repeated flag)")
|
||||
root.Flags().StringVar(&logLevel, "log-level", "info", "Log level: debug|info|warn|error")
|
||||
|
||||
// Default: discover all config.yaml files under agents/
|
||||
root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
|
||||
if len(configPaths) == 0 {
|
||||
matches, _ := filepath.Glob("agents/*/config.yaml")
|
||||
configPaths = matches
|
||||
}
|
||||
return nil
|
||||
}
|
||||
root.Flags().StringSliceVarP(&configPaths, "config", "c", nil,
|
||||
"Agent config file(s). If omitted, discovers all agents/*/config.yaml")
|
||||
root.Flags().StringVar(&logLevel, "log-level", "info",
|
||||
"Log level: debug | info | warn | error")
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// loadRulesForAgent returns the decision rules for a given agent config.
|
||||
// Extend this function (or use a registry) to wire up agent-specific rules.
|
||||
func loadRulesForAgent(cfg *config.AgentConfig) []decision.Rule {
|
||||
return []decision.Rule{
|
||||
{
|
||||
Name: "help",
|
||||
Match: decision.MatchCommand("help"),
|
||||
Actions: []decision.Action{{
|
||||
Kind: decision.ActionKindReply,
|
||||
Reply: &decision.ReplyAction{
|
||||
Content: "I'm " + cfg.Agent.Name + ". " + cfg.Agent.Description,
|
||||
},
|
||||
}},
|
||||
},
|
||||
func rulesFor(agentID string, logger *slog.Logger) []decision.Rule {
|
||||
factory, ok := rulesRegistry[agentID]
|
||||
if !ok {
|
||||
logger.Warn("no rules registered for agent, using empty ruleset", "id", agentID)
|
||||
return nil
|
||||
}
|
||||
return factory()
|
||||
}
|
||||
|
||||
func newLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// Command register creates a Matrix bot user via the Synapse admin API
|
||||
// and outputs the access token to store in .env.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// MATRIX_ADMIN_TOKEN=syt_... go run ./cmd/register \
|
||||
// --homeserver https://matrix-af2f3d.organic-machine.com \
|
||||
// --username assistant-bot \
|
||||
// --displayname "Assistant Bot" \
|
||||
// --env-var MATRIX_TOKEN_ASSISTANT
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
homeserver string
|
||||
username string
|
||||
displayname string
|
||||
envVar string
|
||||
password string
|
||||
)
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "register",
|
||||
Short: "Register a Matrix bot user via Synapse admin API",
|
||||
Long: `Creates a bot user on your Synapse homeserver and prints its access token.
|
||||
|
||||
Requires MATRIX_ADMIN_TOKEN env var with an admin user's access token.
|
||||
|
||||
Example:
|
||||
MATRIX_ADMIN_TOKEN=syt_... go run ./cmd/register \
|
||||
--homeserver https://matrix.example.com \
|
||||
--username my-bot \
|
||||
--displayname "My Bot" \
|
||||
--env-var MATRIX_TOKEN_MY_BOT`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
adminToken := os.Getenv("MATRIX_ADMIN_TOKEN")
|
||||
if adminToken == "" {
|
||||
return fmt.Errorf("MATRIX_ADMIN_TOKEN env var is not set")
|
||||
}
|
||||
|
||||
// Strip trailing slash
|
||||
homeserver = strings.TrimRight(homeserver, "/")
|
||||
|
||||
// Extract server name from homeserver URL
|
||||
serverName := homeserver
|
||||
serverName = strings.TrimPrefix(serverName, "https://")
|
||||
serverName = strings.TrimPrefix(serverName, "http://")
|
||||
|
||||
userID := fmt.Sprintf("@%s:%s", username, serverName)
|
||||
|
||||
fmt.Printf("→ Registering user %s on %s\n", userID, homeserver)
|
||||
|
||||
// Generate password if not provided
|
||||
if password == "" {
|
||||
password = generatePassword()
|
||||
}
|
||||
|
||||
// Step 1: Create/update user via admin API
|
||||
if err := createUser(homeserver, adminToken, userID, displayname, password); err != nil {
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ User %s created/updated\n", userID)
|
||||
|
||||
// Step 2: Login as the bot to get an access token
|
||||
token, deviceID, err := loginAs(homeserver, username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login as bot: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ Logged in, device ID: %s\n", deviceID)
|
||||
|
||||
// Step 3: Print results
|
||||
fmt.Println("\n─── Add to your .env ───────────────────────────────")
|
||||
fmt.Printf("%s=%s\n", envVar, token)
|
||||
fmt.Printf("MATRIX_HOMESERVER=%s\n", homeserver)
|
||||
fmt.Printf("MATRIX_SERVER_NAME=%s\n", serverName)
|
||||
fmt.Println("────────────────────────────────────────────────────")
|
||||
fmt.Printf("\nUser ID: %s\n", userID)
|
||||
fmt.Printf("Device ID: %s\n", deviceID)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
root.Flags().StringVar(&homeserver, "homeserver", "", "Matrix homeserver URL (required)")
|
||||
root.Flags().StringVar(&username, "username", "", "Bot username, without @ or server (required)")
|
||||
root.Flags().StringVar(&displayname, "displayname", "", "Bot display name shown in Matrix")
|
||||
root.Flags().StringVar(&envVar, "env-var", "MATRIX_TOKEN_BOT", "Name of the env var to output")
|
||||
root.Flags().StringVar(&password, "password", "", "Bot password (auto-generated if empty)")
|
||||
_ = root.MarkFlagRequired("homeserver")
|
||||
_ = root.MarkFlagRequired("username")
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// createUser calls PUT /_synapse/admin/v2/users/@user:server
|
||||
func createUser(homeserver, adminToken, userID, displayname, password string) error {
|
||||
body := map[string]any{
|
||||
"password": password,
|
||||
"admin": false,
|
||||
"deactivated": false,
|
||||
}
|
||||
if displayname != "" {
|
||||
body["displayname"] = displayname
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(body)
|
||||
url := fmt.Sprintf("%s/_synapse/admin/v2/users/%s", homeserver, userID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP PUT %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return fmt.Errorf("admin API returned %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginAs calls POST /_matrix/client/v3/login with the bot credentials.
|
||||
func loginAs(homeserver, username, password string) (token, deviceID string, err error) {
|
||||
body := map[string]any{
|
||||
"type": "m.login.password",
|
||||
"identifier": map[string]string{
|
||||
"type": "m.id.user",
|
||||
"user": username,
|
||||
},
|
||||
"password": password,
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
|
||||
url := homeserver + "/_matrix/client/v3/login"
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("HTTP POST %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("login returned %d: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
DeviceID string `json:"device_id"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", "", fmt.Errorf("parse login response: %w", err)
|
||||
}
|
||||
return result.AccessToken, result.DeviceID, nil
|
||||
}
|
||||
|
||||
// generatePassword creates a random-enough password for the bot account.
|
||||
func generatePassword() string {
|
||||
// Simple: use os.ReadFile on /dev/urandom, encode hex
|
||||
f, err := os.Open("/dev/urandom")
|
||||
if err != nil {
|
||||
return "agent-bot-default-please-change"
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, 24)
|
||||
_, _ = io.ReadFull(f, buf)
|
||||
return fmt.Sprintf("%x", buf)
|
||||
}
|
||||
Reference in New Issue
Block a user