ScuttleBot

feat: ergo lifecycle manager, API client, config generation, and config schema

lmata 2026-03-31 04:50 trunk
Commit c369cd584fcfa410e4699dff1fb93a1a4b470ef40e95f9224318fe7726ff0744
--- internal/config/config.go
+++ internal/config/config.go
@@ -1,1 +1,99 @@
1
+// Package config defines scuttlebot's configuration schema.
12
package config
3
+
4
+// Config is the top-level scuttlebot configuration.
5
+type Config struct {
6
+ Ergo ErgoConfig `yaml:"ergo"`
7
+ Datastore DatastoreConfig `yaml:"datastore"`
8
+}
9
+
10
+// ErgoConfig holds settings for the managed Ergo IRC server.
11
+type ErgoConfig struct {
12
+ // BinaryPath is the path to the ergo binary. Defaults to "ergo" (looks in PATH).
13
+ BinaryPath string `yaml:"binary_path"`
14
+
15
+ // DataDir is the directory where Ergo stores ircd.db and generated config.
16
+ DataDir string `yaml:"data_dir"`
17
+
18
+ // NetworkName is the human-readable IRC network name.
19
+ NetworkName string `yaml:"network_name"`
20
+
21
+ // ServerName is the IRC server hostname (e.g. "irc.example.com").
22
+ ServerName string `yaml:"server_name"`
23
+
24
+ // IRCAddr is the address Ergo listens for IRC connections on.
25
+ // Default: "127.0.0.1:6667" (loopback plaintext for private networks).
26
+ IRCAddr string `yaml:"irc_addr"`
27
+
28
+ // APIAddr is the address of Ergo's HTTP management API.
29
+ // Default: "127.0.0.1:8089" (loopback only).
30
+ APIAddr string `yaml:"api_addr"`
31
+
32
+ // APIToken is the bearer token for Ergo's HTTP API.
33
+ // scuttlebot generates this on first start and stores it.
34
+ APIToken string `yaml:"api_token"`
35
+
36
+ // History configures persistent message history storage.
37
+ History HistoryConfig `yaml:"history"`
38
+}
39
+
40
+// HistoryConfig configures Ergo's persistent message history.
41
+type HistoryConfig struct {
42
+ // Enabled enables persistent history storage.
43
+ Enabled bool `yaml:"enabled"`
44
+
45
+ // PostgresDSN is the Postgres connection string for persistent history.
46
+ // Recommended. If empty and Enabled is true, MySQL config is used instead.
47
+ PostgresDSN string `yaml:"postgres_dsn"`
48
+
49
+ // MySQL is the MySQL connection config for persistent history.
50
+ MySQL MySQLConfig `yaml:"mysql"`
51
+}
52
+
53
+// MySQLConfig holds MySQL connection settings for Ergo history.
54
+type MySQLConfig struct {
55
+ Host string `yaml:"host"`
56
+ Port int `yaml:"port"`
57
+ User string `yaml:"user"`
58
+ Password string `yaml:"password"`
59
+ Database string `yaml:"database"`
60
+}
61
+
62
+// DatastoreConfig configures scuttlebot's own state store (separate from Ergo).
63
+type DatastoreConfig struct {
64
+ // Driver is "sqlite" or "postgres". Default: "sqlite".
65
+ Driver string `yaml:"driver"`
66
+
67
+ // DSN is the data source name.
68
+ // For sqlite: path to the .db file.
69
+ // For postgres: connection string.
70
+ DSN string `yaml:"dsn"`
71
+}
72
+
73
+// Defaults fills in zero values with sensible defaults.
74
+func (c *Config) Defaults() {
75
+ if c.Ergo.BinaryPath == "" {
76
+ c.Ergo.BinaryPath = "ergo"
77
+ }
78
+ if c.Ergo.DataDir == "" {
79
+ c.Ergo.DataDir = "./data/ergo"
80
+ }
81
+ if c.Ergo.NetworkName == "" {
82
+ c.Ergo.NetworkName = "scuttlebot"
83
+ }
84
+ if c.Ergo.ServerName == "" {
85
+ c.Ergo.ServerName = "irc.scuttlebot.local"
86
+ }
87
+ if c.Ergo.IRCAddr == "" {
88
+ c.Ergo.IRCAddr = "127.0.0.1:6667"
89
+ }
90
+ if c.Ergo.APIAddr == "" {
91
+ c.Ergo.APIAddr = "127.0.0.1:8089"
92
+ }
93
+ if c.Datastore.Driver == "" {
94
+ c.Datastore.Driver = "sqlite"
95
+ }
96
+ if c.Datastore.DSN == "" {
97
+ c.Datastore.DSN = "./data/scuttlebot.db"
98
+ }
99
+}
2100
3101
ADDED internal/ergo/api.go
--- internal/config/config.go
+++ internal/config/config.go
@@ -1,1 +1,99 @@
 
