• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / tg-spam / 24966212061

26 Apr 2026 08:23PM UTC coverage: 83.078% (+0.1%) from 82.966%
24966212061

Pull #294

github

web-flow
fix: preserve InstanceID across loadConfigFromDB swap; rename collided subtests (#397)

* fix: preserve InstanceID across loadConfigFromDB swap; rename collided subtests

`loadConfigFromDB` does `*settings = *dbSettings` which clobbers the
CLI/env-supplied `InstanceID` with whatever the persisted blob carries.
External orchestrators that write per-instance config blobs without
embedding `instance_id` (e.g. tg-spam-manager) leave it empty, so after
the swap `settings.InstanceID == ""`. Initial load works because the
short-lived store inside loadConfigFromDB was already keyed by the
still-CLI value, but every subsequent `makeDB` call in `activateServer`
opens with `gid=""` — including the runtime SettingsStore.

Symptoms: `POST /config/reload` returns
`500 "no settings found in database: sql: no rows in result set"` from a
clean state. The first UI Save with `saveToDb=true` then writes a fresh
row under `gid=""`, so subsequent reloads succeed against a
manager-orphaned row, leaving manager-side updates and bot-side updates
on different rows.

Fix: snapshot `settings.InstanceID` before the swap and restore it when
the loaded blob's value is empty. A blob that does carry its own
non-empty `InstanceID` (saved by tg-spam itself) is still trusted, so
existing single-binary deployments are unaffected.

Same patch also gives unique names to the three subtests in
`TestDetector_CheckOpenAI` that collided post-master-merge — Go was
running them under `#01` suffixes which made `-run` filtering ambiguous.
Two are byte-identical to their earlier siblings; the third differs
slightly (uses `spam` instead of `viagra` and asserts the LoadStopWords
result), and the new name reflects that.

* add WARN on InstanceID divergence + regression test for empty-blob branch

Address review:

- log a [WARN] when the persisted blob carries a non-empty InstanceID
  that differs from the CLI/env value. Behaviour is unchanged (blob
  still wins) but the divergence shifts the runtime gi... (continued)
Pull Request #294: Implement database configuration support

1290 of 1591 new or added lines in 7 files covered. (81.08%)

10 existing lines in 2 files now uncovered.

8174 of 9839 relevant lines covered (83.08%)

232.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.34
/app/webapi/config.go
1
package webapi
2

3
import (
4
        "context"
5
        "fmt"
6
        "net/http"
7
        "strconv"
8
        "strings"
9
        "time"
10

11
        log "github.com/go-pkgz/lgr"
12
        "github.com/go-pkgz/rest"
13

14
        "github.com/umputun/tg-spam/app/config"
15
)
16

17
//go:generate moq --out mocks/settings_store.go --pkg mocks --with-resets --skip-ensure . SettingsStore
18

19
// SettingsStore provides access to configuration stored in database
20
type SettingsStore interface {
21
        Load(ctx context.Context) (*config.Settings, error)
22
        Save(ctx context.Context, settings *config.Settings) error
23
        Delete(ctx context.Context) error
24
        LastUpdated(ctx context.Context) (time.Time, error)
25
        Exists(ctx context.Context) (bool, error)
26
}
27

28
// saveConfigHandler handles POST /config request.
29
// It saves the current configuration to the database.
30
func (s *Server) saveConfigHandler(w http.ResponseWriter, r *http.Request) {
4✔
31
        if s.SettingsStore == nil {
5✔
32
                http.Error(w, "Configuration storage not available", http.StatusInternalServerError)
1✔
33
                return
1✔
34
        }
1✔
35

36
        // save current settings to database; hold the read lock across the call so
37
        // concurrent mutations don't race with JSON encoding in the store
38
        s.appSettingsMu.RLock()
3✔
39
        err := s.SettingsStore.Save(r.Context(), s.AppSettings)
3✔
40
        s.appSettingsMu.RUnlock()
3✔
41
        if err != nil {
4✔
42
                log.Printf("[ERROR] failed to save configuration: %v", err)
1✔
43
                http.Error(w, fmt.Sprintf("Failed to save configuration: %v", err), http.StatusInternalServerError)
1✔
44
                return
1✔
45
        }
1✔
46

47
        if r.Header.Get("HX-Request") == "true" {
3✔
48
                // return a success message for HTMX
1✔
49
                if _, err := w.Write([]byte(`<div class="alert alert-success">Configuration saved successfully</div>`)); err != nil {
1✔
NEW
50
                        log.Printf("[ERROR] failed to write response: %v", err)
×
NEW
51
                }
×
52
                return
1✔
53
        }
54

55
        // return JSON response for API calls
56
        rest.RenderJSON(w, rest.JSON{"status": "ok", "message": "Configuration saved successfully"})
1✔
57
}
58

59
// loadConfigHandler handles POST /config/reload request.
60
// It reloads configuration from the database, replacing in-memory settings.
61
// This is a state-changing action so it must use a non-safe HTTP method:
62
// Go's cross-origin protection middleware treats GET/HEAD/OPTIONS as safe
63
// and lets them through unchecked, which would expose the reload to CSRF.
64
func (s *Server) loadConfigHandler(w http.ResponseWriter, r *http.Request) {
9✔
65
        if s.SettingsStore == nil {
10✔
66
                http.Error(w, "Configuration storage not available", http.StatusInternalServerError)
1✔
67
                return
1✔
68
        }
1✔
69

70
        // hold the write lock across the DB read and memory swap so a concurrent
71
        // updateConfigHandler can't commit a newer snapshot between our Load and
72
        // the assignment below, which would leave memory stale relative to the DB.
73
        s.appSettingsMu.Lock()
8✔
74
        settings, err := s.SettingsStore.Load(r.Context())
8✔
75
        if err != nil {
9✔
76
                s.appSettingsMu.Unlock()
1✔
77
                log.Printf("[ERROR] failed to load configuration: %v", err)
1✔
78
                http.Error(w, fmt.Sprintf("Failed to load configuration: %v", err), http.StatusInternalServerError)
1✔
79
                return
1✔
80
        }
1✔
81

82
        // reapply startup-equivalent normalization: fills any zero fields left by a
83
        // partial/legacy DB blob from the defaults template and reasserts operator-
84
        // supplied operational CLI overrides (--files.dynamic, --files.samples,
85
        // --server.listen, --dry) so reload doesn't silently revert them to DB values.
86
        // run BEFORE transient/auth preservation so the closure can't accidentally
87
        // touch in-memory transient state.
88
        if s.ReloadNormalize != nil {
8✔
89
                s.ReloadNormalize(settings)
1✔
90
        }
1✔
91

92
        // preserve transient settings (never stored in DB). Tokens are NOT preserved:
93
        // in --confdb mode the DB is authoritative for Telegram/OpenAI/Gemini tokens,
94
        // so reload must pick up fresh DB values. Auth hash is preserved only when
95
        // transient.AuthFromCLI is set, which marks an in-memory hash that must
96
        // survive reload (set by applyCLIOverrides for explicit --server.auth/-hash
97
        // flags, and by applyAutoAuthFallback for the auto-generated safety net).
98
        // when auth originated from the DB, fresh DB values win so external hash
99
        // rotations are picked up.
100
        settings.Transient = s.AppSettings.Transient
7✔
101
        if s.AppSettings.Transient.AuthFromCLI {
8✔
102
                settings.Server.AuthHash = s.AppSettings.Server.AuthHash
1✔
103
        }
1✔
104
        s.AppSettings = settings
7✔
105
        s.appSettingsMu.Unlock()
7✔
106

7✔
107
        if r.Header.Get("HX-Request") == "true" {
8✔
108
                // return a success message for HTMX with reload
1✔
109
                w.Header().Set("HX-Refresh", "true") // force page reload to reflect new settings
1✔
110
                if _, err := w.Write([]byte(`<div class="alert alert-success">Configuration loaded successfully. Refreshing page...</div>`)); err != nil {
1✔
NEW
111
                        log.Printf("[ERROR] failed to write response: %v", err)
×
NEW
112
                }
×
113
                return
1✔
114
        }
115

116
        // return JSON response for API calls
117
        rest.RenderJSON(w, rest.JSON{"status": "ok", "message": "Configuration loaded successfully"})
6✔
118
}
119

120
// updateConfigHandler handles PUT /config request.
121
// It updates specific configuration settings in memory and optionally saves to database.
122
func (s *Server) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
11✔
123
        if err := r.ParseForm(); err != nil {
12✔
124
                http.Error(w, fmt.Sprintf("Failed to parse form: %v", err), http.StatusBadRequest)
1✔
125
                return
1✔
126
        }
1✔
127

128
        log.Printf("[DEBUG] updateConfigHandler: saveToDb=%s, SettingsStore=%v", r.FormValue("saveToDb"), s.SettingsStore != nil)
10✔
129

10✔
130
        // hold the write lock across form application and optional DB save so the
10✔
131
        // settings struct can't be observed in a partially updated state and so a
10✔
132
        // concurrent loadConfigHandler can't swap the pointer mid-save.
10✔
133
        s.appSettingsMu.Lock()
10✔
134

10✔
135
        // load-bearing: updateSettingsFromForm must replace slices wholesale, never
10✔
136
        // mutate in place. The snapshot below is a value copy that captures slice
10✔
137
        // headers — if a future change adds in-place slice mutation, a failed save
10✔
138
        // would not roll back the slice contents. Enforced by
10✔
139
        // testUpdateSettingsFromForm_NoInPlaceSliceMutation.
10✔
140
        snapshot := *s.AppSettings
10✔
141
        updateSettingsFromForm(s.AppSettings, r)
10✔
142

10✔
143
        // normalize Lua plugin selection: a freshly-rendered settings page checks
10✔
144
        // every plugin when EnabledPlugins is empty (semantic "all enabled"). If
10✔
145
        // the operator hits Save without unchecking anything, the form posts the
10✔
146
        // full list and would freeze auto-enable for future plugins. Collapse
10✔
147
        // "all available selected" back to nil so the empty-list semantic survives.
10✔
148
        if s.Detector != nil {
10✔
NEW
149
                s.AppSettings.LuaPlugins.EnabledPlugins = normalizeLuaEnabledPlugins(
×
NEW
150
                        s.AppSettings.LuaPlugins.EnabledPlugins, s.Detector.GetLuaPluginNames())
×
NEW
151
        }
×
152

153
        saveToDB := r.FormValue("saveToDb") == "true" && s.SettingsStore != nil
10✔
154
        if saveToDB {
16✔
155
                log.Printf("[DEBUG] saving settings to database")
6✔
156
                if err := s.SettingsStore.Save(r.Context(), s.AppSettings); err != nil {
9✔
157
                        *s.AppSettings = snapshot // rollback in-memory mutation on save failure
3✔
158
                        s.appSettingsMu.Unlock()
3✔
159
                        log.Printf("[ERROR] failed to save updated configuration: %v", err)
3✔
160
                        http.Error(w, fmt.Sprintf("Failed to save configuration: %v", err), http.StatusInternalServerError)
3✔
161
                        return
3✔
162
                }
3✔
163
                log.Printf("[DEBUG] settings saved successfully")
3✔
164
        }
165
        s.appSettingsMu.Unlock()
7✔
166

7✔
167
        if r.Header.Get("HX-Request") == "true" {
9✔
168
                // wrap the alert in #update-result so the next outerHTML swap finds the
2✔
169
                // same target — without the id, the first save replaces #update-result
2✔
170
                // with a plain alert div and subsequent saves silently no-op
2✔
171
                if _, err := w.Write([]byte(`<div id="update-result" class="alert alert-success">Configuration updated successfully</div>`)); err != nil {
2✔
NEW
172
                        log.Printf("[ERROR] failed to write response: %v", err)
×
NEW
173
                }
×
174
                return
2✔
175
        }
176

177
        // return JSON response for API calls
178
        rest.RenderJSON(w, rest.JSON{"status": "ok", "message": "Configuration updated successfully"})
5✔
179
}
180

