ScuttleBot

scuttlebot / internal / bots / manager / manager.go
Source Blame History 500 lines
5ac549c… lmata 1 // Package manager starts and stops system bots based on policy configuration.
5ac549c… lmata 2 package manager
5ac549c… lmata 3
5ac549c… lmata 4 import (
5ac549c… lmata 5 "context"
5ac549c… lmata 6 "crypto/rand"
5ac549c… lmata 7 "encoding/hex"
5ac549c… lmata 8 "encoding/json"
5ac549c… lmata 9 "fmt"
5ac549c… lmata 10 "log/slog"
5ac549c… lmata 11 "os"
5ac549c… lmata 12 "path/filepath"
5ac549c… lmata 13 "strings"
5ac549c… lmata 14 "time"
5ac549c… lmata 15
5ac549c… lmata 16 "github.com/conflicthq/scuttlebot/internal/bots/auditbot"
5ac549c… lmata 17 "github.com/conflicthq/scuttlebot/internal/bots/herald"
5ac549c… lmata 18 "github.com/conflicthq/scuttlebot/internal/bots/oracle"
5ac549c… lmata 19 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
5ac549c… lmata 20 "github.com/conflicthq/scuttlebot/internal/bots/scroll"
5ac549c… lmata 21 "github.com/conflicthq/scuttlebot/internal/bots/sentinel"
039edb2… noreply 22 "github.com/conflicthq/scuttlebot/internal/bots/shepherd"
5ac549c… lmata 23 "github.com/conflicthq/scuttlebot/internal/bots/snitch"
5ac549c… lmata 24 "github.com/conflicthq/scuttlebot/internal/bots/steward"
5ac549c… lmata 25 "github.com/conflicthq/scuttlebot/internal/bots/systembot"
5ac549c… lmata 26 "github.com/conflicthq/scuttlebot/internal/bots/warden"
5ac549c… lmata 27 "github.com/conflicthq/scuttlebot/internal/llm"
5ac549c… lmata 28 )
5ac549c… lmata 29
5ac549c… lmata 30 // scribeHistoryAdapter adapts scribe.FileStore to oracle.HistoryFetcher.
5ac549c… lmata 31 type scribeHistoryAdapter struct {
5ac549c… lmata 32 store *scribe.FileStore
5ac549c… lmata 33 }
5ac549c… lmata 34
5ac549c… lmata 35 func (a *scribeHistoryAdapter) Query(channel string, limit int) ([]oracle.HistoryEntry, error) {
5ac549c… lmata 36 entries, err := a.store.Query(channel, limit)
5ac549c… lmata 37 if err != nil {
5ac549c… lmata 38 return nil, err
5ac549c… lmata 39 }
5ac549c… lmata 40 out := make([]oracle.HistoryEntry, len(entries))
5ac549c… lmata 41 for i, e := range entries {
5ac549c… lmata 42 out[i] = oracle.HistoryEntry{
5ac549c… lmata 43 Nick: e.Nick,
5ac549c… lmata 44 MessageType: e.MessageType,
5ac549c… lmata 45 Raw: e.Raw,
5ac549c… lmata 46 }
5ac549c… lmata 47 }
5ac549c… lmata 48 return out, nil
5ac549c… lmata 49 }
5ac549c… lmata 50
5ac549c… lmata 51 // BotSpec mirrors api.BehaviorConfig without importing the api package.
5ac549c… lmata 52 type BotSpec struct {
5ac549c… lmata 53 ID string
5ac549c… lmata 54 Nick string
5ac549c… lmata 55 Enabled bool
5ac549c… lmata 56 JoinAllChannels bool
5ac549c… lmata 57 RequiredChannels []string
5ac549c… lmata 58 Config map[string]any
5ac549c… lmata 59 }
5ac549c… lmata 60
5ac549c… lmata 61 // Provisioner can register and change passwords for IRC accounts.
5ac549c… lmata 62 type Provisioner interface {
5ac549c… lmata 63 RegisterAccount(name, pass string) error
5ac549c… lmata 64 ChangePassword(name, pass string) error
5ac549c… lmata 65 }
5ac549c… lmata 66
5ac549c… lmata 67 // ChannelLister can enumerate active IRC channels.
5ac549c… lmata 68 type ChannelLister interface {
5ac549c… lmata 69 ListChannels() ([]string, error)
5ac549c… lmata 70 }
5ac549c… lmata 71
5ac549c… lmata 72 // bot is the common interface all bots satisfy.
5ac549c… lmata 73 type bot interface {
5ac549c… lmata 74 Start(ctx context.Context) error
5ac549c… lmata 75 }
5ac549c… lmata 76
5ac549c… lmata 77 // Manager starts and stops bots based on BotSpec slices.
5ac549c… lmata 78 type Manager struct {
5ac549c… lmata 79 ircAddr string
5ac549c… lmata 80 dataDir string
5ac549c… lmata 81 prov Provisioner
5ac549c… lmata 82 channels ChannelLister
5ac549c… lmata 83 log *slog.Logger
57db3d6… lmata 84 passwords map[string]string // nick → password, persisted
5ac549c… lmata 85 running map[string]context.CancelFunc
5ac549c… lmata 86 }
5ac549c… lmata 87
5ac549c… lmata 88 // New creates a Manager.
5ac549c… lmata 89 func New(ircAddr, dataDir string, prov Provisioner, channels ChannelLister, log *slog.Logger) *Manager {
5ac549c… lmata 90 m := &Manager{
5ac549c… lmata 91 ircAddr: ircAddr,
5ac549c… lmata 92 dataDir: dataDir,
5ac549c… lmata 93 prov: prov,
5ac549c… lmata 94 channels: channels,
5ac549c… lmata 95 log: log,
5ac549c… lmata 96 passwords: make(map[string]string),
5ac549c… lmata 97 running: make(map[string]context.CancelFunc),
5ac549c… lmata 98 }
5ac549c… lmata 99 _ = m.loadPasswords()
5ac549c… lmata 100 return m
5ac549c… lmata 101 }
5ac549c… lmata 102
5ac549c… lmata 103 // Running returns the nicks of currently running bots.
5ac549c… lmata 104 func (m *Manager) Running() []string {
5ac549c… lmata 105 out := make([]string, 0, len(m.running))
5ac549c… lmata 106 for nick := range m.running {
5ac549c… lmata 107 out = append(out, nick)
5ac549c… lmata 108 }
5ac549c… lmata 109 return out
5ac549c… lmata 110 }
5ac549c… lmata 111
5ac549c… lmata 112 // Sync starts enabled+not-running bots and stops disabled+running bots.
5ac549c… lmata 113 func (m *Manager) Sync(ctx context.Context, specs []BotSpec) {
5ac549c… lmata 114 desired := make(map[string]BotSpec, len(specs))
5ac549c… lmata 115 for _, s := range specs {
5ac549c… lmata 116 desired[s.Nick] = s
5ac549c… lmata 117 }
5ac549c… lmata 118
5ac549c… lmata 119 // Stop bots that are running but should be disabled.
5ac549c… lmata 120 for nick, cancel := range m.running {
5ac549c… lmata 121 spec, ok := desired[nick]
5ac549c… lmata 122 if !ok || !spec.Enabled {
5ac549c… lmata 123 m.log.Info("manager: stopping bot", "nick", nick)
5ac549c… lmata 124 cancel()
5ac549c… lmata 125 delete(m.running, nick)
5ac549c… lmata 126 }
5ac549c… lmata 127 }
5ac549c… lmata 128
5ac549c… lmata 129 // Start bots that are enabled but not running.
5ac549c… lmata 130 for _, spec := range specs {
5ac549c… lmata 131 if !spec.Enabled {
5ac549c… lmata 132 continue
5ac549c… lmata 133 }
5ac549c… lmata 134 if _, running := m.running[spec.Nick]; running {
5ac549c… lmata 135 continue
5ac549c… lmata 136 }
5ac549c… lmata 137
5ac549c… lmata 138 pass, err := m.ensurePassword(spec.Nick)
5ac549c… lmata 139 if err != nil {
5ac549c… lmata 140 m.log.Error("manager: ensure password", "nick", spec.Nick, "err", err)
5ac549c… lmata 141 continue
5ac549c… lmata 142 }
5ac549c… lmata 143
5ac549c… lmata 144 if err := m.ensureAccount(spec.Nick, pass); err != nil {
5ac549c… lmata 145 m.log.Error("manager: ensure account", "nick", spec.Nick, "err", err)
5ac549c… lmata 146 continue
5ac549c… lmata 147 }
5ac549c… lmata 148
5ac549c… lmata 149 channels, err := m.resolveChannels(spec)
5ac549c… lmata 150 if err != nil {
5ac549c… lmata 151 m.log.Warn("manager: list channels failed, using required", "nick", spec.Nick, "err", err)
5ac549c… lmata 152 }
5ac549c… lmata 153
5ac549c… lmata 154 b, err := m.buildBot(spec, pass, channels)
5ac549c… lmata 155 if err != nil {
5ac549c… lmata 156 m.log.Error("manager: build bot", "nick", spec.Nick, "err", err)
5ac549c… lmata 157 continue
5ac549c… lmata 158 }
5ac549c… lmata 159 if b == nil {
5ac549c… lmata 160 continue
5ac549c… lmata 161 }
5ac549c… lmata 162
5ac549c… lmata 163 botCtx, cancel := context.WithCancel(ctx)
5ac549c… lmata 164 m.running[spec.Nick] = cancel
5ac549c… lmata 165
5ac549c… lmata 166 go func(nick string, b bot, ctx context.Context) {
5ac549c… lmata 167 m.log.Info("manager: starting bot", "nick", nick)
5ac549c… lmata 168 if err := b.Start(ctx); err != nil && ctx.Err() == nil {
5ac549c… lmata 169 m.log.Error("manager: bot exited with error", "nick", nick, "err", err)
5ac549c… lmata 170 }
5ac549c… lmata 171 }(spec.Nick, b, botCtx)
5ac549c… lmata 172 }
5ac549c… lmata 173 }
5ac549c… lmata 174
5ac549c… lmata 175 func (m *Manager) resolveChannels(spec BotSpec) ([]string, error) {
5ac549c… lmata 176 if spec.JoinAllChannels {
5ac549c… lmata 177 ch, err := m.channels.ListChannels()
5ac549c… lmata 178 if err != nil {
5ac549c… lmata 179 return spec.RequiredChannels, err
5ac549c… lmata 180 }
5ac549c… lmata 181 return ch, nil
5ac549c… lmata 182 }
5ac549c… lmata 183 return spec.RequiredChannels, nil
5ac549c… lmata 184 }
5ac549c… lmata 185
5ac549c… lmata 186 func (m *Manager) ensurePassword(nick string) (string, error) {
5ac549c… lmata 187 if pass, ok := m.passwords[nick]; ok {
5ac549c… lmata 188 return pass, nil
5ac549c… lmata 189 }
5ac549c… lmata 190 pass, err := genPassword()
5ac549c… lmata 191 if err != nil {
5ac549c… lmata 192 return "", err
5ac549c… lmata 193 }
5ac549c… lmata 194 m.passwords[nick] = pass
5ac549c… lmata 195 if err := m.savePasswords(); err != nil {
5ac549c… lmata 196 return "", err
5ac549c… lmata 197 }
5ac549c… lmata 198 return pass, nil
5ac549c… lmata 199 }
5ac549c… lmata 200
5ac549c… lmata 201 func (m *Manager) ensureAccount(nick, pass string) error {
5ac549c… lmata 202 if err := m.prov.RegisterAccount(nick, pass); err != nil {
5ac549c… lmata 203 if strings.Contains(err.Error(), "ACCOUNT_EXISTS") {
5ac549c… lmata 204 return m.prov.ChangePassword(nick, pass)
5ac549c… lmata 205 }
5ac549c… lmata 206 return err
5ac549c… lmata 207 }
5ac549c… lmata 208 return nil
5ac549c… lmata 209 }
5ac549c… lmata 210
5ac549c… lmata 211 func (m *Manager) buildBot(spec BotSpec, pass string, channels []string) (bot, error) {
5ac549c… lmata 212 cfg := spec.Config
5ac549c… lmata 213 switch spec.ID {
5ac549c… lmata 214 case "scribe":
5ac549c… lmata 215 store := scribe.NewFileStore(scribe.FileStoreConfig{
5ac549c… lmata 216 Dir: cfgStr(cfg, "dir", filepath.Join(m.dataDir, "logs", "scribe")),
5ac549c… lmata 217 Format: cfgStr(cfg, "format", "jsonl"),
5ac549c… lmata 218 Rotation: cfgStr(cfg, "rotation", "none"),
5ac549c… lmata 219 MaxSizeMB: cfgInt(cfg, "max_size_mb", 0),
5ac549c… lmata 220 PerChannel: cfgBool(cfg, "per_channel", false),
5ac549c… lmata 221 MaxAgeDays: cfgInt(cfg, "max_age_days", 0),
5ac549c… lmata 222 })
5ac549c… lmata 223 return scribe.New(m.ircAddr, pass, channels, store, m.log), nil
5ac549c… lmata 224
5ac549c… lmata 225 case "auditbot":
5ac549c… lmata 226 return auditbot.New(m.ircAddr, pass, channels, nil, &auditbot.MemoryStore{}, m.log), nil
5ac549c… lmata 227
5ac549c… lmata 228 case "snitch":
5ac549c… lmata 229 return snitch.New(snitch.Config{
5ac549c… lmata 230 IRCAddr: m.ircAddr,
5ac549c… lmata 231 Nick: spec.Nick,
5ac549c… lmata 232 Password: pass,
5ac549c… lmata 233 AlertChannel: cfgStr(cfg, "alert_channel", ""),
5ac549c… lmata 234 AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")),
5ac549c… lmata 235 FloodMessages: cfgInt(cfg, "flood_messages", 10),
5ac549c… lmata 236 FloodWindow: time.Duration(cfgInt(cfg, "flood_window_sec", 5)) * time.Second,
5ac549c… lmata 237 JoinPartThreshold: cfgInt(cfg, "join_part_threshold", 5),
5ac549c… lmata 238 JoinPartWindow: time.Duration(cfgInt(cfg, "join_part_window_sec", 30)) * time.Second,
3420a83… lmata 239 Channels: channels,
5ac549c… lmata 240 }, m.log), nil
5ac549c… lmata 241
5ac549c… lmata 242 case "warden":
3420a83… lmata 243 return warden.New(m.ircAddr, pass, channels, nil, warden.ChannelConfig{
5ac549c… lmata 244 MessagesPerSecond: cfgFloat(cfg, "messages_per_second", 5),
5ac549c… lmata 245 Burst: cfgInt(cfg, "burst", 10),
5ac549c… lmata 246 }, m.log), nil
5ac549c… lmata 247
5ac549c… lmata 248 case "scroll":
3420a83… lmata 249 return scroll.New(m.ircAddr, pass, channels, &scribe.MemoryStore{}, m.log), nil
5ac549c… lmata 250
5ac549c… lmata 251 case "systembot":
5ac549c… lmata 252 return systembot.New(m.ircAddr, pass, channels, &systembot.MemoryStore{}, m.log), nil
5ac549c… lmata 253
5ac549c… lmata 254 case "herald":
3420a83… lmata 255 return herald.New(m.ircAddr, pass, channels, herald.RouteConfig{
5ac549c… lmata 256 DefaultChannel: cfgStr(cfg, "default_channel", ""),
5ac549c… lmata 257 }, cfgFloat(cfg, "rate_limit", 1), cfgInt(cfg, "burst", 5), m.log), nil
5ac549c… lmata 258
5ac549c… lmata 259 case "oracle":
5ac549c… lmata 260 // Resolve API key — prefer direct api_key, fall back to api_key_env for
5ac549c… lmata 261 // backwards compatibility with existing configs.
5ac549c… lmata 262 apiKey := cfgStr(cfg, "api_key", "")
5ac549c… lmata 263 if apiKey == "" {
5ac549c… lmata 264 apiKeyEnv := cfgStr(cfg, "api_key_env", "")
5ac549c… lmata 265 if apiKeyEnv != "" {
5ac549c… lmata 266 apiKey = os.Getenv(apiKeyEnv)
5ac549c… lmata 267 }
5ac549c… lmata 268 }
5ac549c… lmata 269
5ac549c… lmata 270 llmCfg := llm.BackendConfig{
5ac549c… lmata 271 Backend: cfgStr(cfg, "backend", "openai"),
5ac549c… lmata 272 APIKey: apiKey,
5ac549c… lmata 273 BaseURL: cfgStr(cfg, "base_url", ""),
5ac549c… lmata 274 Model: cfgStr(cfg, "model", ""),
5ac549c… lmata 275 Region: cfgStr(cfg, "region", ""),
5ac549c… lmata 276 AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
5ac549c… lmata 277 AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
5ac549c… lmata 278 }
5ac549c… lmata 279 provider, err := llm.New(llmCfg)
5ac549c… lmata 280 if err != nil {
5ac549c… lmata 281 return nil, fmt.Errorf("oracle: build llm provider: %w", err)
5ac549c… lmata 282 }
5ac549c… lmata 283
5ac549c… lmata 284 // Read from the same dir scribe writes to.
5ac549c… lmata 285 scribeDir := cfgStr(cfg, "scribe_dir", filepath.Join(m.dataDir, "logs", "scribe"))
5ac549c… lmata 286 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: scribeDir, Format: "jsonl"})
5ac549c… lmata 287 history := &scribeHistoryAdapter{store: fs}
5ac549c… lmata 288
3420a83… lmata 289 return oracle.New(m.ircAddr, pass, channels, history, provider, m.log), nil
5ac549c… lmata 290
5ac549c… lmata 291 case "sentinel":
5ac549c… lmata 292 apiKey := cfgStr(cfg, "api_key", "")
5ac549c… lmata 293 if apiKey == "" {
5ac549c… lmata 294 if env := cfgStr(cfg, "api_key_env", ""); env != "" {
5ac549c… lmata 295 apiKey = os.Getenv(env)
5ac549c… lmata 296 }
5ac549c… lmata 297 }
5ac549c… lmata 298 llmCfg := llm.BackendConfig{
5ac549c… lmata 299 Backend: cfgStr(cfg, "backend", "openai"),
5ac549c… lmata 300 APIKey: apiKey,
5ac549c… lmata 301 BaseURL: cfgStr(cfg, "base_url", ""),
5ac549c… lmata 302 Model: cfgStr(cfg, "model", ""),
5ac549c… lmata 303 Region: cfgStr(cfg, "region", ""),
5ac549c… lmata 304 AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
5ac549c… lmata 305 AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
5ac549c… lmata 306 }
5ac549c… lmata 307 provider, err := llm.New(llmCfg)
5ac549c… lmata 308 if err != nil {
5ac549c… lmata 309 return nil, fmt.Errorf("sentinel: build llm provider: %w", err)
5ac549c… lmata 310 }
5ac549c… lmata 311 return sentinel.New(sentinel.Config{
5ac549c… lmata 312 IRCAddr: m.ircAddr,
5ac549c… lmata 313 Nick: spec.Nick,
5ac549c… lmata 314 Password: pass,
5ac549c… lmata 315 ModChannel: cfgStr(cfg, "mod_channel", "#moderation"),
5ac549c… lmata 316 DMOperators: cfgBool(cfg, "dm_operators", false),
5ac549c… lmata 317 AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")),
5ac549c… lmata 318 Policy: cfgStr(cfg, "policy", ""),
5ac549c… lmata 319 WindowSize: cfgInt(cfg, "window_size", 20),
5ac549c… lmata 320 WindowAge: time.Duration(cfgInt(cfg, "window_age_sec", 300)) * time.Second,
5ac549c… lmata 321 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 600)) * time.Second,
5ac549c… lmata 322 MinSeverity: cfgStr(cfg, "min_severity", "medium"),
3420a83… lmata 323 Channels: channels,
5ac549c… lmata 324 }, provider, m.log), nil
5ac549c… lmata 325
5ac549c… lmata 326 case "steward":
5ac549c… lmata 327 return steward.New(steward.Config{
5ac549c… lmata 328 IRCAddr: m.ircAddr,
5ac549c… lmata 329 Nick: spec.Nick,
5ac549c… lmata 330 Password: pass,
5ac549c… lmata 331 ModChannel: cfgStr(cfg, "mod_channel", "#moderation"),
5ac549c… lmata 332 OperatorNicks: splitCSV(cfgStr(cfg, "operator_nicks", "")),
5ac549c… lmata 333 DMOnAction: cfgBool(cfg, "dm_on_action", false),
5ac549c… lmata 334 AutoAct: cfgBool(cfg, "auto_act", true),
5ac549c… lmata 335 MuteDuration: time.Duration(cfgInt(cfg, "mute_duration_sec", 600)) * time.Second,
5ac549c… lmata 336 WarnOnLow: cfgBool(cfg, "warn_on_low", true),
5ac549c… lmata 337 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
3420a83… lmata 338 Channels: channels,
5ac549c… lmata 339 }, m.log), nil
039edb2… noreply 340
039edb2… noreply 341 case "shepherd":
039edb2… noreply 342 apiKey := cfgStr(cfg, "api_key", "")
039edb2… noreply 343 if apiKey == "" {
039edb2… noreply 344 if env := cfgStr(cfg, "api_key_env", ""); env != "" {
039edb2… noreply 345 apiKey = os.Getenv(env)
039edb2… noreply 346 }
039edb2… noreply 347 }
039edb2… noreply 348 var provider shepherd.LLMProvider
039edb2… noreply 349 if apiKey != "" {
039edb2… noreply 350 llmCfg := llm.BackendConfig{
039edb2… noreply 351 Backend: cfgStr(cfg, "backend", "openai"),
039edb2… noreply 352 APIKey: apiKey,
039edb2… noreply 353 BaseURL: cfgStr(cfg, "base_url", ""),
039edb2… noreply 354 Model: cfgStr(cfg, "model", ""),
039edb2… noreply 355 Region: cfgStr(cfg, "region", ""),
039edb2… noreply 356 AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
039edb2… noreply 357 AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
039edb2… noreply 358 }
039edb2… noreply 359 p, err := llm.New(llmCfg)
039edb2… noreply 360 if err != nil {
039edb2… noreply 361 return nil, fmt.Errorf("shepherd: build llm provider: %w", err)
039edb2… noreply 362 }
039edb2… noreply 363 provider = p
039edb2… noreply 364 }
039edb2… noreply 365 checkinSec := cfgInt(cfg, "checkin_interval_sec", 0)
039edb2… noreply 366 return shepherd.New(shepherd.Config{
039edb2… noreply 367 IRCAddr: m.ircAddr,
039edb2… noreply 368 Nick: spec.Nick,
039edb2… noreply 369 Password: pass,
039edb2… noreply 370 Channels: channels,
039edb2… noreply 371 ReportChannel: cfgStr(cfg, "report_channel", "#ops"),
039edb2… noreply 372 CheckinInterval: time.Duration(checkinSec) * time.Second,
039edb2… noreply 373 GoalSource: cfgStr(cfg, "goal_source", ""),
039edb2… noreply 374 }, provider, m.log), nil
5ac549c… lmata 375
5ac549c… lmata 376 default:
5ac549c… lmata 377 return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
5ac549c… lmata 378 }
5ac549c… lmata 379 }
5ac549c… lmata 380
5ac549c… lmata 381 // passwordsPath returns the path for the passwords file.
5ac549c… lmata 382 func (m *Manager) passwordsPath() string {
5ac549c… lmata 383 return filepath.Join(m.dataDir, "bot_passwords.json")
5ac549c… lmata 384 }
5ac549c… lmata 385
5ac549c… lmata 386 func (m *Manager) loadPasswords() error {
5ac549c… lmata 387 raw, err := os.ReadFile(m.passwordsPath())
5ac549c… lmata 388 if os.IsNotExist(err) {
5ac549c… lmata 389 return nil
5ac549c… lmata 390 }
5ac549c… lmata 391 if err != nil {
5ac549c… lmata 392 return err
5ac549c… lmata 393 }
5ac549c… lmata 394 return json.Unmarshal(raw, &m.passwords)
5ac549c… lmata 395 }
5ac549c… lmata 396
5ac549c… lmata 397 func (m *Manager) savePasswords() error {
5ac549c… lmata 398 raw, err := json.MarshalIndent(m.passwords, "", " ")
5ac549c… lmata 399 if err != nil {
5ac549c… lmata 400 return err
5ac549c… lmata 401 }
5ac549c… lmata 402 if err := os.MkdirAll(m.dataDir, 0755); err != nil {
5ac549c… lmata 403 return err
5ac549c… lmata 404 }
5ac549c… lmata 405 return os.WriteFile(m.passwordsPath(), raw, 0600)
5ac549c… lmata 406 }
5ac549c… lmata 407
5ac549c… lmata 408 func genPassword() (string, error) {
5ac549c… lmata 409 b := make([]byte, 16)
5ac549c… lmata 410 if _, err := rand.Read(b); err != nil {
5ac549c… lmata 411 return "", fmt.Errorf("manager: generate password: %w", err)
5ac549c… lmata 412 }
5ac549c… lmata 413 return hex.EncodeToString(b), nil
5ac549c… lmata 414 }
5ac549c… lmata 415
5ac549c… lmata 416 // splitCSV splits a comma-separated string into a slice, trimming spaces and
5ac549c… lmata 417 // filtering empty strings.
5ac549c… lmata 418 func splitCSV(s string) []string {
5ac549c… lmata 419 if s == "" {
5ac549c… lmata 420 return nil
5ac549c… lmata 421 }
5ac549c… lmata 422 parts := strings.Split(s, ",")
5ac549c… lmata 423 out := make([]string, 0, len(parts))
5ac549c… lmata 424 for _, p := range parts {
5ac549c… lmata 425 if p = strings.TrimSpace(p); p != "" {
5ac549c… lmata 426 out = append(out, p)
5ac549c… lmata 427 }
5ac549c… lmata 428 }
5ac549c… lmata 429 return out
5ac549c… lmata 430 }
5ac549c… lmata 431
5ac549c… lmata 432 // Config helper extractors.
5ac549c… lmata 433
5ac549c… lmata 434 func cfgStr(cfg map[string]any, key, def string) string {
5ac549c… lmata 435 if cfg == nil {
5ac549c… lmata 436 return def
5ac549c… lmata 437 }
5ac549c… lmata 438 v, ok := cfg[key]
5ac549c… lmata 439 if !ok {
5ac549c… lmata 440 return def
5ac549c… lmata 441 }
5ac549c… lmata 442 s, ok := v.(string)
5ac549c… lmata 443 if !ok {
5ac549c… lmata 444 return def
5ac549c… lmata 445 }
5ac549c… lmata 446 return s
5ac549c… lmata 447 }
5ac549c… lmata 448
5ac549c… lmata 449 func cfgInt(cfg map[string]any, key string, def int) int {
5ac549c… lmata 450 if cfg == nil {
5ac549c… lmata 451 return def
5ac549c… lmata 452 }
5ac549c… lmata 453 v, ok := cfg[key]
5ac549c… lmata 454 if !ok {
5ac549c… lmata 455 return def
5ac549c… lmata 456 }
5ac549c… lmata 457 switch n := v.(type) {
5ac549c… lmata 458 case int:
5ac549c… lmata 459 return n
5ac549c… lmata 460 case int64:
5ac549c… lmata 461 return int(n)
5ac549c… lmata 462 case float64:
5ac549c… lmata 463 return int(n)
5ac549c… lmata 464 }
5ac549c… lmata 465 return def
5ac549c… lmata 466 }
5ac549c… lmata 467
5ac549c… lmata 468 func cfgBool(cfg map[string]any, key string, def bool) bool {
5ac549c… lmata 469 if cfg == nil {
5ac549c… lmata 470 return def
5ac549c… lmata 471 }
5ac549c… lmata 472 v, ok := cfg[key]
5ac549c… lmata 473 if !ok {
5ac549c… lmata 474 return def
5ac549c… lmata 475 }
5ac549c… lmata 476 b, ok := v.(bool)
5ac549c… lmata 477 if !ok {
5ac549c… lmata 478 return def
5ac549c… lmata 479 }
5ac549c… lmata 480 return b
5ac549c… lmata 481 }
5ac549c… lmata 482
5ac549c… lmata 483 func cfgFloat(cfg map[string]any, key string, def float64) float64 {
5ac549c… lmata 484 if cfg == nil {
5ac549c… lmata 485 return def
5ac549c… lmata 486 }
5ac549c… lmata 487 v, ok := cfg[key]
5ac549c… lmata 488 if !ok {
5ac549c… lmata 489 return def
5ac549c… lmata 490 }
5ac549c… lmata 491 switch n := v.(type) {
5ac549c… lmata 492 case float64:
5ac549c… lmata 493 return n
5ac549c… lmata 494 case int:
5ac549c… lmata 495 return float64(n)
5ac549c… lmata 496 case int64:
5ac549c… lmata 497 return float64(n)
5ac549c… lmata 498 }
5ac549c… lmata 499 return def
5ac549c… lmata 500 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button