1 package config
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
3 DDED internal/ergo/api.go
--- internal/config/config.go
+++ internal/config/config.go
@@ -1,1 +1,99 @@
1 // Package config defines scuttlebot's configuration schema.
2 package config
3
4 // Config is the top-level scuttlebot configuration.
5 type Config struct {
6 Ergo ErgoConfig `yaml:"ergo"`
7 Datastore DatastoreConfig `yaml:"datastore"`
8 }
9
10 // ErgoConfig holds settings for the managed Ergo IRC server.
11 type ErgoConfig struct {
12 // BinaryPath is the path to the ergo binary. Defaults to "ergo" (looks in PATH).
13 BinaryPath string `yaml:"binary_path"`
14
15 // DataDir is the directory where Ergo stores ircd.db and generated config.
16 DataDir string `yaml:"data_dir"`
17
18 // NetworkName is the human-readable IRC network name.
19 NetworkName string `yaml:"network_name"`
20
21 // ServerName is the IRC server hostname (e.g. "irc.example.com").
22 ServerName string `yaml:"server_name"`
23
24 // IRCAddr is the address Ergo listens for IRC connections on.
25 // Default: "127.0.0.1:6667" (loopback plaintext for private networks).
26 IRCAddr string `yaml:"irc_addr"`
27
28 // APIAddr is the address of Ergo's HTTP management API.
29 // Default: "127.0.0.1:8089" (loopback only).
30 APIAddr string `yaml:"api_addr"`
31
32 // APIToken is the bearer token for Ergo's HTTP API.
33 // scuttlebot generates this on first start and stores it.
34 APIToken string `yaml:"api_token"`
35
36 // History configures persistent message history storage.
37 History HistoryConfig `yaml:"history"`
38 }
39
40 // HistoryConfig configures Ergo's persistent message history.
41 type HistoryConfig struct {
42 // Enabled enables persistent history storage.
43 Enabled bool `yaml:"enabled"`
44
45 // PostgresDSN is the Postgres connection string for persistent history.
46 // Recommended. If empty and Enabled is true, MySQL config is used instead.
47 PostgresDSN string `yaml:"postgres_dsn"`
48
49 // MySQL is the MySQL connection config for persistent history.
50 MySQL MySQLConfig `yaml:"mysql"`
51 }
52
53 // MySQLConfig holds MySQL connection settings for Ergo history.
54 type MySQLConfig struct {
55 Host string `yaml:"host"`
56 Port int `yaml:"port"`
57 User string `yaml:"user"`
58 Password string `yaml:"password"`
59 Database string `yaml:"database"`
60 }
61
62 // DatastoreConfig configures scuttlebot's own state store (separate from Ergo).
63 type DatastoreConfig struct {
64 // Driver is "sqlite" or "postgres". Default: "sqlite".
65 Driver string `yaml:"driver"`
66
67 // DSN is the data source name.
68 // For sqlite: path to the .db file.
69 // For postgres: connection string.
70 DSN string `yaml:"dsn"`
71 }
72
73 // Defaults fills in zero values with sensible defaults.
74 func (c *Config) Defaults() {
75 if c.Ergo.BinaryPath == "" {
76 c.Ergo.BinaryPath = "ergo"
77 }
78 if c.Ergo.DataDir == "" {
79 c.Ergo.DataDir = "./data/ergo"
80 }
81 if c.Ergo.NetworkName == "" {
82 c.Ergo.NetworkName = "scuttlebot"
83 }
84 if c.Ergo.ServerName == "" {
85 c.Ergo.ServerName = "irc.scuttlebot.local"
86 }
87 if c.Ergo.IRCAddr == "" {
88 c.Ergo.IRCAddr = "127.0.0.1:6667"
89 }
90 if c.Ergo.APIAddr == "" {
91 c.Ergo.APIAddr = "127.0.0.1:8089"
92 }
93 if c.Datastore.Driver == "" {
94 c.Datastore.Driver = "sqlite"
95 }
96 if c.Datastore.DSN == "" {
97 c.Datastore.DSN = "./data/scuttlebot.db"
98 }
99 }
100
101 DDED internal/ergo/api.go
--- a/internal/ergo/api.go
+++ b/internal/ergo/api.go
@@ -0,0 +1,182 @@
1
+package ergo
2
+
3
+import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "time"
9
+)
10
+
11
+// APIClient is an HTTP client for Ergo's management API.
12
+type APIClient struct {
13
+ baseURL string
14
+ token string
15
+ http *http.Client
16
+}
17
+
18
+// NewAPIClient returns a new APIClient pointed at addr with the given bearer token.
19
+func NewAPIClient(addr, token string) *APIClient {
20
+ return &APIClient{
21
+ baseURL: "http://" + addr,
22
+ token: token,
23
+ http: &http.Client{Timeout: 10 * time.Second},
24
+ }
25
+}
26
+
27
+// Status returns the Ergo server status.
28
+func (c *APIClient) Status() (*StatusResponse, error) {
29
+ var resp StatusResponse
30
+ if err := c.post("/v1/status", nil, &resp); err != nil {
31
+ return nil, fmt.Errorf("ergo api: status: %w", err)
32
+ }
33
+ return &resp, nil
34
+}
35
+
36
+// Rehash reloads Ergo's configuration file.
37
+func (c *APIClient) Rehash() error {
38
+ var resp successResponse
39
+ if err := c.post("/v1/rehash", nil, &resp); err != nil {
40
+ return fmt.Errorf("ergo api: rehash: %w", err)
41
+ }
42
+ if !resp.Success {
43
+ return fmt.Errorf("ergo api: rehash failed: %s", resp.Error)
44
+ }
45
+ return nil
46
+}
47
+
48
+// RegisterAccount creates a NickServ account via saregister.
49
+func (c *APIClient) RegisterAccount(name, passphrase string) error {
50
+ var resp registerResponse
51
+ if err := c.post("/v1/ns/saregister", map[string]string{
52
+ "accountName": name,
53
+ "passphrase": passphrase,
54
+ }, &resp); err != nil {
55
+ return fmt.Errorf("ergo api: register account %q: %w", name, err)
56
+ }
57
+ if !resp.Success {
58
+ return fmt.Errorf("ergo api: register account %q: %s", name, resp.ErrorCode)
59
+ }
60
+ return nil
61
+}
62
+
63
+// ChangePassword updates the passphrase of an existing NickServ account.
64
+func (c *APIClient) ChangePassword(name, passphrase string) error {
65
+ var resp passwdResponse
66
+ if err := c.post("/v1/ns/passwd", map[string]string{
67
+ "accountName": name,
68
+ "passphrase": passphrase,
69
+ }, &resp); err != nil {
70
+ return fmt.Errorf("ergo api: change password %q: %w", name, err)
71
+ }
72
+ if !resp.Success {
73
+ return fmt.Errorf("ergo api: change password %q: %s", name, resp.ErrorCode)
74
+ }
75
+ return nil
76
+}
77
+
78
+// AccountInfo fetches details about a NickServ account.
79
+func (c *APIClient) AccountInfo(name string) (*AccountInfoResponse, error) {
80
+ var resp AccountInfoResponse
81
+ if err := c.post("/v1/ns/info", map[string]string{
82
+ "accountName": name,
83
+ }, &resp); err != nil {
84
+ return nil, package ergo
85
+
86
+import (
87
+ "bytes"
88
+ "encoding/json"
89
+ "fmt"
90
+ "net/http"
91
+ "time"
92
+)
93
+
94
+// APIClient is an HTTP client for Ergo's management API.
95
+type APIClient struct {
96
+ baseURL string
97
+ token string
98
+ http *http.Client
99
+}
100
+
101
+// NewAPIClient returns a new APIClient pointed at addr with the given bearer token.
102
+func NewAPIClient(addr, token string) *APIClient {
103
+ return &APIClient{
104
+ baseURL: "http://" + addr,
105
+ token: token,
106
+ http: &http.Client{Timeout: 10 * time.Second},
107
+ }
108
+}
109
+
110
+// Status returns the Ergo server status.
111
+func (c *APIClient) Status() (*StatusResponse, error) {
112
+ var resp StatusResponse
113
+ if err := c.post("/v1/status", nil, &resp); err != nil {
114
+ return nil, fmt.Errorf("ergo api: status: %w", err)
115
+ }
116
+ return &resp, nil
117
+}
118
+
119
+// Rehash reloads Ergo's configuration f(
120
+ "bytes"
121
+ "encoding/json"
122
+ "fmt"
123
+ "net/http"
124
+ "time"
125
+)
126
+
127
+// APIClient is an HTTP client for Ergo's management API.
128
+type APIClient struct {
129
+ baseURL string
130
+ token string
131
+ http *http.Client
132
+}
133
+
134
+// NewAPIClient returns a new APIClient pointed at addr with the given bearer token.
135
+func NewAPIClient(addr, token string) *APIClient {
136
+ return &APIClient{
137
+ baseURL: "http://" + addr,
138
+ token: token,
139
+ http: &http.Client{Timeout: 10 * time.Second},
140
+ }
141
+}
142
+
143
+// Status returns the Ergo server status.
144
+func (c *APIClient) Status() (*StatusResponse, error) {
145
+ var resp StatusResponse
146
+ if err := c.post("/v1/status", nil, &resp); err != nil {
147
+ return nil, fmt.Errorf("ergo api: status: %w", err)
148
+ }
149
+ return &resp, nil
150
+}
151
+
152
+// Rehash reloads Ergo's configuration file.
153
+func (c *APIClient) Rehash() error {
154
+ var resp successResponse
155
+ if err := c.post("/v1/rehash", nil, &resp); err != nil {
156
+ return fmt.Errorf("ergo api: rehash: %w", err)
157
+ }
158
+ if !resp.Success {
159
+ return fmt.Errorf("ergo api: rehash failed: %s", resp.Error)
160
+ }
161
+ return nil
162
+}
163
+
164
+// RegisterAccount creates a NickServ account via saregister.
165
+func (c *APIClient) RegisterAccount(name, passphrase string) error {
166
+ var resp registerResponse
167
+ if err := c.post("/v1/ns/saregister", map[string]string{
168
+ "accountName": name,
169
+ "passphrase": passphrase,
170
+ }, &resp); err != nil {
171
+ return fmt.Errorf("ergo api: register account %q: %w", name, err)
172
+ }
173
+ if !resp.Success {
174
+ return fmt.Errorf("ergo api: register account %q: %s", name, resp.ErrorCode)
175
+ }
176
+ return nil
177
+}
178
+
179
+// ChangePassword updates the passphrase of an existing NickServ account.
180
+func (c *APIClient) ChangePassword(name, passphrase string) error {
181
+ var resp passwdResponse
182
+ if err := c.post("/v1/ns/passwd", map[stri
--- a/internal/ergo/api.go
+++ b/internal/ergo/api.go
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/ergo/api.go
+++ b/internal/ergo/api.go
@@ -0,0 +1,182 @@
1 package ergo
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "time"
9 )
10
11 // APIClient is an HTTP client for Ergo's management API.
12 type APIClient struct {
13 baseURL string
14 token string
15 http *http.Client
16 }
17
18 // NewAPIClient returns a new APIClient pointed at addr with the given bearer token.
19 func NewAPIClient(addr, token string) *APIClient {
20 return &APIClient{
21 baseURL: "http://" + addr,
22 token: token,
23 http: &http.Client{Timeout: 10 * time.Second},
24 }
25 }
26
27 // Status returns the Ergo server status.
28 func (c *APIClient) Status() (*StatusResponse, error) {
29 var resp StatusResponse
30 if err := c.post("/v1/status", nil, &resp); err != nil {
31 return nil, fmt.Errorf("ergo api: status: %w", err)
32 }
33 return &resp, nil
34 }
35
36 // Rehash reloads Ergo's configuration file.
37 func (c *APIClient) Rehash() error {
38 var resp successResponse
39 if err := c.post("/v1/rehash", nil, &resp); err != nil {
40 return fmt.Errorf("ergo api: rehash: %w", err)
41 }
42 if !resp.Success {
43 return fmt.Errorf("ergo api: rehash failed: %s", resp.Error)
44 }
45 return nil
46 }
47
48 // RegisterAccount creates a NickServ account via saregister.
49 func (c *APIClient) RegisterAccount(name, passphrase string) error {
50 var resp registerResponse
51 if err := c.post("/v1/ns/saregister", map[string]string{
52 "accountName": name,
53 "passphrase": passphrase,
54 }, &resp); err != nil {
55 return fmt.Errorf("ergo api: register account %q: %w", name, err)
56 }
57 if !resp.Success {
58 return fmt.Errorf("ergo api: register account %q: %s", name, resp.ErrorCode)
59 }
60 return nil
61 }
62
63 // ChangePassword updates the passphrase of an existing NickServ account.
64 func (c *APIClient) ChangePassword(name, passphrase string) error {
65 var resp passwdResponse
66 if err := c.post("/v1/ns/passwd", map[string]string{
67 "accountName": name,
68 "passphrase": passphrase,
69 }, &resp); err != nil {
70 return fmt.Errorf("ergo api: change password %q: %w", name, err)
71 }
72 if !resp.Success {
73 return fmt.Errorf("ergo api: change password %q: %s", name, resp.ErrorCode)
74 }
75 return nil
76 }
77
78 // AccountInfo fetches details about a NickServ account.
79 func (c *APIClient) AccountInfo(name string) (*AccountInfoResponse, error) {
80 var resp AccountInfoResponse
81 if err := c.post("/v1/ns/info", map[string]string{
82 "accountName": name,
83 }, &resp); err != nil {
84 return nil, package ergo
85
86 import (
87 "bytes"
88 "encoding/json"
89 "fmt"
90 "net/http"
91 "time"
92 )
93
94 // APIClient is an HTTP client for Ergo's management API.
95 type APIClient struct {
96 baseURL string
97 token string
98 http *http.Client
99 }
100
101 // NewAPIClient returns a new APIClient pointed at addr with the given bearer token.
102 func NewAPIClient(addr, token string) *APIClient {
103 return &APIClient{
104 baseURL: "http://" + addr,
105 token: token,
106 http: &http.Client{Timeout: 10 * time.Second},
107 }
108 }
109
110 // Status returns the Ergo server status.
111 func (c *APIClient) Status() (*StatusResponse, error) {
112 var resp StatusResponse
113 if err := c.post("/v1/status", nil, &resp); err != nil {
114 return nil, fmt.Errorf("ergo api: status: %w", err)
115 }
116 return &resp, nil
117 }
118
119 // Rehash reloads Ergo's configuration f(
120 "bytes"
121 "encoding/json"
122 "fmt"
123 "net/http"
124 "time"
125 )
126
127 // APIClient is an HTTP client for Ergo's management API.
128 type APIClient struct {
129 baseURL string
130 token string
131 http *http.Client
132 }
133
134 // NewAPIClient returns a new APIClient pointed at addr with the given bearer token.
135 func NewAPIClient(addr, token string) *APIClient {
136 return &APIClient{
137 baseURL: "http://" + addr,
138 token: token,
139 http: &http.Client{Timeout: 10 * time.Second},
140 }
141 }
142
143 // Status returns the Ergo server status.
144 func (c *APIClient) Status() (*StatusResponse, error) {
145 var resp StatusResponse
146 if err := c.post("/v1/status", nil, &resp); err != nil {
147 return nil, fmt.Errorf("ergo api: status: %w", err)
148 }
149 return &resp, nil
150 }
151
152 // Rehash reloads Ergo's configuration file.
153 func (c *APIClient) Rehash() error {
154 var resp successResponse
155 if err := c.post("/v1/rehash", nil, &resp); err != nil {
156 return fmt.Errorf("ergo api: rehash: %w", err)
157 }
158 if !resp.Success {
159 return fmt.Errorf("ergo api: rehash failed: %s", resp.Error)
160 }
161 return nil
162 }
163
164 // RegisterAccount creates a NickServ account via saregister.
165 func (c *APIClient) RegisterAccount(name, passphrase string) error {
166 var resp registerResponse
167 if err := c.post("/v1/ns/saregister", map[string]string{
168 "accountName": name,
169 "passphrase": passphrase,
170 }, &resp); err != nil {
171 return fmt.Errorf("ergo api: register account %q: %w", name, err)
172 }
173 if !resp.Success {
174 return fmt.Errorf("ergo api: register account %q: %s", name, resp.ErrorCode)
175 }
176 return nil
177 }
178
179 // ChangePassword updates the passphrase of an existing NickServ account.
180 func (c *APIClient) ChangePassword(name, passphrase string) error {
181 var resp passwdResponse
182 if err := c.post("/v1/ns/passwd", map[stri
--- internal/ergo/ergo.go
+++ internal/ergo/ergo.go
@@ -1,1 +1,2 @@
1
+// Package ergo manages the lifecycle of the Ergo IRC server subprocess.
12
package ergo
23
34
ADDED internal/ergo/ircdconfig.go
45
ADDED internal/ergo/ircdconfig_test.go
56
ADDED internal/ergo/manager.go
--- internal/ergo/ergo.go
+++ internal/ergo/ergo.go
@@ -1,1 +1,2 @@
 
1 package ergo
2
3 DDED internal/ergo/ircdconfig.go
4 DDED internal/ergo/ircdconfig_test.go
5 DDED internal/ergo/manager.go
--- internal/ergo/ergo.go
+++ internal/ergo/ergo.go
@@ -1,1 +1,2 @@
1 // Package ergo manages the lifecycle of the Ergo IRC server subprocess.
2 package ergo
3
4 DDED internal/ergo/ircdconfig.go
5 DDED internal/ergo/ircdconfig_test.go
6 DDED internal/ergo/manager.go
--- a/internal/ergo/ircdconfig.go
+++ b/internal/ergo/ircdconfig.go
@@ -0,0 +1,76 @@
1
+package ergo
2
+
3
+import (
4
+ "bytes"
5
+ "text/template"
6
+
7
+ "github.com/conflicthq/s)
8
+
9
+var ircdTemplate = template.Must(template.New("ircd").Parse(`# Generated by scuttlebot — do not edit manually.
10
+network:
11
+ name: {{.NetworkName}}
12
+
13
+server:
14
+ name: {{.ServerName}}
15
+ listeners:
16
+ "{{.IRCAddr}}": {}
17
+{{- if .TLSDomain}}
18
+ enforce-utf8: true
19
+ false
20
+ ip-cloaking:
21
+ enabled: false
22
+ lookup-hostnames: false
23
+
24
+datastore:
25
+ path: {{.DataDir}}rue
26
+{{- if .HistoryEnabled}}
27
+ {{- if .PostgresDSN}}
28
+ postgresql:
29
+ enabled: true
30
+ dsn: "{{.PostgresDSN}}"
31
+ {{- else if .MySQLEnabled}}
32
+ mysql:
33
+ enabled: true
34
+ host: "{{.MySQL.Host}}"
35
+ port: {{.MySQL.Port}}
36
+ user: "{{.MySQL.User}}"
37
+ password: "{{.MySQL.Password}}"
38
+ history-database: "{{.MySQL.Database}}"
39
+ {{- end}}
40
+{{- end}}
41
+
42
+accounts:
43
+ registration:
44
+ enabled: true
45
+ allow-before-connect: true
46
+ throttling:
47
+ enabled: false
48
+ authentication-enabled: true
49
+ require-sasl:
50
+ enabled: falsen:
51
+ enabled: true
52
+
53
+history:
54
+ enabled: {{.Histoopers:
55
+ scuttl
56
+ class: ircop
57
+8@GU,1z:hidden: true
58
+ whois-line: scuttlebot operator
59
+
60
+oper-classes:
61
+ ircop:
62
+ title: IRC Operator
63
+ capabilitieB@4i,L: - oper:local_kill9@4k,K: - oper:local_ban9@4k,M: - oper:local_unban9@4k,M: - oper:remote_kill9@4k,L: - oper:remote_ban9@4k,N: - oper:remote_unban9@4k,H: - oper:rehash9@4k,E: - oper:die9@4k,K: - oper:nofakelag9@4k,M: - oper:relaymsg
64
+ A@YM,1J:- ban:nick
65
+ - ban:hostname
66
+ - ban:cidr
67
+ - ban:ip
68
+ 8@PS,x: - samode
69
+ - sajoin
70
+ - snomask
71
+ 7@FG,D:- vhosts
72
+ 8@bV,A:- accreg
73
+ 8@bV,F: - chanreg
74
+ 8@4l,F: - history
75
+ 7@cy,_: - defcon
76
+ - massmessageEz@UW,z3NyD;
--- a/internal/ergo/ircdconfig.go
+++ b/internal/ergo/ircdconfig.go
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/ergo/ircdconfig.go
+++ b/internal/ergo/ircdconfig.go
@@ -0,0 +1,76 @@
1 package ergo
2
3 import (
4 "bytes"
5 "text/template"
6
7 "github.com/conflicthq/s)
8
9 var ircdTemplate = template.Must(template.New("ircd").Parse(`# Generated by scuttlebot — do not edit manually.
10 network:
11 name: {{.NetworkName}}
12
13 server:
14 name: {{.ServerName}}
15 listeners:
16 "{{.IRCAddr}}": {}
17 {{- if .TLSDomain}}
18 enforce-utf8: true
19 false
20 ip-cloaking:
21 enabled: false
22 lookup-hostnames: false
23
24 datastore:
25 path: {{.DataDir}}rue
26 {{- if .HistoryEnabled}}
27 {{- if .PostgresDSN}}
28 postgresql:
29 enabled: true
30 dsn: "{{.PostgresDSN}}"
31 {{- else if .MySQLEnabled}}
32 mysql:
33 enabled: true
34 host: "{{.MySQL.Host}}"
35 port: {{.MySQL.Port}}
36 user: "{{.MySQL.User}}"
37 password: "{{.MySQL.Password}}"
38 history-database: "{{.MySQL.Database}}"
39 {{- end}}
40 {{- end}}
41
42 accounts:
43 registration:
44 enabled: true
45 allow-before-connect: true
46 throttling:
47 enabled: false
48 authentication-enabled: true
49 require-sasl:
50 enabled: falsen:
51 enabled: true
52
53 history:
54 enabled: {{.Histoopers:
55 scuttl
56 class: ircop
57 8@GU,1z:hidden: true
58 whois-line: scuttlebot operator
59
60 oper-classes:
61 ircop:
62 title: IRC Operator
63 capabilitieB@4i,L: - oper:local_kill9@4k,K: - oper:local_ban9@4k,M: - oper:local_unban9@4k,M: - oper:remote_kill9@4k,L: - oper:remote_ban9@4k,N: - oper:remote_unban9@4k,H: - oper:rehash9@4k,E: - oper:die9@4k,K: - oper:nofakelag9@4k,M: - oper:relaymsg
64 A@YM,1J:- ban:nick
65 - ban:hostname
66 - ban:cidr
67 - ban:ip
68 8@PS,x: - samode
69 - sajoin
70 - snomask
71 7@FG,D:- vhosts
72 8@bV,A:- accreg
73 8@bV,F: - chanreg
74 8@4l,F: - history
75 7@cy,_: - defcon
76 - massmessageEz@UW,z3NyD;
--- a/internal/ergo/ircdconfig_test.go
+++ b/internal/ergo/ircdconfig_test.go
@@ -0,0 +1,95 @@
1
+package ergo_test
2
+
3
+import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/conflicthq/scuttlebot/internal/config"
8
+ "github.com/conflicthq/scuttlebot/internal/ergo"
9
+)
10
+
11
+func TestGenerateConfig(t *testing.T) {
12
+ cfg := config.ErgoConfig{
13
+ NetworkName: "testnet",
14
+ ServerName: "irc.test.local",
15
+ IRCAddr: "127.0.0.1:6667",
16
+ DataDir: "/tmp/ergo",
17
+ APIAddr: "127.0.0.1:8089",
18
+ APIToken: "test-token-abc123",
19
+ }
20
+
21
+ data, err := ergo.GenerateConfig(cfg)
22
+ if err != nil {
23
+ t.Fatalf("GenerateConfig: %v", err)
24
+ }
25
+
26
+ yaml := string(data)
27
+
28
+ cases := []struct {
29
+ field string
30
+ want string
31
+ }{
32
+ {"network name", "name: testnet"},
33
+ {"server name", "name: irc.test.local"},
34
+ {"irc addr", `"127.0.0.1:6667"`},
35
+ {"data dir", "/tmp/ergo/ircd.db"},
36
+ {"api addr", `"127.0.0.1:8089"`},
37
+ {"api token", "test-token-abc123"},
38
+ {"api enabled", "enabled: true"},
39
+ }
40
+
41
+ for _, tc := range cases {
42
+ t.Run(tc.field, func(t *testing.T) {
43
+ if !strings.Contains(yaml, tc.want) {
44
+ t.Errorf("generated config missing %q\ngot:\n%s", tc.want, yaml)
45
+ }
46
+ })
47
+ }
48
+}
49
+
50
+func TestGenerateConfigWithPostgresHistory(t *testing.T) {
51
+ cfg := config.ErgoConfig{
52
+ NetworkName: "testnet",
53
+ ServerName: "irc.test.local",
54
+ IRCAddr: "127.0.0.1:6667",
55
+ DataDir: "/tmp/ergo",
56
+ APIAddr: "127.0.0.1:8089",
57
+ APIToken: "tok",
58
+ History: config.HistoryConfig{
59
+ Enabled: true,
60
+ PostgresDSN: "postgres://ergo:pass@localhost/ergo_history",
61
+ },
62
+ }
63
+
64
+ data, err := ergo.GenerateConfig(cfg)
65
+ if err != nil {
66
+ t.Fatalf("GenerateConfig: %v", err)
67
+ }
68
+
69
+ yaml := string(data)
70
+ if !strings.Contains(yaml, "enabled: true") {
71
+ t.Error("expected history enabled")
72
+ }
73
+ if !strings.Contains(yaml, "postgres://ergo:pass@localhost/ergo_history") {
74
+ t.Error("expected postgres DSN in config")
75
+ }
76
+}
77
+
78
+func TestGenerateConfigNoHistory(t *testing.T) {
79
+ cfg := config.ErgoConfig{
80
+ NetworkName: "testnet",
81
+ ServerName: "irc.test.local",
82
+ IRCAddr: "127.0.0.1:6667",
83
+ DataDir: "/tmp/ergo",
84
+ APIAddr: "127.0.0.1:8089",
85
+ APIToken: "tok",
86
+ }
87
+
88
+ data, err := ergo.GenerateConfig(cfg)
89
+ if err != nil {
90
+ t.Fatalf("GenerateConfig: %v", err)
91
+ }
92
+
93
+ yaml := string(data)
94
+ if strings.Contains(yaml, "postgres") {
95
+ t.Error("postgres config should not appear wh
--- a/internal/ergo/ircdconfig_test.go
+++ b/internal/ergo/ircdconfig_test.go
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/ergo/ircdconfig_test.go
+++ b/internal/ergo/ircdconfig_test.go
@@ -0,0 +1,95 @@
1 package ergo_test
2
3 import (
4 "strings"
5 "testing"
6
7 "github.com/conflicthq/scuttlebot/internal/config"
8 "github.com/conflicthq/scuttlebot/internal/ergo"
9 )
10
11 func TestGenerateConfig(t *testing.T) {
12 cfg := config.ErgoConfig{
13 NetworkName: "testnet",
14 ServerName: "irc.test.local",
15 IRCAddr: "127.0.0.1:6667",
16 DataDir: "/tmp/ergo",
17 APIAddr: "127.0.0.1:8089",
18 APIToken: "test-token-abc123",
19 }
20
21 data, err := ergo.GenerateConfig(cfg)
22 if err != nil {
23 t.Fatalf("GenerateConfig: %v", err)
24 }
25
26 yaml := string(data)
27
28 cases := []struct {
29 field string
30 want string
31 }{
32 {"network name", "name: testnet"},
33 {"server name", "name: irc.test.local"},
34 {"irc addr", `"127.0.0.1:6667"`},
35 {"data dir", "/tmp/ergo/ircd.db"},
36 {"api addr", `"127.0.0.1:8089"`},
37 {"api token", "test-token-abc123"},
38 {"api enabled", "enabled: true"},
39 }
40
41 for _, tc := range cases {
42 t.Run(tc.field, func(t *testing.T) {
43 if !strings.Contains(yaml, tc.want) {
44 t.Errorf("generated config missing %q\ngot:\n%s", tc.want, yaml)
45 }
46 })
47 }
48 }
49
50 func TestGenerateConfigWithPostgresHistory(t *testing.T) {
51 cfg := config.ErgoConfig{
52 NetworkName: "testnet",
53 ServerName: "irc.test.local",
54 IRCAddr: "127.0.0.1:6667",
55 DataDir: "/tmp/ergo",
56 APIAddr: "127.0.0.1:8089",
57 APIToken: "tok",
58 History: config.HistoryConfig{
59 Enabled: true,
60 PostgresDSN: "postgres://ergo:pass@localhost/ergo_history",
61 },
62 }
63
64 data, err := ergo.GenerateConfig(cfg)
65 if err != nil {
66 t.Fatalf("GenerateConfig: %v", err)
67 }
68
69 yaml := string(data)
70 if !strings.Contains(yaml, "enabled: true") {
71 t.Error("expected history enabled")
72 }
73 if !strings.Contains(yaml, "postgres://ergo:pass@localhost/ergo_history") {
74 t.Error("expected postgres DSN in config")
75 }
76 }
77
78 func TestGenerateConfigNoHistory(t *testing.T) {
79 cfg := config.ErgoConfig{
80 NetworkName: "testnet",
81 ServerName: "irc.test.local",
82 IRCAddr: "127.0.0.1:6667",
83 DataDir: "/tmp/ergo",
84 APIAddr: "127.0.0.1:8089",
85 APIToken: "tok",
86 }
87
88 data, err := ergo.GenerateConfig(cfg)
89 if err != nil {
90 t.Fatalf("GenerateConfig: %v", err)
91 }
92
93 yaml := string(data)
94 if strings.Contains(yaml, "postgres") {
95 t.Error("postgres config should not appear wh
--- a/internal/ergo/manager.go
+++ b/internal/ergo/manager.go
@@ -0,0 +1,65 @@
1
+package ergo
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "log/slog"
7
+ "os"
8
+ "os/exec"
9
+ "path/filepath"
10
+ "time"
11
+
12
+ "github.com/conflicthq/scuttlebot/internal/config"
13
+)
14
+
15
+const (
16
+ ircdConfigFile = "ircd.yaml"
17
+ restartBaseWait = 2 * time.Second
18
+ restartMaxWait = 60 * time.Second
19
+ healthTimeout = 30 * time.Second
20
+ healthInterval = 500 * time.Millisecond
21
+)
22
+
23
+// Manager manages the Ergo IRC server subprocess.
24
+type Manager struct {
25
+ cfg config.ErgoConfig
26
+ api *APIClient
27
+ log *slog.Logger
28
+}
29
+
30
+// NewManager creates a new Manager. Call Start to launch the Ergo process.
31
+func NewManager(cfg config.ErgoConfig, log *slog.Logger) *Manager {
32
+ return &Manager{
33
+ cfg: cfg,
34
+ api: NewAPIClient(cfg.APIAddr, cfg.APIToken),
35
+ log: log,
36
+ }
37
+}
38
+
39
+// API returns the Ergo HTTP API client. Available after Start succeeds.
40
+func (m *Manager) API() *APIClient {
41
+ return m.api
42
+}
43
+
44
+// Start writes the Ergo confand waits for it to
45
+// become healthy. It then runs the process in the background, restarting it
46
+//lth, then keeps it
47
+// alivif it exits unexpectedly. Blocks until the
48
+// context is cancelledy: %w", err)
49
+ }
50
+ m.lStartarting ergo", "binary", m.cfg.BinaryPath)
51
+ cmd := exec.CommandContextreturn, m.cfg.BinaryPath, {
52
+ return fmt.Errorf("ergo: start process: %w", err)
53
+ }
54
+
55
+ if err := m.waitHealthy(ctx); err != nil {
56
+ _ = cmd.Process.Kill()
57
+ return fmt.Errorf("ergo: did not become healthy: %w", err)
58
+ }
59
+ m.log.Info("ergo is healthy")
60
+ wait = restartBaseWait // reset backoff on successful start
61
+
62
+ // Wait for process exit.
63
+ done := make(chan error, 1)
64
+ go func() {ternal waits fl {
65
+ return fmt.Er
--- a/internal/ergo/manager.go
+++ b/internal/ergo/manager.go
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/ergo/manager.go
+++ b/internal/ergo/manager.go
@@ -0,0 +1,65 @@
1 package ergo
2
3 import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "time"
11
12 "github.com/conflicthq/scuttlebot/internal/config"
13 )
14
15 const (
16 ircdConfigFile = "ircd.yaml"
17 restartBaseWait = 2 * time.Second
18 restartMaxWait = 60 * time.Second
19 healthTimeout = 30 * time.Second
20 healthInterval = 500 * time.Millisecond
21 )
22
23 // Manager manages the Ergo IRC server subprocess.
24 type Manager struct {
25 cfg config.ErgoConfig
26 api *APIClient
27 log *slog.Logger
28 }
29
30 // NewManager creates a new Manager. Call Start to launch the Ergo process.
31 func NewManager(cfg config.ErgoConfig, log *slog.Logger) *Manager {
32 return &Manager{
33 cfg: cfg,
34 api: NewAPIClient(cfg.APIAddr, cfg.APIToken),
35 log: log,
36 }
37 }
38
39 // API returns the Ergo HTTP API client. Available after Start succeeds.
40 func (m *Manager) API() *APIClient {
41 return m.api
42 }
43
44 // Start writes the Ergo confand waits for it to
45 // become healthy. It then runs the process in the background, restarting it
46 //lth, then keeps it
47 // alivif it exits unexpectedly. Blocks until the
48 // context is cancelledy: %w", err)
49 }
50 m.lStartarting ergo", "binary", m.cfg.BinaryPath)
51 cmd := exec.CommandContextreturn, m.cfg.BinaryPath, {
52 return fmt.Errorf("ergo: start process: %w", err)
53 }
54
55 if err := m.waitHealthy(ctx); err != nil {
56 _ = cmd.Process.Kill()
57 return fmt.Errorf("ergo: did not become healthy: %w", err)
58 }
59 m.log.Info("ergo is healthy")
60 wait = restartBaseWait // reset backoff on successful start
61
62 // Wait for process exit.
63 done := make(chan error, 1)
64 go func() {ternal waits fl {
65 return fmt.Er

Keyboard Shortcuts

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