181
// deleteConfigHandler handles DELETE /config request.
182
// It deletes the saved configuration from the database.
183
func (s *Server) deleteConfigHandler(w http.ResponseWriter, r *http.Request) {
4✔
184
        if s.SettingsStore == nil {
5✔
185
                http.Error(w, "Configuration storage not available", http.StatusInternalServerError)
1✔
186
                return
1✔
187
        }
1✔
188

189
        // delete configuration from database
190
        err := s.SettingsStore.Delete(r.Context())
3✔
191
        if err != nil {
4✔
192
                log.Printf("[ERROR] failed to delete configuration: %v", err)
1✔
193
                http.Error(w, fmt.Sprintf("Failed to delete configuration: %v", err), http.StatusInternalServerError)
1✔
194
                return
1✔
195
        }
1✔
196

197
        if r.Header.Get("HX-Request") == "true" {
3✔
198
                // return a success message for HTMX
1✔
199
                if _, err := w.Write([]byte(`<div class="alert alert-success">Configuration deleted successfully</div>`)); err != nil {
1✔
NEW
200
                        log.Printf("[ERROR] failed to write response: %v", err)
×
NEW
201
                }
×
202
                return
1✔
203
        }
204

205
        // return JSON response for API calls
206
        rest.RenderJSON(w, rest.JSON{"status": "ok", "message": "Configuration deleted successfully"})
1✔
207
}
208

