Skip to content

Commit 9ef7013

Browse files
committed
Merge Plan 3: editable web settings UI
2 parents 0479a3a + 5ef14f4 commit 9ef7013

12 files changed

Lines changed: 1458 additions & 2 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ The web UI provides management interfaces:
296296
- **Manage Users**: view and control the approved users list.
297297
- **Detected Spam**: browse detected spam history.
298298
- **Settings**: configure bot parameters, super-users, and find your Telegram user ID.
299+
- **Edit Settings**: at `/settings/edit`, change the detection and LLM tuning (thresholds, per-check toggles, LLM mode/veto/consensus, models, system and vision prompts) directly in the browser. Saving stores a new versioned rule set and applies the change live without a restart. A setting that is also pinned by an environment variable shows an `env-pinned` warning badge, since the env value overrides the stored rule set on the next restart — remove it from the environment to manage that setting from the UI.
299300

300301
<details markdown>
301302
<summary>Screenshots</summary>

app/assembly.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ func activateServer(
227227
Version: revision,
228228
Dbg: opts.Dbg,
229229
Settings: settings,
230+
EnvPinnedKeys: envPinnedKeys(),
230231
}}
231232

232233
go func() {

app/webapi/assets/settings.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010

1111
<div class="container mt-4">
1212
<h2 class="text-center mb-4">Application Settings</h2>
13-
13+
<div class="text-center mb-3">
14+
<a href="/settings/edit" class="btn btn-primary btn-sm">Edit Settings</a>
15+
</div>
16+
1417
<div class="row mb-4">
1518
<div class="col-md-8">
1619
<div class="card">
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<title>Edit Settings - TG-Spam</title>
5+
{{template "heads.html"}}
6+
</head>
7+
<body>
8+
{{template "navbar.html"}}
9+
10+
<div class="container mt-4">
11+
<h2 class="text-center mb-4">Edit Settings</h2>
12+
13+
<div id="settings-error"></div>
14+
<div id="settings-status"></div>
15+
16+
<form hx-post="/settings/save" hx-target="#settings-status" hx-swap="innerHTML">
17+
{{$pinned := .EnvPinned}}
18+
19+
<div class="card mb-3">
20+
<div class="card-header">Detection</div>
21+
<div class="card-body">
22+
{{template "settings_int" dict "Key" "detection.max_emoji" "Label" "Max emoji" "Val" .RuleSet.Detection.MaxEmoji "Pinned" $pinned}}
23+
{{template "settings_int" dict "Key" "detection.min_msg_len" "Label" "Min message length" "Val" .RuleSet.Detection.MinMsgLen "Pinned" $pinned}}
24+
{{template "settings_float" dict "Key" "detection.similarity_threshold" "Label" "Similarity threshold" "Val" .RuleSet.Detection.SimilarityThreshold "Pinned" $pinned}}
25+
{{template "settings_float" dict "Key" "detection.min_spam_probability" "Label" "Min spam probability %" "Val" .RuleSet.Detection.MinSpamProbability "Pinned" $pinned}}
26+
{{template "settings_int" dict "Key" "detection.multi_lang_words" "Label" "Multi-lang words" "Val" .RuleSet.Detection.MultiLangWords "Pinned" $pinned}}
27+
{{template "settings_int" dict "Key" "detection.history_size" "Label" "History size" "Val" .RuleSet.Detection.HistorySize "Pinned" $pinned}}
28+
{{template "settings_int" dict "Key" "detection.first_messages_count" "Label" "First messages count" "Val" .RuleSet.Detection.FirstMessagesCount "Pinned" $pinned}}
29+
{{template "settings_bool" dict "Key" "detection.cas_enabled" "Label" "CAS enabled" "Val" .RuleSet.Detection.CasEnabled "Pinned" $pinned}}
30+
{{template "settings_bool" dict "Key" "detection.paranoid_mode" "Label" "Paranoid mode" "Val" .RuleSet.Detection.ParanoidMode "Pinned" $pinned}}
31+
</div>
32+
</div>
33+
34+
<div class="card mb-3">
35+
<div class="card-header">LLM</div>
36+
<div class="card-body">
37+
{{template "settings_enum" dict "Key" "llm.mode" "Label" "LLM mode" "Val" .RuleSet.LLM.Mode "Options" .LLMModes "Pinned" $pinned}}
38+
{{template "settings_enum" dict "Key" "llm.consensus" "Label" "LLM consensus" "Val" .RuleSet.LLM.Consensus "Options" .LLMConsensus "Pinned" $pinned}}
39+
{{template "settings_text" dict "Key" "llm.vision_prompt" "Label" "Vision prompt" "Val" .RuleSet.LLM.VisionPrompt "Pinned" $pinned}}
40+
</div>
41+
</div>
42+
43+
<div class="card mb-3">
44+
<div class="card-header">OpenAI</div>
45+
<div class="card-body">
46+
{{template "settings_bool" dict "Key" "openai.veto" "Label" "Veto" "Val" .RuleSet.OpenAI.Veto "Pinned" $pinned}}
47+
{{template "settings_bool" dict "Key" "openai.check_short_messages" "Label" "Check short messages" "Val" .RuleSet.OpenAI.CheckShortMessages "Pinned" $pinned}}
48+
{{template "settings_str" dict "Key" "openai.model" "Label" "Model" "Val" .RuleSet.OpenAI.Model "Pinned" $pinned}}
49+
{{template "settings_int" dict "Key" "openai.history_size" "Label" "History size" "Val" .RuleSet.OpenAI.HistorySize "Pinned" $pinned}}
50+
{{template "settings_text" dict "Key" "openai.prompt" "Label" "System prompt" "Val" .RuleSet.OpenAI.Prompt "Pinned" $pinned}}
51+
</div>
52+
</div>
53+
54+
<div class="card mb-3">
55+
<div class="card-header">Gemini</div>
56+
<div class="card-body">
57+
{{template "settings_bool" dict "Key" "gemini.veto" "Label" "Veto" "Val" .RuleSet.Gemini.Veto "Pinned" $pinned}}
58+
{{template "settings_bool" dict "Key" "gemini.check_short_messages" "Label" "Check short messages" "Val" .RuleSet.Gemini.CheckShortMessages "Pinned" $pinned}}
59+
{{template "settings_str" dict "Key" "gemini.model" "Label" "Model" "Val" .RuleSet.Gemini.Model "Pinned" $pinned}}
60+
{{template "settings_int" dict "Key" "gemini.history_size" "Label" "History size" "Val" .RuleSet.Gemini.HistorySize "Pinned" $pinned}}
61+
{{template "settings_text" dict "Key" "gemini.prompt" "Label" "System prompt" "Val" .RuleSet.Gemini.Prompt "Pinned" $pinned}}
62+
</div>
63+
</div>
64+
65+
<div class="card mb-3">
66+
<div class="card-body">
67+
{{template "settings_bool" dict "Key" "slow_path_enabled" "Label" "Slow-path enabled" "Val" .RuleSet.SlowPathEnabled "Pinned" $pinned}}
68+
</div>
69+
</div>
70+
71+
<button type="submit" class="btn btn-primary">Save</button>
72+
</form>
73+
</div>
74+
75+
</body>
76+
</html>
77+
78+
{{define "settings_pinned_badge"}}
79+
{{if index .Pinned .Key}}<span class="badge bg-warning text-dark ms-2" title="Set by env var; this will be overwritten on restart. Remove it from env to manage it here.">env-pinned</span>{{end}}
80+
{{end}}
81+
82+
{{define "settings_int"}}
83+
<div class="mb-3 row">
84+
<label class="col-sm-4 col-form-label">{{.Label}}{{template "settings_pinned_badge" .}}</label>
85+
<div class="col-sm-8">
86+
<input type="number" step="1" class="form-control" name="{{.Key}}" value="{{.Val}}">
87+
</div>
88+
</div>
89+
{{end}}
90+
91+
{{define "settings_float"}}
92+
<div class="mb-3 row">
93+
<label class="col-sm-4 col-form-label">{{.Label}}{{template "settings_pinned_badge" .}}</label>
94+
<div class="col-sm-8">
95+
<input type="number" step="any" class="form-control" name="{{.Key}}" value="{{.Val}}">
96+
</div>
97+
</div>
98+
{{end}}
99+
100+
{{define "settings_str"}}
101+
<div class="mb-3 row">
102+
<label class="col-sm-4 col-form-label">{{.Label}}{{template "settings_pinned_badge" .}}</label>
103+
<div class="col-sm-8">
104+
<input type="text" class="form-control" name="{{.Key}}" value="{{.Val}}">
105+
</div>
106+
</div>
107+
{{end}}
108+
109+
{{define "settings_text"}}
110+
<div class="mb-3 row">
111+
<label class="col-sm-4 col-form-label">{{.Label}}{{template "settings_pinned_badge" .}}</label>
112+
<div class="col-sm-8">
113+
<textarea class="form-control" name="{{.Key}}" rows="3">{{.Val}}</textarea>
114+
</div>
115+
</div>
116+
{{end}}
117+
118+
{{define "settings_bool"}}
119+
<div class="mb-3 row">
120+
<label class="col-sm-4 col-form-label">{{.Label}}{{template "settings_pinned_badge" .}}</label>
121+
<div class="col-sm-8">
122+
<div class="form-check">
123+
<input type="checkbox" class="form-check-input" name="{{.Key}}" {{if .Val}}checked{{end}}>
124+
</div>
125+
</div>
126+
</div>
127+
{{end}}
128+
129+
{{define "settings_enum"}}
130+
<div class="mb-3 row">
131+
<label class="col-sm-4 col-form-label">{{.Label}}{{template "settings_pinned_badge" .}}</label>
132+
<div class="col-sm-8">
133+
<select class="form-select" name="{{.Key}}">
134+
{{$cur := .Val}}
135+
{{range .Options}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{if eq . ""}}(default){{else}}{{.}}{{end}}</option>{{end}}
136+
</select>
137+
</div>
138+
</div>
139+
{{end}}

app/webapi/handlers_settings.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package webapi
2+
3+
import (
4+
"html/template"
5+
"net/http"
6+
"strings"
7+
8+
"github.com/umputun/tg-spam/app/observability"
9+
"github.com/umputun/tg-spam/app/rules"
10+
)
11+
12+
// llmModeOptions and llmConsensusOptions are the allowed values for the enum selects.
13+
var (
14+
llmModeOptions = []string{"", "missed", "flagged", "always"}
15+
llmConsensusOptions = []string{"any", "all"}
16+
)
17+
18+
// htmlSettingsEditHandler renders the editable settings form, pre-filled from the current ruleset.
19+
func (s *Server) htmlSettingsEditHandler(w http.ResponseWriter, r *http.Request) {
20+
if s.RuleSetProvider == nil {
21+
http.Error(w, "rule set provider not configured", http.StatusNotImplemented)
22+
return
23+
}
24+
rs, err := s.RuleSetProvider.Get(r.Context(), s.Settings.TenantID)
25+
if err != nil {
26+
http.Error(w, "failed to load rule set: "+err.Error(), http.StatusInternalServerError)
27+
return
28+
}
29+
30+
data := struct {
31+
RuleSet rules.RuleSet
32+
EnvPinned map[string]bool
33+
LLMModes []string
34+
LLMConsensus []string
35+
}{
36+
RuleSet: rs,
37+
EnvPinned: s.EnvPinnedKeys,
38+
LLMModes: llmModeOptions,
39+
LLMConsensus: llmConsensusOptions,
40+
}
41+
42+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
43+
if err := tmpl.ExecuteTemplate(w, "settings_edit.html", data); err != nil {
44+
observability.Logf(r.Context(), "[WARN] can't execute template: %v", err)
45+
http.Error(w, "failed to render: "+err.Error(), http.StatusInternalServerError)
46+
}
47+
}
48+
49+
// saveSettingsHandler parses the submitted settings form, validates it, and persists
50+
// the updated ruleset through the rule set provider. On a validation error it returns
51+
// an HTMX error fragment retargeted to #settings-error and persists nothing.
52+
func (s *Server) saveSettingsHandler(w http.ResponseWriter, r *http.Request) {
53+
if s.RuleSetProvider == nil {
54+
http.Error(w, "rule set provider not configured", http.StatusNotImplemented)
55+
return
56+
}
57+
if err := r.ParseForm(); err != nil {
58+
s.settingsError(w, []string{"malformed form: " + err.Error()})
59+
return
60+
}
61+
62+
base, err := s.RuleSetProvider.Get(r.Context(), s.Settings.TenantID)
63+
if err != nil {
64+
s.settingsError(w, []string{"failed to load current rule set: " + err.Error()})
65+
return
66+
}
67+
68+
updated, errs := ruleSetFromForm(base, r.Form)
69+
if len(errs) > 0 {
70+
s.settingsError(w, errs)
71+
return
72+
}
73+
74+
if _, err := s.RuleSetProvider.Update(r.Context(), s.Settings.TenantID, "web", updated); err != nil {
75+
s.settingsError(w, []string{"failed to save: " + err.Error()})
76+
return
77+
}
78+
79+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
80+
_, _ = w.Write([]byte(`<div class="alert alert-success">Saved. Changes applied live.</div>`))
81+
}
82+
83+
// settingsError writes an HTMX error fragment retargeted to the #settings-error slot.
84+
func (s *Server) settingsError(w http.ResponseWriter, errs []string) {
85+
w.Header().Set("HX-Retarget", "#settings-error")
86+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
87+
var b strings.Builder
88+
b.WriteString(`<div class="alert alert-danger"><strong>Could not save:</strong><ul>`)
89+
for _, e := range errs {
90+
b.WriteString("<li>" + template.HTMLEscapeString(e) + "</li>")
91+
}
92+
b.WriteString("</ul></div>")
93+
_, _ = w.Write([]byte(b.String()))
94+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package webapi
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"strings"
9+
"testing"
10+
11+
"github.com/go-pkgz/routegroup"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/umputun/tg-spam/app/rules"
16+
"github.com/umputun/tg-spam/app/webapi/mocks"
17+
)
18+
19+
type settingsRuleSetStub struct {
20+
get rules.RuleSet
21+
updated rules.RuleSet
22+
source string
23+
err error
24+
}
25+
26+
func (s *settingsRuleSetStub) Get(_ context.Context, _ string) (rules.RuleSet, error) {
27+
return s.get, s.err
28+
}
29+
30+
func (s *settingsRuleSetStub) Update(_ context.Context, _, source string, rs rules.RuleSet) (rules.RuleSet, error) {
31+
s.source = source
32+
s.updated = rs
33+
return rs, s.err
34+
}
35+
36+
func TestHTMLSettingsEditHandler_RendersForm(t *testing.T) {
37+
prov := &settingsRuleSetStub{get: rules.RuleSet{
38+
Detection: rules.DetectionRules{MaxEmoji: 5},
39+
OpenAI: rules.LLMRules{Model: "gpt-4o-mini"},
40+
}}
41+
srv := &Server{Config: Config{
42+
RuleSetProvider: prov,
43+
Settings: Settings{TenantID: "tg-spam"},
44+
EnvPinnedKeys: map[string]bool{"detection.max_emoji": true},
45+
}}
46+
47+
rr := httptest.NewRecorder()
48+
srv.htmlSettingsEditHandler(rr, httptest.NewRequest(http.MethodGet, "/settings/edit", http.NoBody))
49+
50+
require.Equal(t, http.StatusOK, rr.Code)
51+
body := rr.Body.String()
52+
assert.Contains(t, body, `name="detection.max_emoji"`)
53+
assert.Contains(t, body, `value="5"`)
54+
assert.Contains(t, body, "env-pinned", "pinned badge shown for detection.max_emoji")
55+
}
56+
57+
func TestSaveSettingsHandler_ValidUpdate(t *testing.T) {
58+
prov := &settingsRuleSetStub{get: rules.RuleSet{WorkspaceID: "tg-spam", Version: 3}}
59+
srv := &Server{Config: Config{RuleSetProvider: prov, Settings: Settings{TenantID: "tg-spam"}}}
60+
61+
form := url.Values{"detection.max_emoji": {"9"}, "llm.mode": {"flagged"}, "llm.consensus": {"any"}}
62+
req := httptest.NewRequest(http.MethodPost, "/settings/save", strings.NewReader(form.Encode()))
63+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
64+
rr := httptest.NewRecorder()
65+
66+
srv.saveSettingsHandler(rr, req)
67+
68+
require.Equal(t, http.StatusOK, rr.Code)
69+
assert.Equal(t, 9, prov.updated.Detection.MaxEmoji)
70+
assert.Equal(t, "flagged", prov.updated.LLM.Mode)
71+
assert.Equal(t, "web", prov.source, "update source must be 'web'")
72+
assert.Contains(t, rr.Body.String(), "Saved")
73+
}
74+
75+
func TestSaveSettingsHandler_ValidationError(t *testing.T) {
76+
prov := &settingsRuleSetStub{get: rules.RuleSet{WorkspaceID: "tg-spam"}}
77+
srv := &Server{Config: Config{RuleSetProvider: prov, Settings: Settings{TenantID: "tg-spam"}}}
78+
79+
form := url.Values{"detection.max_emoji": {"abc"}, "llm.mode": {"flagged"}, "llm.consensus": {"any"}}
80+
req := httptest.NewRequest(http.MethodPost, "/settings/save", strings.NewReader(form.Encode()))
81+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
82+
rr := httptest.NewRecorder()
83+
84+
srv.saveSettingsHandler(rr, req)
85+
86+
assert.Equal(t, "#settings-error", rr.Header().Get("HX-Retarget"))
87+
assert.Contains(t, rr.Body.String(), "detection.max_emoji")
88+
assert.Equal(t, rules.RuleSet{}, prov.updated, "nothing persisted on validation error")
89+
}
90+
91+
func TestSettingsEditRoute_Registered(t *testing.T) {
92+
prov := &settingsRuleSetStub{get: rules.RuleSet{}}
93+
srv := NewServer(Config{
94+
RuleSetProvider: prov,
95+
SpamFilter: &mocks.SpamFilterMock{},
96+
Settings: Settings{TenantID: "tg-spam"},
97+
})
98+
router := srv.routes(routegroup.New(http.NewServeMux()))
99+
100+
rr := httptest.NewRecorder()
101+
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/settings/edit", http.NoBody))
102+
assert.NotEqual(t, http.StatusNotFound, rr.Code, "GET /settings/edit must be routed")
103+
}

app/webapi/routes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ func (s *Server) setupWebUIRoutes(router *routegroup.Bundle) {
128128
webUI.HandleFunc("GET /manage_dictionary", s.htmlManageDictionaryHandler)
129129
webUI.HandleFunc("GET /detected_spam", s.htmlDetectedSpamHandler)
130130
webUI.HandleFunc("GET /list_settings", s.htmlSettingsHandler)
131+
webUI.HandleFunc("GET /settings/edit", s.htmlSettingsEditHandler)
132+
webUI.HandleFunc("POST /settings/save", s.saveSettingsHandler)
131133
webUI.HandleFunc("POST /detected_spam/add", s.htmlAddDetectedSpamHandler)
132134
webUI.HandleFunc("GET /dm-users", s.getDMUsersHandler)
133135

0 commit comments

Comments
 (0)