|
1
|
package main |
|
2
|
|
|
3
|
import ( |
|
4
|
"context" |
|
5
|
"crypto/rand" |
|
6
|
"crypto/tls" |
|
7
|
"encoding/hex" |
|
8
|
"flag" |
|
9
|
"fmt" |
|
10
|
"log/slog" |
|
11
|
"net/http" |
|
12
|
"os" |
|
13
|
"os/signal" |
|
14
|
"path/filepath" |
|
15
|
"strings" |
|
16
|
"syscall" |
|
17
|
"time" |
|
18
|
|
|
19
|
"golang.org/x/crypto/acme/autocert" |
|
20
|
|
|
21
|
"github.com/conflicthq/scuttlebot/internal/api" |
|
22
|
"github.com/conflicthq/scuttlebot/internal/auth" |
|
23
|
"github.com/conflicthq/scuttlebot/internal/bots/bridge" |
|
24
|
botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager" |
|
25
|
"github.com/conflicthq/scuttlebot/internal/config" |
|
26
|
"github.com/conflicthq/scuttlebot/internal/ergo" |
|
27
|
"github.com/conflicthq/scuttlebot/internal/mcp" |
|
28
|
"github.com/conflicthq/scuttlebot/internal/registry" |
|
29
|
"github.com/conflicthq/scuttlebot/internal/store" |
|
30
|
"github.com/conflicthq/scuttlebot/internal/topology" |
|
31
|
) |
|
32
|
|
|
33
|
var version = "dev" |
|
34
|
|
|
35
|
func main() { |
|
36
|
configPath := flag.String("config", "scuttlebot.yaml", "path to config file (YAML)") |
|
37
|
flag.Parse() |
|
38
|
|
|
39
|
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) |
|
40
|
|
|
41
|
cfg := &config.Config{} |
|
42
|
cfg.Defaults() |
|
43
|
if err := cfg.LoadFile(*configPath); err != nil { |
|
44
|
log.Error("load config", "path", *configPath, "err", err) |
|
45
|
os.Exit(1) |
|
46
|
} |
|
47
|
cfg.ApplyEnv() |
|
48
|
|
|
49
|
// In managed mode, auto-fetch the ergo binary if not found. |
|
50
|
if !cfg.Ergo.External { |
|
51
|
binary, err := ergo.EnsureBinary(cfg.Ergo.BinaryPath, cfg.Ergo.DataDir) |
|
52
|
if err != nil { |
|
53
|
log.Error("ergo binary unavailable", "err", err) |
|
54
|
os.Exit(1) |
|
55
|
} |
|
56
|
abs, err := filepath.Abs(binary) |
|
57
|
if err != nil { |
|
58
|
log.Error("resolve ergo binary path", "err", err) |
|
59
|
os.Exit(1) |
|
60
|
} |
|
61
|
cfg.Ergo.BinaryPath = abs |
|
62
|
} |
|
63
|
|
|
64
|
// Load or generate a stable Ergo management API token. |
|
65
|
// We persist it to data/ergo-api-token so it survives restarts — without |
|
66
|
// this the token changes every launch and the NickServ password-rotation |
|
67
|
// API call fails with 401 because ergo already loaded the old token. |
|
68
|
ergoTokenPath := filepath.Join(cfg.Ergo.DataDir, "ergo-api-token") |
|
69
|
if cfg.Ergo.APIToken == "" { |
|
70
|
if raw, err := os.ReadFile(ergoTokenPath); err == nil && len(raw) > 0 { |
|
71
|
cfg.Ergo.APIToken = strings.TrimSpace(string(raw)) |
|
72
|
} else { |
|
73
|
cfg.Ergo.APIToken = mustGenToken() |
|
74
|
_ = os.WriteFile(ergoTokenPath, []byte(cfg.Ergo.APIToken), 0600) |
|
75
|
} |
|
76
|
} |
|
77
|
|
|
78
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
|
79
|
defer cancel() |
|
80
|
|
|
81
|
log.Info("scuttlebot starting", "version", version) |
|
82
|
|
|
83
|
// Start Ergo. |
|
84
|
ergoMgr := ergo.NewManager(cfg.Ergo, log) |
|
85
|
ergoErr := make(chan error, 1) |
|
86
|
go func() { |
|
87
|
if err := ergoMgr.Start(ctx); err != nil { |
|
88
|
ergoErr <- err |
|
89
|
} |
|
90
|
}() |
|
91
|
|
|
92
|
// Wait for Ergo to become healthy before starting the rest. |
|
93
|
healthCtx, healthCancel := context.WithTimeout(ctx, 30*time.Second) |
|
94
|
defer healthCancel() |
|
95
|
for { |
|
96
|
if _, err := ergoMgr.API().Status(); err == nil { |
|
97
|
break |
|
98
|
} |
|
99
|
select { |
|
100
|
case <-healthCtx.Done(): |
|
101
|
log.Error("ergo did not become healthy in time") |
|
102
|
os.Exit(1) |
|
103
|
case err := <-ergoErr: |
|
104
|
log.Error("ergo failed to start", "err", err) |
|
105
|
os.Exit(1) |
|
106
|
case <-time.After(500 * time.Millisecond): |
|
107
|
} |
|
108
|
} |
|
109
|
log.Info("ergo healthy") |
|
110
|
|
|
111
|
// Open datastore if configured (SQLite or PostgreSQL). |
|
112
|
// When not configured, all stores fall back to JSON files in data/. |
|
113
|
var dataStore *store.Store |
|
114
|
if cfg.Datastore.Driver != "" && cfg.Datastore.DSN != "" { |
|
115
|
ds, err := store.Open(cfg.Datastore.Driver, cfg.Datastore.DSN) |
|
116
|
if err != nil { |
|
117
|
log.Error("datastore open", "driver", cfg.Datastore.Driver, "err", err) |
|
118
|
os.Exit(1) |
|
119
|
} |
|
120
|
defer ds.Close() |
|
121
|
dataStore = ds |
|
122
|
log.Info("datastore open", "driver", cfg.Datastore.Driver) |
|
123
|
} |
|
124
|
|
|
125
|
// Build registry backed by Ergo's NickServ API. |
|
126
|
// Signing key persists so issued payloads stay valid across restarts. |
|
127
|
signingKeyHex, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "signing_key")) |
|
128
|
if err != nil { |
|
129
|
log.Error("signing key", "err", err) |
|
130
|
os.Exit(1) |
|
131
|
} |
|
132
|
reg := registry.New(ergoMgr.API(), []byte(signingKeyHex)) |
|
133
|
if dataStore != nil { |
|
134
|
if err := reg.SetStore(dataStore); err != nil { |
|
135
|
log.Error("registry load from store", "err", err) |
|
136
|
os.Exit(1) |
|
137
|
} |
|
138
|
} else if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil { |
|
139
|
log.Error("registry load", "err", err) |
|
140
|
os.Exit(1) |
|
141
|
} |
|
142
|
|
|
143
|
// API key store — per-consumer tokens with scoped permissions. |
|
144
|
apiKeyStore, err := auth.NewAPIKeyStore(filepath.Join(cfg.Ergo.DataDir, "api_keys.json")) |
|
145
|
if err != nil { |
|
146
|
log.Error("api key store", "err", err) |
|
147
|
os.Exit(1) |
|
148
|
} |
|
149
|
// Migrate legacy api_token into key store on first run. |
|
150
|
if apiKeyStore.IsEmpty() { |
|
151
|
apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token")) |
|
152
|
if err != nil { |
|
153
|
log.Error("api token", "err", err) |
|
154
|
os.Exit(1) |
|
155
|
} |
|
156
|
if _, err := apiKeyStore.Insert("server", apiToken, []auth.Scope{auth.ScopeAdmin}); err != nil { |
|
157
|
log.Error("migrate api token to key store", "err", err) |
|
158
|
os.Exit(1) |
|
159
|
} |
|
160
|
log.Info("migrated api_token to api_keys.json", "token", apiToken) |
|
161
|
} else { |
|
162
|
log.Info("api key store loaded", "keys", len(apiKeyStore.List())) |
|
163
|
} |
|
164
|
|
|
165
|
// Start bridge bot (powers the web chat UI). |
|
166
|
var bridgeBot *bridge.Bot |
|
167
|
if cfg.Bridge.Enabled { |
|
168
|
if cfg.Bridge.Password == "" { |
|
169
|
cfg.Bridge.Password = mustGenToken() |
|
170
|
} |
|
171
|
// Ensure the bridge's NickServ account exists with the current password. |
|
172
|
if err := ergoMgr.API().RegisterAccount(cfg.Bridge.Nick, cfg.Bridge.Password); err != nil { |
|
173
|
// Account exists from a previous run — update the password so it matches. |
|
174
|
if err2 := ergoMgr.API().ChangePassword(cfg.Bridge.Nick, cfg.Bridge.Password); err2 != nil { |
|
175
|
log.Error("bridge account setup failed", "err", err2) |
|
176
|
os.Exit(1) |
|
177
|
} |
|
178
|
} |
|
179
|
bridgeBot = bridge.New( |
|
180
|
cfg.Ergo.IRCAddr, |
|
181
|
cfg.Bridge.Nick, |
|
182
|
cfg.Bridge.Password, |
|
183
|
cfg.Bridge.Channels, |
|
184
|
cfg.Bridge.BufferSize, |
|
185
|
time.Duration(cfg.Bridge.WebUserTTLMinutes)*time.Minute, |
|
186
|
log, |
|
187
|
) |
|
188
|
go func() { |
|
189
|
if err := bridgeBot.Start(ctx); err != nil { |
|
190
|
log.Error("bridge bot error", "err", err) |
|
191
|
} |
|
192
|
}() |
|
193
|
} |
|
194
|
|
|
195
|
// Topology manager — provisions static channels and enforces autojoin policy. |
|
196
|
var topoMgr *topology.Manager |
|
197
|
if len(cfg.Topology.Channels) > 0 || len(cfg.Topology.Types) > 0 { |
|
198
|
topoPolicy := topology.NewPolicy(cfg.Topology) |
|
199
|
topoPass := mustGenToken() |
|
200
|
if err := ergoMgr.API().RegisterAccount(cfg.Topology.Nick, topoPass); err != nil { |
|
201
|
if err2 := ergoMgr.API().ChangePassword(cfg.Topology.Nick, topoPass); err2 != nil { |
|
202
|
log.Error("topology account setup failed", "err", err2) |
|
203
|
os.Exit(1) |
|
204
|
} |
|
205
|
} |
|
206
|
topoMgr = topology.NewManager(cfg.Ergo.IRCAddr, cfg.Topology.Nick, topoPass, cfg.Ergo.APIToken, topoPolicy, log) |
|
207
|
topoCtx, topoCancel := context.WithTimeout(ctx, 30*time.Second) |
|
208
|
if err := topoMgr.Connect(topoCtx); err != nil { |
|
209
|
topoCancel() |
|
210
|
log.Error("topology manager connect failed", "err", err) |
|
211
|
os.Exit(1) |
|
212
|
} |
|
213
|
topoCancel() |
|
214
|
staticChannels := make([]topology.ChannelConfig, 0, len(cfg.Topology.Channels)) |
|
215
|
for _, sc := range cfg.Topology.Channels { |
|
216
|
staticChannels = append(staticChannels, topology.ChannelConfig{ |
|
217
|
Name: sc.Name, |
|
218
|
Topic: sc.Topic, |
|
219
|
Ops: sc.Ops, |
|
220
|
Voice: sc.Voice, |
|
221
|
Autojoin: sc.Autojoin, |
|
222
|
Modes: sc.Modes, |
|
223
|
}) |
|
224
|
} |
|
225
|
go func() { |
|
226
|
if err := topoMgr.Provision(staticChannels); err != nil { |
|
227
|
log.Error("topology provision failed", "err", err) |
|
228
|
} |
|
229
|
}() |
|
230
|
topoMgr.StartReaper(ctx) |
|
231
|
go func() { |
|
232
|
<-ctx.Done() |
|
233
|
topoMgr.Close() |
|
234
|
}() |
|
235
|
} |
|
236
|
|
|
237
|
// Policy store — persists behavior/agent/logging settings. |
|
238
|
policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes) |
|
239
|
if err != nil { |
|
240
|
log.Error("policy store", "err", err) |
|
241
|
os.Exit(1) |
|
242
|
} |
|
243
|
if dataStore != nil { |
|
244
|
if err := policyStore.SetStore(dataStore); err != nil { |
|
245
|
log.Error("policy store load from db", "err", err) |
|
246
|
os.Exit(1) |
|
247
|
} |
|
248
|
} |
|
249
|
if bridgeBot != nil { |
|
250
|
bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute) |
|
251
|
// Deliver on-join instructions when agents join channels. |
|
252
|
// Skip system bots to avoid flooding the bridge IRC connection on startup. |
|
253
|
bridgeBot.SetOnUserJoin(func(channel, nick string) { |
|
254
|
// Don't send on-join to system bots — they already know what to do. |
|
255
|
systemBots := map[string]bool{ |
|
256
|
"bridge": true, "auditbot": true, "scribe": true, "herald": true, |
|
257
|
"oracle": true, "warden": true, "scroll": true, "systembot": true, |
|
258
|
"snitch": true, "sentinel": true, "steward": true, "shepherd": true, |
|
259
|
"topology": true, |
|
260
|
} |
|
261
|
if systemBots[nick] { |
|
262
|
return |
|
263
|
} |
|
264
|
p := policyStore.Get() |
|
265
|
msg, ok := p.OnJoinMessages[channel] |
|
266
|
if !ok || msg == "" { |
|
267
|
return |
|
268
|
} |
|
269
|
msg = strings.ReplaceAll(msg, "{nick}", nick) |
|
270
|
msg = strings.ReplaceAll(msg, "{channel}", channel) |
|
271
|
for _, line := range strings.Split(msg, "\n") { |
|
272
|
if line != "" { |
|
273
|
bridgeBot.Notice(nick, line) |
|
274
|
} |
|
275
|
} |
|
276
|
}) |
|
277
|
} |
|
278
|
|
|
279
|
// Admin store — bcrypt-hashed admin accounts. |
|
280
|
adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json")) |
|
281
|
if err != nil { |
|
282
|
log.Error("admin store", "err", err) |
|
283
|
os.Exit(1) |
|
284
|
} |
|
285
|
if dataStore != nil { |
|
286
|
if err := adminStore.SetStore(dataStore); err != nil { |
|
287
|
log.Error("admin store load from db", "err", err) |
|
288
|
os.Exit(1) |
|
289
|
} |
|
290
|
} |
|
291
|
if adminStore.IsEmpty() { |
|
292
|
password := mustGenToken()[:16] |
|
293
|
if err := adminStore.Add("admin", password); err != nil { |
|
294
|
log.Error("create default admin", "err", err) |
|
295
|
os.Exit(1) |
|
296
|
} |
|
297
|
log.Info("first run — default admin created", "username", "admin", "password", password, "action", "change this password immediately") |
|
298
|
} |
|
299
|
|
|
300
|
// Bot manager — starts/stops system bots based on policy. |
|
301
|
botMgr := botmanager.New(cfg.Ergo.IRCAddr, cfg.Ergo.DataDir, ergoMgr.API(), &ergoChannelListAdapter{ergoMgr.API()}, log) |
|
302
|
|
|
303
|
// Wire policy onChange to re-sync bots on every policy update. |
|
304
|
policyStore.OnChange(func(p api.Policies) { |
|
305
|
specs := make([]botmanager.BotSpec, len(p.Behaviors)) |
|
306
|
for i, b := range p.Behaviors { |
|
307
|
specs[i] = botmanager.BotSpec{ |
|
308
|
ID: b.ID, |
|
309
|
Nick: b.Nick, |
|
310
|
Enabled: b.Enabled, |
|
311
|
JoinAllChannels: b.JoinAllChannels, |
|
312
|
RequiredChannels: b.RequiredChannels, |
|
313
|
Config: b.Config, |
|
314
|
} |
|
315
|
} |
|
316
|
if bridgeBot != nil { |
|
317
|
bridgeBot.SetWebUserTTL(time.Duration(p.Bridge.WebUserTTLMinutes) * time.Minute) |
|
318
|
} |
|
319
|
if p.AgentPolicy.OnlineTimeoutSecs > 0 { |
|
320
|
reg.SetOnlineTimeout(time.Duration(p.AgentPolicy.OnlineTimeoutSecs) * time.Second) |
|
321
|
} |
|
322
|
botMgr.Sync(ctx, specs) |
|
323
|
}) |
|
324
|
|
|
325
|
// Initial bot sync — delayed to let the bridge create channels first. |
|
326
|
// Bots with join_all_channels need channels to exist before they query |
|
327
|
// the channel list. The bridge takes ~2s to connect and join. |
|
328
|
go func() { |
|
329
|
time.Sleep(5 * time.Second) |
|
330
|
p := policyStore.Get() |
|
331
|
specs := make([]botmanager.BotSpec, len(p.Behaviors)) |
|
332
|
for i, b := range p.Behaviors { |
|
333
|
specs[i] = botmanager.BotSpec{ |
|
334
|
ID: b.ID, |
|
335
|
Nick: b.Nick, |
|
336
|
Enabled: b.Enabled, |
|
337
|
JoinAllChannels: b.JoinAllChannels, |
|
338
|
RequiredChannels: b.RequiredChannels, |
|
339
|
Config: b.Config, |
|
340
|
} |
|
341
|
} |
|
342
|
botMgr.Sync(ctx, specs) |
|
343
|
}() |
|
344
|
|
|
345
|
// Agent reaper — periodically removes stale agents based on policy. |
|
346
|
go func() { |
|
347
|
ticker := time.NewTicker(1 * time.Hour) |
|
348
|
defer ticker.Stop() |
|
349
|
for { |
|
350
|
select { |
|
351
|
case <-ctx.Done(): |
|
352
|
return |
|
353
|
case <-ticker.C: |
|
354
|
p := policyStore.Get() |
|
355
|
if p.AgentPolicy.ReapAfterDays > 0 { |
|
356
|
maxAge := time.Duration(p.AgentPolicy.ReapAfterDays) * 24 * time.Hour |
|
357
|
if n := reg.Reap(maxAge); n > 0 { |
|
358
|
log.Info("reaped stale agents", "count", n, "max_age_days", p.AgentPolicy.ReapAfterDays) |
|
359
|
} |
|
360
|
} |
|
361
|
} |
|
362
|
} |
|
363
|
}() |
|
364
|
|
|
365
|
// Config store — owns write-back to scuttlebot.yaml with history snapshots. |
|
366
|
cfgStore := api.NewConfigStore(*configPath, *cfg) |
|
367
|
cfgStore.OnChange(func(updated config.Config) { |
|
368
|
// Hot-reload topology on config change. |
|
369
|
if topoMgr != nil { |
|
370
|
staticChannels := make([]topology.ChannelConfig, 0, len(updated.Topology.Channels)) |
|
371
|
for _, sc := range updated.Topology.Channels { |
|
372
|
staticChannels = append(staticChannels, topology.ChannelConfig{ |
|
373
|
Name: sc.Name, Topic: sc.Topic, |
|
374
|
Ops: sc.Ops, Voice: sc.Voice, Autojoin: sc.Autojoin, |
|
375
|
Modes: sc.Modes, |
|
376
|
}) |
|
377
|
} |
|
378
|
if err := topoMgr.Provision(staticChannels); err != nil { |
|
379
|
log.Error("topology hot-reload failed", "err", err) |
|
380
|
} |
|
381
|
} |
|
382
|
// Hot-reload bridge web TTL. |
|
383
|
if bridgeBot != nil { |
|
384
|
bridgeBot.SetWebUserTTL(time.Duration(updated.Bridge.WebUserTTLMinutes) * time.Minute) |
|
385
|
} |
|
386
|
// Regenerate ircd.yaml and rehash Ergo on config changes. |
|
387
|
if ergoMgr != nil { |
|
388
|
if err := ergoMgr.UpdateConfig(updated.Ergo); err != nil { |
|
389
|
log.Error("ergo config hot-reload failed", "err", err) |
|
390
|
} else { |
|
391
|
log.Info("ergo config reloaded") |
|
392
|
} |
|
393
|
} |
|
394
|
}) |
|
395
|
|
|
396
|
// Start HTTP REST API server. |
|
397
|
var llmCfg *config.LLMConfig |
|
398
|
if len(cfg.LLM.Backends) > 0 { |
|
399
|
llmCfg = &cfg.LLM |
|
400
|
} |
|
401
|
// Pass an explicit nil interface when topology is not configured. |
|
402
|
// A nil *topology.Manager passed as a topologyManager interface is |
|
403
|
// non-nil (Go nil interface trap) and causes panics in setAgentModes. |
|
404
|
var topoIface api.TopologyManager |
|
405
|
if topoMgr != nil { |
|
406
|
topoIface = topoMgr |
|
407
|
} |
|
408
|
apiSrv := api.New(reg, apiKeyStore, bridgeBot, policyStore, adminStore, llmCfg, topoIface, cfgStore, cfg.TLS.Domain, log) |
|
409
|
handler := apiSrv.Handler() |
|
410
|
|
|
411
|
var httpServer, tlsServer *http.Server |
|
412
|
|
|
413
|
if cfg.TLS.Domain != "" { |
|
414
|
certDir := cfg.TLS.CertDir |
|
415
|
if certDir == "" { |
|
416
|
certDir = filepath.Join(cfg.Ergo.DataDir, "certs") |
|
417
|
} |
|
418
|
if err := os.MkdirAll(certDir, 0700); err != nil { |
|
419
|
log.Error("create cert dir", "err", err) |
|
420
|
os.Exit(1) |
|
421
|
} |
|
422
|
|
|
423
|
m := &autocert.Manager{ |
|
424
|
Cache: autocert.DirCache(certDir), |
|
425
|
Prompt: autocert.AcceptTOS, |
|
426
|
Email: cfg.TLS.Email, |
|
427
|
HostPolicy: autocert.HostWhitelist(cfg.TLS.Domain), |
|
428
|
} |
|
429
|
|
|
430
|
// HTTPS on :443 |
|
431
|
tlsServer = &http.Server{ |
|
432
|
Addr: ":443", |
|
433
|
Handler: handler, |
|
434
|
TLSConfig: &tls.Config{GetCertificate: m.GetCertificate}, |
|
435
|
} |
|
436
|
go func() { |
|
437
|
log.Info("api server listening (TLS)", "addr", ":443", "domain", cfg.TLS.Domain) |
|
438
|
if err := tlsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { |
|
439
|
log.Error("tls server error", "err", err) |
|
440
|
} |
|
441
|
}() |
|
442
|
|
|
443
|
// HTTP on :80 — ACME challenge always enabled; also serves API when AllowInsecure. |
|
444
|
var httpHandler http.Handler |
|
445
|
if cfg.TLS.AllowInsecure { |
|
446
|
httpHandler = m.HTTPHandler(handler) |
|
447
|
} else { |
|
448
|
httpHandler = m.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
|
449
|
http.Redirect(w, r, "https://"+cfg.TLS.Domain+r.RequestURI, http.StatusMovedPermanently) |
|
450
|
})) |
|
451
|
} |
|
452
|
httpServer = &http.Server{Addr: ":80", Handler: httpHandler} |
|
453
|
go func() { |
|
454
|
log.Info("http server listening", "addr", ":80", "insecure", cfg.TLS.AllowInsecure) |
|
455
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
|
456
|
log.Error("http server error", "err", err) |
|
457
|
} |
|
458
|
}() |
|
459
|
} else { |
|
460
|
// No TLS — plain HTTP on configured addr. |
|
461
|
httpServer = &http.Server{ |
|
462
|
Addr: cfg.APIAddr, |
|
463
|
Handler: handler, |
|
464
|
} |
|
465
|
go func() { |
|
466
|
log.Info("api server listening", "addr", httpServer.Addr) |
|
467
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
|
468
|
log.Error("api server error", "err", err) |
|
469
|
} |
|
470
|
}() |
|
471
|
} |
|
472
|
|
|
473
|
// Start MCP server. |
|
474
|
mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, apiKeyStore, log) |
|
475
|
mcpServer := &http.Server{ |
|
476
|
Addr: cfg.MCPAddr, |
|
477
|
Handler: mcpSrv.Handler(), |
|
478
|
} |
|
479
|
go func() { |
|
480
|
log.Info("mcp server listening", "addr", mcpServer.Addr) |
|
481
|
if err := mcpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
|
482
|
log.Error("mcp server error", "err", err) |
|
483
|
} |
|
484
|
}() |
|
485
|
|
|
486
|
<-ctx.Done() |
|
487
|
log.Info("shutting down") |
|
488
|
|
|
489
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) |
|
490
|
defer shutdownCancel() |
|
491
|
if httpServer != nil { |
|
492
|
_ = httpServer.Shutdown(shutdownCtx) |
|
493
|
} |
|
494
|
if tlsServer != nil { |
|
495
|
_ = tlsServer.Shutdown(shutdownCtx) |
|
496
|
} |
|
497
|
_ = mcpServer.Shutdown(shutdownCtx) |
|
498
|
|
|
499
|
log.Info("goodbye") |
|
500
|
} |
|
501
|
|
|
502
|
// ergoChannelListAdapter adapts ergo.APIClient to botmanager.ChannelLister. |
|
503
|
type ergoChannelListAdapter struct { |
|
504
|
api *ergo.APIClient |
|
505
|
} |
|
506
|
|
|
507
|
func (e *ergoChannelListAdapter) ListChannels() ([]string, error) { |
|
508
|
resp, err := e.api.ListChannels() |
|
509
|
if err != nil { |
|
510
|
return nil, err |
|
511
|
} |
|
512
|
out := make([]string, len(resp.Channels)) |
|
513
|
for i, ch := range resp.Channels { |
|
514
|
out[i] = ch.Name |
|
515
|
} |
|
516
|
return out, nil |
|
517
|
} |
|
518
|
|
|
519
|
// ergoChannelLister adapts ergo.APIClient to mcp.ChannelLister. |
|
520
|
type ergoChannelLister struct { |
|
521
|
api *ergo.APIClient |
|
522
|
} |
|
523
|
|
|
524
|
func (e *ergoChannelLister) ListChannels() ([]mcp.ChannelInfo, error) { |
|
525
|
resp, err := e.api.ListChannels() |
|
526
|
if err != nil { |
|
527
|
return nil, err |
|
528
|
} |
|
529
|
out := make([]mcp.ChannelInfo, len(resp.Channels)) |
|
530
|
for i, ch := range resp.Channels { |
|
531
|
out[i] = mcp.ChannelInfo{ |
|
532
|
Name: ch.Name, |
|
533
|
Topic: ch.Topic, |
|
534
|
Count: ch.UserCount, |
|
535
|
} |
|
536
|
} |
|
537
|
return out, nil |
|
538
|
} |
|
539
|
|
|
540
|
func mustGenToken() string { |
|
541
|
b := make([]byte, 24) |
|
542
|
if _, err := rand.Read(b); err != nil { |
|
543
|
fmt.Fprintf(os.Stderr, "failed to generate token: %v\n", err) |
|
544
|
os.Exit(1) |
|
545
|
} |
|
546
|
return hex.EncodeToString(b) |
|
547
|
} |
|
548
|
|
|
549
|
// loadOrCreateToken reads a token from path. If the file doesn't exist it |
|
550
|
// generates a new token, writes it, and returns it. |
|
551
|
func loadOrCreateToken(path string) (string, error) { |
|
552
|
data, err := os.ReadFile(path) |
|
553
|
if err == nil { |
|
554
|
t := strings.TrimSpace(string(data)) |
|
555
|
if t != "" { |
|
556
|
return t, nil |
|
557
|
} |
|
558
|
} |
|
559
|
if !os.IsNotExist(err) && err != nil { |
|
560
|
return "", fmt.Errorf("read token %s: %w", path, err) |
|
561
|
} |
|
562
|
token := mustGenToken() |
|
563
|
if err := os.WriteFile(path, []byte(token+"\n"), 0600); err != nil { |
|
564
|
return "", fmt.Errorf("write token %s: %w", path, err) |
|
565
|
} |
|
566
|
return token, nil |
|
567
|
} |
|
568
|
|