209
// normalizeLuaEnabledPlugins collapses a "all available plugins selected"
210
// submission back to nil so the semantic "no preference, enable all" stored in
211
// EnabledPlugins survives a UI round-trip. The settings page renders every
212
// plugin checkbox as checked when EnabledPlugins is empty; without this
213
// normalization, hitting Save once freezes the list at the currently-loaded
214
// set and silently disables auto-enable for plugins added later.
215
//
216
// Returns selected unchanged when:
217
//   - available is empty (no detector or no plugins loaded)
218
//   - selected is shorter than available (operator unchecked at least one)
219
//   - selected omits any plugin in available (literal selection differs)
220
//
221
// Returns nil only when selected covers every entry in available.
222
func normalizeLuaEnabledPlugins(selected, available []string) []string {
8✔
223
        if len(available) == 0 || len(selected) < len(available) {
12✔
224
                return selected
4✔
225
        }
4✔
226
        selectedSet := make(map[string]struct{}, len(selected))
4✔
227
        for _, name := range selected {
13✔
228
                selectedSet[name] = struct{}{}
9✔
229
        }
9✔
230
        for _, name := range available {
13✔
231
                if _, ok := selectedSet[name]; !ok {
10✔
232
                        return selected
1✔
233
                }
1✔
234
        }
235
        return nil
3✔
236
}
237

238
// updateSettingsFromForm updates settings from form values
239
func updateSettingsFromForm(settings *config.Settings, r *http.Request) {
46✔
240
        // general settings
46✔
241
        if val := r.FormValue("primaryGroup"); val != "" {
57✔
242
                settings.Telegram.Group = val
11✔
243
        }
11✔
244
        if val := r.FormValue("adminGroup"); val != "" {
46✔
NEW
245
                settings.Admin.AdminGroup = val
×
NEW
246
        }
×
247
        settings.Admin.DisableAdminSpamForward = r.FormValue("disableAdminSpamForward") == "on"
46✔
248
        settings.Logger.Enabled = r.FormValue("loggerEnabled") == "on"
46✔
249
        settings.NoSpamReply = r.FormValue("noSpamReply") == "on"
46✔
250

46✔
251
        // handle CasEnabled separately because we need to set CAS.API
46✔
252
        switch casEnabled := r.FormValue("casEnabled") == "on"; {
46✔
253
        case !casEnabled:
44✔
254
                settings.CAS.API = ""
44✔
255
        case settings.CAS.API == "":
2✔
256
                settings.CAS.API = "https://api.cas.chat" // default CAS API endpoint
2✔
257
        }
258

259
        // parse super users from comma-separated string; only write when the form
260
        // contains the field so unrelated saves preserve the list, but honor an
261
        // explicit empty value as "clear all super users"
262
        if _, ok := r.Form["superUsers"]; ok {
51✔
263
                superUsers := r.FormValue("superUsers")
5✔
264
                if superUsers == "" {
6✔
265
                        settings.Admin.SuperUsers = nil
1✔
266
                } else {
5✔
267
                        users := strings.Split(superUsers, ",")
4✔
268
                        settings.Admin.SuperUsers = make([]string, 0, len(users))
4✔
269
                        for _, user := range users {
15✔
270
                                trimmed := strings.TrimSpace(user)
11✔
271
                                if trimmed != "" {
22✔
272
                                        settings.Admin.SuperUsers = append(settings.Admin.SuperUsers, trimmed)
11✔
273
                                }
11✔
274
                        }
275
                }
276
        }
277

278
        // meta checks: server-side authoritative master toggle. Behavior:
279
        //   - form contains zero meta-related fields → skip the entire block so
280
        //     unrelated saves preserve all 11 IsMetaEnabled-contributing fields
281
        //   - metaEnabled=on → write rendered fields from form; rendered booleans
282
        //     follow presence-of-on (absent == unchecked == false), unrendered
283
        //     booleans (metaContactOnly, metaGiveaway) and the optional
284
        //     metaUsernameSymbols are gated on r.Form presence so submits without
285
        //     them preserve existing values
286
        //   - metaEnabled absent → master toggle off, clear ALL 11 fields used by
287
        //     isMetaEnabled() so a checked per-feature box (e.g., metaImageOnly)
288
        //     cannot keep meta enabled
289
        metaFormFields := []string{
46✔
290
                "metaEnabled", "metaLinksLimit", "metaMentionsLimit", "metaUsernameSymbols",
46✔
291
                "metaLinksOnly", "metaImageOnly", "metaVideoOnly", "metaAudioOnly",
46✔
292
                "metaForwarded", "metaKeyboard", "metaContactOnly", "metaGiveaway",
46✔
293
        }
46✔
294
        hasMetaForm := false
46✔
295
        for _, k := range metaFormFields {
500✔
296
                if _, ok := r.Form[k]; ok {
463✔
297
                        hasMetaForm = true
9✔
298
                        break
9✔
299
                }
300
        }
301
        if hasMetaForm {
55✔
302
                if r.FormValue("metaEnabled") == "on" {
17✔
303
                        if val := r.FormValue("metaLinksLimit"); val != "" {
12✔
304
                                if limit, err := strconv.Atoi(val); err == nil {
8✔
305
                                        settings.Meta.LinksLimit = limit
4✔
306
                                }
4✔
307
                        }
308
                        if val := r.FormValue("metaMentionsLimit"); val != "" {
10✔
309
                                if limit, err := strconv.Atoi(val); err == nil {
4✔
310
                                        settings.Meta.MentionsLimit = limit
2✔
311
                                }
2✔
312
                        }
313
                        // honor the "leave empty to disable" UI hint: clear the setting whenever
314
                        // the form posts an empty value. The field is only written when the form
315
                        // contains it, so submits without it preserve the existing value.
316
                        if _, ok := r.Form["metaUsernameSymbols"]; ok {
11✔
317
                                settings.Meta.UsernameSymbols = r.FormValue("metaUsernameSymbols")
3✔
318
                        }
3✔
319
                        settings.Meta.LinksOnly = r.FormValue("metaLinksOnly") == "on"
8✔
320
                        settings.Meta.ImageOnly = r.FormValue("metaImageOnly") == "on"
8✔
321
                        settings.Meta.VideosOnly = r.FormValue("metaVideoOnly") == "on"
8✔
322
                        settings.Meta.AudiosOnly = r.FormValue("metaAudioOnly") == "on"
8✔
323
                        settings.Meta.Forward = r.FormValue("metaForwarded") == "on"
8✔
324
                        settings.Meta.Keyboard = r.FormValue("metaKeyboard") == "on"
8✔
325
                        // metaContactOnly and metaGiveaway are not currently rendered in the ConfigDB
8✔
326
                        // UI form. Gate them behind form presence so saves that don't render them
8✔
327
                        // can't silently wipe values set via save-config CLI or external DB tooling.
8✔
328
                        if _, ok := r.Form["metaContactOnly"]; ok {
11✔
329
                                settings.Meta.ContactOnly = r.FormValue("metaContactOnly") == "on"
3✔
330
                        }
3✔
331
                        if _, ok := r.Form["metaGiveaway"]; ok {
10✔
332
                                settings.Meta.Giveaway = r.FormValue("metaGiveaway") == "on"
2✔
333
                        }
2✔
334
                } else {
1✔
335
                        // master toggle off — clear EVERY field used by IsMetaEnabled so the
1✔
336
                        // returned settings unambiguously satisfy IsMetaEnabled() == false
1✔
337
                        settings.Meta.LinksLimit = -1
1✔
338
                        settings.Meta.MentionsLimit = -1
1✔
339
                        settings.Meta.UsernameSymbols = ""
1✔
340
                        settings.Meta.LinksOnly = false
1✔
341
                        settings.Meta.ImageOnly = false
1✔
342
                        settings.Meta.VideosOnly = false
1✔
343
                        settings.Meta.AudiosOnly = false
1✔
344
                        settings.Meta.Forward = false
1✔
345
                        settings.Meta.Keyboard = false
1✔
346
                        settings.Meta.ContactOnly = false
1✔
347
                        settings.Meta.Giveaway = false
1✔
348
                }
1✔
349
        }
350

351
        // openAI settings. enablement (APIBase/Token) is managed via CLI and save-config
352
        // to avoid destroying credentials through a UI toggle; only non-credential fields
353
        // are accepted from the form here, mirroring Gemini's handling.
354
        settings.OpenAI.Veto = r.FormValue("openAIVeto") == "on"
46✔
355
        settings.OpenAI.CheckShortMessages = r.FormValue("openAICheckShortMessages") == "on"
46✔
356

46✔
357
        if val := r.FormValue("openAIHistorySize"); val != "" {
48✔
358
                if size, err := strconv.Atoi(val); err == nil {
4✔
359
                        settings.OpenAI.HistorySize = size
2✔
360
                }
2✔
361
        }
362

363
        if val := r.FormValue("openAIModel"); val != "" {
49✔
364
                settings.OpenAI.Model = val
3✔
365
        }
3✔
366

367
        // gemini settings (mirror openAI handling; do not touch Gemini.Token from form — credential lives in CLI/DB only)
368
        settings.Gemini.Veto = r.FormValue("geminiVeto") == "on"
46✔
369
        settings.Gemini.CheckShortMessages = r.FormValue("geminiCheckShortMessages") == "on"
46✔
370

46✔
371
        if val := r.FormValue("geminiHistorySize"); val != "" {
49✔
372
                if size, err := strconv.Atoi(val); err == nil {
5✔
373
                        settings.Gemini.HistorySize = size
2✔
374
                }
2✔
375
        }
376

377
        if val := r.FormValue("geminiModel"); val != "" {
48✔
378
                settings.Gemini.Model = val
2✔
379
        }
2✔
380

381
        if val := r.FormValue("geminiPrompt"); val != "" {
48✔
382
                settings.Gemini.Prompt = val
2✔
383
        }
2✔
384

385
        if val := r.FormValue("geminiMaxTokensResponse"); val != "" {
49✔
386
                if n, err := strconv.ParseInt(val, 10, 32); err == nil {
5✔
387
                        settings.Gemini.MaxTokensResponse = int32(n)
2✔
388
                }
2✔
389
        }
390

391
        if val := r.FormValue("geminiMaxSymbolsRequest"); val != "" {
49✔
392
                if n, err := strconv.Atoi(val); err == nil {
5✔
393
                        settings.Gemini.MaxSymbolsRequest = n
2✔
394
                }
2✔
395
        }
396

397
        if val := r.FormValue("geminiRetryCount"); val != "" {
49✔
398
                if n, err := strconv.Atoi(val); err == nil {
5✔
399
                        settings.Gemini.RetryCount = n
2✔
400
                }
2✔
401
        }
402

403
        // llm orchestration settings
404
        if val := r.FormValue("llmConsensus"); val != "" {
48✔
405
                settings.LLM.Consensus = val
2✔
406
        }
2✔
407

408
        if val := r.FormValue("llmRequestTimeout"); val != "" {
48✔
409
                if d, err := time.ParseDuration(val); err == nil {
4✔
410
                        settings.LLM.RequestTimeout = d
2✔
411
                }
2✔
412
        }
413

414
        // duplicates detector
415
        if val := r.FormValue("duplicatesThreshold"); val != "" {
48✔
416
                if n, err := strconv.Atoi(val); err == nil {
4✔
417
                        settings.Duplicates.Threshold = n
2✔
418
                }
2✔
419
        }
420

421
        if val := r.FormValue("duplicatesWindow"); val != "" {
48✔
422
                if d, err := time.ParseDuration(val); err == nil {
4✔
423
                        settings.Duplicates.Window = d
2✔
424
                }
2✔
425
        }
426

427
        // reactions detector. Gated on r.Form key presence (not value-non-empty) so a
428
        // saved form without the reactions section preserves existing values, while an
429
        // explicit zero from the operator (disabling the detector) is honored.
430
        if _, ok := r.Form["reactionsMaxReactions"]; ok {
49✔
431
                if n, err := strconv.Atoi(r.FormValue("reactionsMaxReactions")); err == nil {
6✔
432
                        settings.Reactions.MaxReactions = n
3✔
433
                }
3✔
434
        }
435
        if _, ok := r.Form["reactionsWindow"]; ok {
48✔
436
                if d, err := time.ParseDuration(r.FormValue("reactionsWindow")); err == nil {
4✔
437
                        settings.Reactions.Window = d
2✔
438
                }
2✔
439
        }
440

441
        // user reports. reportEnabled is not rendered in the ConfigDB UI form, so
442
        // gate the write on form presence to avoid silently wiping values set via
443
        // save-config or external DB tooling when saving unrelated changes.
444
        if _, ok := r.Form["reportEnabled"]; ok {
48✔
445
                settings.Report.Enabled = r.FormValue("reportEnabled") == "on"
2✔
446
        }
2✔
447

448
        if val := r.FormValue("reportThreshold"); val != "" {
48✔
449
                if n, err := strconv.Atoi(val); err == nil {
4✔
450
                        settings.Report.Threshold = n
2✔
451
                }
2✔
452
        }
453

454
        if val := r.FormValue("reportAutoBanThreshold"); val != "" {
48✔
455
                if n, err := strconv.Atoi(val); err == nil {
4✔
456
                        settings.Report.AutoBanThreshold = n
2✔
457
                }
2✔
458
        }
459

460
        if val := r.FormValue("reportRateLimit"); val != "" {
48✔
461
                if n, err := strconv.Atoi(val); err == nil {
4✔
462
                        settings.Report.RateLimit = n
2✔
463
                }
2✔
464
        }
465

466
        if val := r.FormValue("reportRatePeriod"); val != "" {
48✔
467
                if d, err := time.ParseDuration(val); err == nil {
4✔
468
                        settings.Report.RatePeriod = d
2✔
469
                }
2✔
470
        }
471

472
        // service-message deletion. These flags are not rendered in the ConfigDB UI
473
        // form; gate the writes on form presence so unrelated saves don't wipe them.
474
        if _, ok := r.Form["deleteJoinMessages"]; ok {
48✔
475
                settings.Delete.JoinMessages = r.FormValue("deleteJoinMessages") == "on"
2✔
476
        }
2✔
477
        if _, ok := r.Form["deleteLeaveMessages"]; ok {
48✔
478
                settings.Delete.LeaveMessages = r.FormValue("deleteLeaveMessages") == "on"
2✔
479
        }
2✔
480

481
        // aggressive cleanup. Flag not rendered in the ConfigDB UI form; gate the
482
        // write on form presence so unrelated saves don't wipe it.
483
        if _, ok := r.Form["aggressiveCleanup"]; ok {
48✔
484
                settings.AggressiveCleanup = r.FormValue("aggressiveCleanup") == "on"
2✔
485
        }
2✔
486
        if val := r.FormValue("aggressiveCleanupLimit"); val != "" {
48✔
487
                if n, err := strconv.Atoi(val); err == nil {
4✔
488
                        settings.AggressiveCleanupLimit = n
2✔
489
                }
2✔
490
        }
491

492
        // lua plugins
493
        settings.LuaPlugins.Enabled = r.FormValue("luaPluginsEnabled") == "on"
46✔
494
        settings.LuaPlugins.DynamicReload = r.FormValue("luaDynamicReload") == "on"
46✔
495

46✔
496
        if val := r.FormValue("luaPluginsDir"); val != "" {
48✔
497
                settings.LuaPlugins.PluginsDir = val
2✔
498
        }
2✔
499

500
        // get selected Lua plugins
501
        settings.LuaPlugins.EnabledPlugins = r.Form["luaEnabledPlugins"]
46✔
502

46✔
503
        // spam detection
46✔
504
        if val := r.FormValue("similarityThreshold"); val != "" {
50✔
505
                if threshold, err := strconv.ParseFloat(val, 64); err == nil {
8✔
506
                        settings.SimilarityThreshold = threshold
4✔
507
                }
4✔
508
        }
509

510
        if val := r.FormValue("minMsgLen"); val != "" {
50✔
511
                if msgLen, err := strconv.Atoi(val); err == nil {
8✔
512
                        settings.MinMsgLen = msgLen
4✔
513
                }
4✔
514
        }
515

516
        if val := r.FormValue("maxEmoji"); val != "" {
48✔
517
                if count, err := strconv.Atoi(val); err == nil {
4✔
518
                        settings.MaxEmoji = count
2✔
519
                }
2✔
520
        }
521

522
        if val := r.FormValue("minSpamProbability"); val != "" {
48✔
523
                if prob, err := strconv.ParseFloat(val, 64); err == nil {
4✔
524
                        settings.MinSpamProbability = prob
2✔
525
                }
2✔
526
        }
527

528
        settings.ParanoidMode = r.FormValue("paranoidMode") == "on"
46✔
529

46✔
530
        if val := r.FormValue("firstMessagesCount"); val != "" {
47✔
531
                if count, err := strconv.Atoi(val); err == nil {
2✔
532
                        settings.FirstMessagesCount = count
1✔
533
                }
1✔
534
        }
535

536
        // startupMessageEnabled controls Message.Startup
537
        switch startupMessageEnabled := r.FormValue("startupMessageEnabled") == "on"; {
46✔
538
        case !startupMessageEnabled:
44✔
539
                settings.Message.Startup = ""
44✔
540
        case settings.Message.Startup == "":
2✔
541
                settings.Message.Startup = "Bot started"
2✔
542
        }
543

544
        settings.Training = r.FormValue("trainingEnabled") == "on"
46✔
545
        settings.SoftBan = r.FormValue("softBanEnabled") == "on"
46✔
546
        settings.AbnormalSpace.Enabled = r.FormValue("abnormalSpacingEnabled") == "on"
46✔
547

46✔
548
        if val := r.FormValue("multiLangWords"); val != "" {
47✔
549
                if limit, err := strconv.Atoi(val); err == nil {
2✔
550
                        settings.MultiLangWords = limit
1✔
551
                }
1✔
552
        }
553

554
        if val := r.FormValue("historySize"); val != "" {
47✔
555
                if size, err := strconv.Atoi(val); err == nil {
2✔
556
                        settings.History.Size = size
1✔
557
                }
1✔
558
        }
559

560
        // data storage
561
        if val := r.FormValue("samplesDataPath"); val != "" {
46✔
NEW
562
                settings.Files.SamplesDataPath = val
×
NEW
563
        }
×
564

565
        if val := r.FormValue("dynamicDataPath"); val != "" {
46✔
NEW
566
                settings.Files.DynamicDataPath = val
×
NEW
567
        }
×
568

569
        if val := r.FormValue("watchIntervalSecs"); val != "" {
47✔
570
                if secs, err := strconv.Atoi(val); err == nil {
2✔
571
                        settings.Files.WatchInterval = secs
1✔
572
                }
1✔
573
        }
574

575
        // dry-run is a real persisted setting. Dbg/TGDbg are CLI-only and not accepted
576
        // from the form because Transient is stripped by the store on save, so any value
577
        // posted here would be silently dropped.
578
        settings.Dry = r.FormValue("dryModeEnabled") == "on"
46✔
579
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc