• 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

54.63
/app/main.go
1
package main
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "errors"
7
        "fmt"
8
        "io"
9
        "log"
10
        "math"
11
        "net/http"
12
        "os"
13
        "os/exec"
14
        "os/signal"
15
        "path/filepath"
16
        "sort"
17
        "strconv"
18
        "strings"
19
        "syscall"
20
        "time"
21

22
        tbapi "github.com/OvyFlash/telegram-bot-api"
23
        "github.com/fatih/color"
24
        "github.com/go-pkgz/fileutils"
25
        "github.com/go-pkgz/lgr"
26
        "github.com/go-pkgz/rest"
27
        "github.com/jessevdk/go-flags"
28
        "github.com/sashabaranov/go-openai"
29
        "google.golang.org/genai"
30
        "gopkg.in/natefinch/lumberjack.v2"
31

32
        "github.com/umputun/tg-spam/app/bot"
33
        "github.com/umputun/tg-spam/app/config"
34
        "github.com/umputun/tg-spam/app/events"
35
        "github.com/umputun/tg-spam/app/storage"
36
        "github.com/umputun/tg-spam/app/storage/engine"
37
        "github.com/umputun/tg-spam/app/webapi"
38
        "github.com/umputun/tg-spam/lib/tgspam"
39
        "github.com/umputun/tg-spam/lib/tgspam/plugin"
40
)
41

42
type options struct {
43
        InstanceID         string `long:"instance-id" env:"INSTANCE_ID" default:"tg-spam" description:"instance id"`
44
        DataBaseURL        string `long:"db" env:"DB" default:"tg-spam.db" description:"database URL, if empty uses sqlite"`
45
        ConfigDB           bool   `long:"confdb" env:"CONFDB" description:"load configuration from database"`
46
        ConfigDBEncryptKey string `long:"confdb-encrypt-key" env:"CONFDB_ENCRYPT_KEY" description:"encryption key for sensitive config values in database"`
47

48
        Telegram struct {
49
                Token        string        `long:"token" env:"TOKEN" description:"telegram bot token"`
50
                Group        string        `long:"group" env:"GROUP" description:"group name/id"`
51
                Timeout      time.Duration `long:"timeout" env:"TIMEOUT" default:"30s" description:"http client timeout for telegram" `
52
                IdleDuration time.Duration `long:"idle" env:"IDLE" default:"30s" description:"idle duration"`
53
        } `group:"telegram" namespace:"telegram" env-namespace:"TELEGRAM"`
54

55
        AdminGroup              string `long:"admin.group" env:"ADMIN_GROUP" description:"admin group name, or channel id"`
56
        DisableAdminSpamForward bool   `long:"disable-admin-spam-forward" env:"DISABLE_ADMIN_SPAM_FORWARD" description:"disable handling messages forwarded to admin group as spam"`
57

58
        TestingIDs []int64 `long:"testing-id" env:"TESTING_ID" env-delim:"," description:"testing ids, allow bot to reply to them"`
59

60
        HistoryDuration time.Duration `long:"history-duration" env:"HISTORY_DURATION" default:"24h" description:"history duration"`
61
        HistoryMinSize  int           `long:"history-min-size" env:"HISTORY_MIN_SIZE" default:"1000" description:"history minimal size to keep"`
62
        StorageTimeout  time.Duration `long:"storage-timeout" env:"STORAGE_TIMEOUT" default:"0s" description:"storage timeout"`
63

64
        Logger struct {
65
                Enabled    bool   `long:"enabled" env:"ENABLED" description:"enable spam rotated logs"`
66
                FileName   string `long:"file" env:"FILE"  default:"tg-spam.log" description:"location of spam log"`
67
                MaxSize    string `long:"max-size" env:"MAX_SIZE" default:"100M" description:"maximum size before it gets rotated"`
68
                MaxBackups int    `long:"max-backups" env:"MAX_BACKUPS" default:"10" description:"maximum number of old log files to retain"`
69
        } `group:"logger" namespace:"logger" env-namespace:"LOGGER"`
70

71
        SuperUsers          events.SuperUsers `long:"super" env:"SUPER_USER" env-delim:"," description:"super-users"`
72
        NoSpamReply         bool              `long:"no-spam-reply" env:"NO_SPAM_REPLY" description:"do not reply to spam messages"`
73
        SuppressJoinMessage bool              `long:"suppress-join-message" env:"SUPPRESS_JOIN_MESSAGE" description:"delete join message if user is kicked out"`
74

75
        Delete struct {
76
                JoinMessages  bool `long:"join-messages" env:"JOIN_MESSAGES" description:"delete join messages immediately"`
77
                LeaveMessages bool `long:"leave-messages" env:"LEAVE_MESSAGES" description:"delete leave messages immediately"`
78
        } `group:"delete" namespace:"delete" env-namespace:"DELETE"`
79

80
        CAS struct {
81
                API       string        `long:"api" env:"API" default:"https://api.cas.chat" description:"CAS API"`
82
                Timeout   time.Duration `long:"timeout" env:"TIMEOUT" default:"5s" description:"CAS timeout"`
83
                UserAgent string        `long:"user-agent" env:"USER_AGENT" description:"User-Agent header for CAS API requests"`
84
        } `group:"cas" namespace:"cas" env-namespace:"CAS"`
85

86
        Meta struct {
87
                LinksLimit      int    `long:"links-limit" env:"LINKS_LIMIT" default:"-1" description:"max links in message, disabled by default"`
88
                MentionsLimit   int    `long:"mentions-limit" env:"MENTIONS_LIMIT" default:"-1" description:"max mentions in message, disabled by default"`
89
                ImageOnly       bool   `long:"image-only" env:"IMAGE_ONLY" description:"enable image only check"`
90
                LinksOnly       bool   `long:"links-only" env:"LINKS_ONLY" description:"enable links only check"`
91
                VideosOnly      bool   `long:"video-only" env:"VIDEO_ONLY" description:"enable video only check"`
92
                AudiosOnly      bool   `long:"audio-only" env:"AUDIO_ONLY" description:"enable audio only check"`
93
                ContactOnly     bool   `long:"contact-only" env:"CONTACT_ONLY" description:"enable contact only check"`
94
                Forward         bool   `long:"forward" env:"FORWARD" description:"enable forward check"`
95
                Keyboard        bool   `long:"keyboard" env:"KEYBOARD" description:"enable keyboard check"`
96
                UsernameSymbols string `long:"username-symbols" env:"USERNAME_SYMBOLS" description:"prohibited symbols in username, disabled by default"`
97
                Giveaway        bool   `long:"giveaway" env:"GIVEAWAY" description:"enable giveaway check"`
98
        } `group:"meta" namespace:"meta" env-namespace:"META"`
99

100
        OpenAI struct {
101
                Token              string   `long:"token" env:"TOKEN" description:"openai token, disabled if not set"`
102
                APIBase            string   `long:"apibase" env:"API_BASE" description:"custom openai API base, default is https://api.openai.com/v1"`
103
                Veto               bool     `long:"veto" env:"VETO" description:"veto mode, confirm detected spam"`
104
                Prompt             string   `long:"prompt" env:"PROMPT" default:"" description:"openai system prompt, if empty uses builtin default"`
105
                CustomPrompts      []string `long:"custom-prompt" env:"CUSTOM_PROMPT" env-delim:"," description:"additional custom prompts for specific spam patterns"`
106
                Model              string   `long:"model" env:"MODEL" default:"gpt-4o-mini" description:"openai model"`
107
                MaxTokensResponse  int      `long:"max-tokens-response" env:"MAX_TOKENS_RESPONSE" default:"1024" description:"openai max tokens in response"`
108
                MaxTokensRequest   int      `long:"max-tokens-request" env:"MAX_TOKENS_REQUEST" default:"2048" description:"openai max tokens in request"`
109
                MaxSymbolsRequest  int      `long:"max-symbols-request" env:"MAX_SYMBOLS_REQUEST" default:"16000" description:"openai max symbols in request, failback if tokenizer failed"`
110
                RetryCount         int      `long:"retry-count" env:"RETRY_COUNT" default:"1" description:"openai retry count"`
111
                HistorySize        int      `long:"history-size" env:"HISTORY_SIZE" default:"0" description:"openai history size"`
112
                ReasoningEffort    string   `long:"reasoning-effort" env:"REASONING_EFFORT" default:"none" choice:"none" choice:"low" choice:"medium" choice:"high" description:"reasoning effort for thinking models, none disables thinking"`
113
                CheckShortMessages bool     `long:"check-short-messages" env:"CHECK_SHORT_MESSAGES" description:"check messages shorter than min-msg-len with OpenAI"`
114
        } `group:"openai" namespace:"openai" env-namespace:"OPENAI"`
115

116
        Gemini struct {
117
                Token              string   `long:"token" env:"TOKEN" description:"gemini token, disabled if not set"`
118
                Veto               bool     `long:"veto" env:"VETO" description:"veto mode, confirm detected spam"`
119
                Prompt             string   `long:"prompt" env:"PROMPT" default:"" description:"gemini system prompt, if empty uses builtin default"`
120
                CustomPrompts      []string `long:"custom-prompt" env:"CUSTOM_PROMPT" env-delim:"," description:"additional custom prompts for specific spam patterns"`
121
                Model              string   `long:"model" env:"MODEL" default:"gemma-4-31b-it" description:"gemini model"`
122
                MaxTokensResponse  int32    `long:"max-tokens-response" env:"MAX_TOKENS_RESPONSE" default:"1024" description:"gemini max tokens in response"`
123
                MaxSymbolsRequest  int      `long:"max-symbols-request" env:"MAX_SYMBOLS_REQUEST" default:"8192" description:"gemini max symbols in request"`
124
                RetryCount         int      `long:"retry-count" env:"RETRY_COUNT" default:"1" description:"gemini retry count"`
125
                HistorySize        int      `long:"history-size" env:"HISTORY_SIZE" default:"0" description:"gemini history size"`
126
                CheckShortMessages bool     `long:"check-short-messages" env:"CHECK_SHORT_MESSAGES" description:"check messages shorter than min-msg-len with Gemini"`
127
        } `group:"gemini" namespace:"gemini" env-namespace:"GEMINI"`
128

129
        LLM struct {
130
                Consensus      string        `long:"consensus" env:"CONSENSUS" choice:"any" choice:"all" default:"any" description:"how eligible LLMs flip the base decision"`
131
                RequestTimeout time.Duration `long:"request-timeout" env:"REQUEST_TIMEOUT" default:"30s" description:"timeout for individual LLM requests"`
132
        } `group:"llm" namespace:"llm" env-namespace:"LLM"`
133

134
        LuaPlugins struct {
135
                Enabled        bool     `long:"enabled" env:"ENABLED" description:"enable Lua plugins"`
136
                PluginsDir     string   `long:"plugins-dir" env:"PLUGINS_DIR" description:"directory with Lua plugins"`
137
                EnabledPlugins []string `long:"enabled-plugins" env:"ENABLED_PLUGINS" env-delim:"," description:"list of enabled plugins (by name, without .lua extension)"`
138
                DynamicReload  bool     `long:"dynamic-reload" env:"DYNAMIC_RELOAD" description:"dynamically reload plugins when they change"`
139
        } `group:"lua-plugins" namespace:"lua-plugins" env-namespace:"LUA_PLUGINS"`
140

141
        AbnormalSpacing struct {
142
                Enabled                 bool    `long:"enabled" env:"ENABLED" description:"enable abnormal words check"`
143
                SpaceRatioThreshold     float64 `long:"ratio" env:"RATIO" default:"0.3" description:"the ratio of spaces to all characters in the message"`
144
                ShortWordRatioThreshold float64 `long:"short-ratio" env:"SHORT_RATIO" default:"0.7" description:"the ratio of short words to all words in the message"`
145
                ShortWordLen            int     `long:"short-word" env:"SHORT_WORD" default:"3" description:"the length of the word to be considered short"`
146
                MinWords                int     `long:"min-words" env:"MIN_WORDS" default:"5" description:"the minimum number of words in the message to check"`
147
        } `group:"space" namespace:"space" env-namespace:"SPACE"`
148

149
        Duplicates struct {
150
                Threshold int           `long:"threshold" env:"THRESHOLD" default:"0" description:"duplicate messages to trigger spam (0=disabled)"`
151
                Window    time.Duration `long:"window" env:"WINDOW" default:"1h" description:"time window for duplicate detection"`
152
        } `group:"duplicates" namespace:"duplicates" env-namespace:"DUPLICATES"`
153

154
        Reactions struct {
155
                MaxReactions int           `long:"max-reactions" env:"MAX_REACTIONS" default:"0" description:"max reactions per user in window to trigger spam ban (0=disabled)"`
156
                Window       time.Duration `long:"window" env:"WINDOW" default:"1h" description:"time window for reaction spam detection"`
157
        } `group:"reactions" namespace:"reactions" env-namespace:"REACTIONS"`
158

159
        Report struct {
160
                Enabled          bool          `long:"enabled" env:"ENABLED" description:"enable user spam reporting"`
161
                Threshold        int           `long:"threshold" env:"THRESHOLD" default:"2" description:"number of reports to trigger admin notification"`
162
                AutoBanThreshold int           `long:"auto-ban-threshold" env:"AUTO_BAN_THRESHOLD" default:"0" description:"auto-ban after N reports (0=disabled, must be >= threshold)"`
163
                RateLimit        int           `long:"rate-limit" env:"RATE_LIMIT" default:"10" description:"max reports per user per period"`
164
                RatePeriod       time.Duration `long:"rate-period" env:"RATE_PERIOD" default:"1h" description:"rate limit time period"`
165
        } `group:"report" namespace:"report" env-namespace:"REPORT"`
166

167
        Files struct {
168
                SamplesDataPath string        `long:"samples" env:"SAMPLES" description:"samples data path, defaults to dynamic data path"`
169
                DynamicDataPath string        `long:"dynamic" env:"DYNAMIC" default:"data" description:"dynamic data path"`
170
                WatchInterval   time.Duration `long:"watch-interval" env:"WATCH_INTERVAL" default:"5s" description:"watch interval for dynamic files, deprecated"`
171
        } `group:"files" namespace:"files" env-namespace:"FILES"`
172

173
        SimilarityThreshold float64 `long:"similarity-threshold" env:"SIMILARITY_THRESHOLD" default:"0.5" description:"spam threshold"`
174
        MinMsgLen           int     `long:"min-msg-len" env:"MIN_MSG_LEN" default:"50" description:"min message length to check"`
175
        MaxEmoji            int     `long:"max-emoji" env:"MAX_EMOJI" default:"2" description:"max emoji count in message, -1 to disable check"`
176
        MinSpamProbability  float64 `long:"min-probability" env:"MIN_PROBABILITY" default:"50" description:"min spam probability percent to ban"`
177
        MultiLangWords      int     `long:"multi-lang" env:"MULTI_LANG" default:"0" description:"number of words in different languages to consider as spam"`
178

179
        ParanoidMode       bool `long:"paranoid" env:"PARANOID" description:"paranoid mode, check all messages"`
180
        FirstMessagesCount int  `long:"first-messages-count" env:"FIRST_MESSAGES_COUNT" default:"1" description:"number of first messages to check"`
181

182
        AggressiveCleanup      bool `long:"aggressive-cleanup" env:"AGGRESSIVE_CLEANUP" description:"delete all messages from user when banned via /spam command"`
183
        AggressiveCleanupLimit int  `long:"aggressive-cleanup-limit" env:"AGGRESSIVE_CLEANUP_LIMIT" default:"100" description:"max messages to delete in aggressive cleanup mode"`
184

185
        Message struct {
186
                Startup string `long:"startup" env:"STARTUP" default:"" description:"startup message"`
187
                Spam    string `long:"spam" env:"SPAM" default:"this is spam" description:"spam message"`
188
                Dry     string `long:"dry" env:"DRY" default:"this is spam (dry mode)" description:"spam dry message"`
189
                Warn    string `long:"warn" env:"WARN" default:"You've violated our rules and this is your first and last warning. Further violations will lead to permanent access denial. Stay compliant or face the consequences!" description:"warning message"`
190
        } `group:"message" namespace:"message" env-namespace:"MESSAGE"`
191

192
        Server struct {
193
                Enabled    bool   `long:"enabled" env:"ENABLED" description:"enable web server"`
194
                ListenAddr string `long:"listen" env:"LISTEN" default:":8080" description:"listen address"`
195
                AuthPasswd string `long:"auth" env:"AUTH" default:"auto" description:"basic auth password"`
196
                AuthHash   string `long:"auth-hash" env:"AUTH_HASH" default:"" description:"basic auth password hash"`
197
        } `group:"server" namespace:"server" env-namespace:"SERVER"`
198

199
        Training bool `long:"training" env:"TRAINING" description:"training mode, passive spam detection only"`
200
        SoftBan  bool `long:"soft-ban" env:"SOFT_BAN" description:"soft ban mode, restrict user actions but not ban"`
201

202
        HistorySize int    `long:"history-size" env:"LAST_MSGS_HISTORY_SIZE" default:"100" description:"history size"`
203
        Convert     string `long:"convert" choice:"only" choice:"enabled" choice:"disabled" default:"enabled" description:"convert mode for txt samples and other storage files to DB"`
204

205
        MaxBackups int `long:"max-backups" env:"MAX_BACKUPS" default:"10" description:"maximum number of backups to keep, set 0 to disable"`
206

207
        Dry   bool `long:"dry" env:"DRY" description:"dry mode, no bans"`
208
        Dbg   bool `long:"dbg" env:"DEBUG" description:"debug mode"`
209
        TGDbg bool `long:"tg-dbg" env:"TG_DEBUG" description:"telegram debug mode"`
210
}
211

212
// default file names
213
const (
214
        samplesSpamFile   = "spam-samples.txt"
215
        samplesHamFile    = "ham-samples.txt"
216
        excludeTokensFile = "exclude-tokens.txt" //nolint:gosec // false positive
217
        stopWordsFile     = "stop-words.txt"     //nolint:gosec // false positive
218
        dynamicSpamFile   = "spam-dynamic.txt"
219
        dynamicHamFile    = "ham-dynamic.txt"
220
        dataFile          = "tg-spam.db"
221
)
222

223
var revision = "local"
224

225
func main() {
×
226
        if os.Getenv("GO_FLAGS_COMPLETION") == "" {
×
227
                fmt.Printf("tg-spam %s\n", revision)
×
228
        }
×
229
        var opts options
×
230
        p := flags.NewParser(&opts, flags.PrintErrors|flags.PassDoubleDash|flags.HelpFlag)
×
231
        p.SubcommandsOptional = true
×
NEW
232

×
NEW
233
        // add save-config command
×
NEW
234
        if _, err := p.AddCommand("save-config", "Save current configuration to database",
×
NEW
235
                "Saves all current settings to the database for future use with --confdb",
×
NEW
236
                &struct{}{}); err != nil {
×
NEW
237
                log.Printf("[ERROR] failed to add save-config command: %v", err)
×
NEW
238
                os.Exit(1)
×
NEW
239
        }
×
240
        if _, err := p.Parse(); err != nil {
×
241
                if !errors.Is(err.(*flags.Error).Type, flags.ErrHelp) {
×
242
                        log.Printf("[ERROR] cli error: %v", err)
×
243
                        os.Exit(1)
×
244
                }
×
245
                os.Exit(2)
×
246
        }
247

248
        // determine configuration source based on --confdb flag
NEW
249
        var appSettings *config.Settings
×
NEW
250
        // reloadNormalize captures the same defaults-fill + operational CLI override
×
NEW
251
        // policy used at startup so POST /config/reload can apply it to a freshly
×
NEW
252
        // loaded DB blob. nil in non-confdb mode (no reload endpoint exists there).
×
NEW
253
        var reloadNormalize func(*config.Settings)
×
NEW
254

×
NEW
255
        if opts.ConfigDB {
×
NEW
256
                // database configuration mode - load from database first
×
NEW
257
                ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
×
NEW
258
                defer cancel()
×
NEW
259

×
NEW
260
                // build defaults template from CLI struct tags once; same template is
×
NEW
261
                // used by loadConfigFromDB (to fill partial blobs) and applyCLIOverrides
×
NEW
262
                // (to distinguish "operator passed default" from "operator opted out")
×
NEW
263
                defaults, err := defaultSettingsTemplate()
×
NEW
264
                if err != nil {
×
NEW
265
                        log.Printf("[ERROR] failed to build defaults template: %v", err)
×
NEW
266
                        cancel()
×
NEW
267
                        os.Exit(1) //nolint:gocritic // cancel is called before exit
×
NEW
268
                }
×
269

270
                // create a new settings instance
NEW
271
                appSettings = config.New()
×
NEW
272

×
NEW
273
                // set transient values needed for database connection BEFORE loading
×
NEW
274
                appSettings.Transient.ConfigDB = opts.ConfigDB
×
NEW
275
                appSettings.Transient.ConfigDBEncryptKey = opts.ConfigDBEncryptKey
×
NEW
276
                appSettings.Transient.DataBaseURL = opts.DataBaseURL
×
NEW
277
                appSettings.InstanceID = opts.InstanceID
×
NEW
278
                // set DynamicDataPath from opts so makeDB can properly construct the database path
×
NEW
279
                appSettings.Files.DynamicDataPath = opts.Files.DynamicDataPath
×
NEW
280

×
NEW
281
                // load settings from database
×
NEW
282
                if err := loadConfigFromDB(ctx, appSettings, defaults); err != nil {
×
NEW
283
                        log.Printf("[ERROR] failed to load configuration from database: %v", err)
×
NEW
284
                        cancel()
×
NEW
285
                        os.Exit(1) //nolint:gocritic // cancel is called before exit
×
NEW
286
                }
×
287

288
                // apply transient values from CLI (these are never stored in DB)
NEW
289
                appSettings.Transient.Dbg = opts.Dbg
×
NEW
290
                appSettings.Transient.TGDbg = opts.TGDbg
×
NEW
291
                appSettings.Transient.StorageTimeout = opts.StorageTimeout
×
NEW
292

×
NEW
293
                // apply explicit CLI overrides for non-transient values
×
NEW
294
                applyCLIOverrides(appSettings, opts, defaults)
×
NEW
295

×
NEW
296
                // build a reload normalizer that mirrors startup's defaults-fill +
×
NEW
297
                // operational CLI overrides; webapi's loadConfigHandler invokes it
×
NEW
298
                // after Load so a partial/legacy DB blob plus operator-supplied
×
NEW
299
                // --files.dynamic / --files.samples / --server.listen / --dry survive
×
NEW
300
                // POST /config/reload.
×
NEW
301
                reloadNormalize = func(s *config.Settings) {
×
NEW
302
                        s.ApplyDefaults(defaults)
×
NEW
303
                        applyOperationalCLIOverrides(s, opts, defaults)
×
NEW
304
                        normalizeFilePaths(s)
×
NEW
305
                }
×
NEW
306
        } else {
×
NEW
307
                // traditional mode - CLI is source of truth
×
NEW
308
                appSettings = optToSettings(opts)
×
NEW
309
        }
×
310

311
        // setup logger with masked secrets BEFORE any subcommand dispatch so any
312
        // error wrapping inside saveConfigToDB or later stages benefits from the
313
        // secret masker. Tokens come directly from the resolved domain settings.
NEW
314
        masked := []string{}
×
NEW
315

×
NEW
316
        if appSettings.Telegram.Token != "" {
×
NEW
317
                masked = append(masked, appSettings.Telegram.Token)
×
NEW
318
        }
×
NEW
319
        if appSettings.OpenAI.Token != "" {
×
NEW
320
                masked = append(masked, appSettings.OpenAI.Token)
×
NEW
321
        }
×
NEW
322
        if appSettings.Gemini.Token != "" {
×
NEW
323
                masked = append(masked, appSettings.Gemini.Token)
×
NEW
324
        }
×
325

326
        // add temporary web password if not "auto"
NEW
327
        if appSettings.Transient.WebAuthPasswd != "auto" && appSettings.Transient.WebAuthPasswd != "" {
×
328
                // auto passwd should not be masked as we print it
×
NEW
329
                masked = append(masked, appSettings.Transient.WebAuthPasswd)
×
330
        }
×
331

332
        // add auth hash
NEW
333
        if appSettings.Server.AuthHash != "" {
×
NEW
334
                masked = append(masked, appSettings.Server.AuthHash)
×
NEW
335
        }
×
336

337
        // add config DB encryption master key - must be masked before the %+v settings dump below
NEW
338
        if appSettings.Transient.ConfigDBEncryptKey != "" {
×
NEW
339
                masked = append(masked, appSettings.Transient.ConfigDBEncryptKey)
×
UNCOV
340
        }
×
341

NEW
342
        setupLog(appSettings.Transient.Dbg, masked...)
×
NEW
343

×
NEW
344
        // handle save-config command (after setupLog so any error output is masked)
×
NEW
345
        if p.Active != nil && p.Active.Name == "save-config" {
×
NEW
346
                ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
×
NEW
347
                exitCode := 0
×
NEW
348
                if err := saveConfigToDB(ctx, appSettings); err != nil {
×
NEW
349
                        log.Printf("[ERROR] failed to save configuration to database: %v", err)
×
NEW
350
                        exitCode = 1
×
NEW
351
                }
×
NEW
352
                cancel()
×
NEW
353
                os.Exit(exitCode)
×
354
        }
355

NEW
356
        log.Printf("[DEBUG] settings: %+v", appSettings)
×
357

×
358
        // validate auto-ban threshold
×
NEW
359
        if appSettings.Report.AutoBanThreshold > 0 && appSettings.Report.AutoBanThreshold < appSettings.Report.Threshold {
×
360
                log.Fatalf("[ERROR] auto-ban-threshold (%d) must be >= threshold (%d) or 0 (disabled)",
×
NEW
361
                        appSettings.Report.AutoBanThreshold, appSettings.Report.Threshold)
×
362
        }
×
363

364
        ctx, cancel := context.WithCancel(context.Background())
×
365

×
366
        go func() {
×
367
                // catch signal and invoke graceful termination
×
368
                stop := make(chan os.Signal, 1)
×
369
                signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
×
370
                <-stop
×
371
                log.Printf("[WARN] interrupt signal")
×
372
                cancel()
×
373
        }()
×
374

375
        // expand, make absolute paths
NEW
376
        normalizeFilePaths(appSettings)
×
377

×
NEW
378
        if err := execute(ctx, appSettings, reloadNormalize); err != nil {
×
379
                log.Printf("[ERROR] %v", err)
×
380
                os.Exit(1)
×
381
        }
×
382
}
383

384
// execute runs the main application loop. The reloadNormalize callback, when
385
// non-nil, is forwarded to webapi so POST /config/reload can reapply startup-
386
// equivalent defaults-fill and operational CLI overrides on top of the DB blob.
387
func execute(ctx context.Context, settings *config.Settings, reloadNormalize func(*config.Settings)) error {
4✔
388
        if settings.Dry {
4✔
389
                log.Print("[WARN] dry mode, no actual bans")
×
390
        }
×
391

392
        convertOnly := settings.Convert == "only"
4✔
393
        if !settings.Server.Enabled && !convertOnly && (settings.Telegram.Token == "" || settings.Telegram.Group == "") {
4✔
394
                return errors.New("telegram token and group are required")
×
395
        }
×
396

397
        checkVolumeMount(settings) // show warning if dynamic files dir not mounted
4✔
398

4✔
399
        // make samples and dynamic data dirs
4✔
400
        if err := os.MkdirAll(settings.Files.SamplesDataPath, 0o700); err != nil {
4✔
401
                return fmt.Errorf("can't make samples dir, %w", err)
×
402
        }
×
403

404
        dataDB, err := makeDB(ctx, settings)
4✔
405
        if err != nil {
4✔
406
                return fmt.Errorf("can't make db, %w", err)
×
407
        }
×
408

409
        // make detector with all sample files loaded
410
        detector := makeDetector(settings)
4✔
411

4✔
412
        // make spam bot
4✔
413
        spamBot, err := makeSpamBot(ctx, settings, dataDB, detector)
4✔
414
        if err != nil {
4✔
415
                return fmt.Errorf("can't make spam bot, %w", err)
×
416
        }
×
417
        if settings.Convert == "only" {
4✔
418
                log.Print("[WARN] convert only mode, converting text samples and exit")
×
419
                return nil
×
420
        }
×
421

422
        // make store and load approved users
423
        approvedUsersStore, auErr := storage.NewApprovedUsers(ctx, dataDB)
4✔
424
        if auErr != nil {
4✔
425
                return fmt.Errorf("can't make approved users store, %w", auErr)
×
426
        }
×
427

428
        count, err := detector.WithUserStorage(approvedUsersStore)
4✔
429
        if err != nil {
4✔
430
                return fmt.Errorf("can't load approved users, %w", err)
×
431
        }
×
432
        log.Printf("[DEBUG] approved users loaded: %d", count)
4✔
433

4✔
434
        // make locator
4✔
435
        locator, err := storage.NewLocator(ctx, settings.History.Duration, settings.History.MinSize, dataDB)
4✔
436
        if err != nil {
4✔
437
                return fmt.Errorf("can't make locator, %w", err)
×
438
        }
×
439

440
        // make reports storage if feature is enabled
441
        var reportsStore *storage.Reports
4✔
442
        if settings.Report.Enabled {
4✔
443
                reportsStore, err = storage.NewReports(ctx, dataDB)
×
444
                if err != nil {
×
445
                        return fmt.Errorf("can't make reports store, %w", err)
×
446
                }
×
447
        }
448

449
        // activate web server if enabled, server-only mode (no telegram token)
450
        if settings.Server.Enabled && (settings.Telegram.Token == "" || settings.Telegram.Group == "") {
8✔
451
                // server starts in background goroutine without DM users provider
4✔
452
                if srvErr := activateServer(ctx, settings, spamBot, locator, dataDB, nil, "", reloadNormalize); srvErr != nil {
4✔
453
                        return fmt.Errorf("can't activate web server, %w", srvErr)
×
454
                }
×
455
                log.Printf("[WARN] no telegram token and group set, web server only mode")
4✔
456
                <-ctx.Done()
4✔
457
                return nil
4✔
458
        }
459

460
        // make telegram bot
NEW
461
        tbAPI, err := tbapi.NewBotAPI(settings.Telegram.Token)
×
462
        if err != nil {
×
463
                return fmt.Errorf("can't make telegram bot, %w", err)
×
464
        }
×
NEW
465
        tbAPI.Debug = settings.Transient.TGDbg
×
466

×
467
        // make spam logger writer
×
NEW
468
        loggerWr, err := makeSpamLogWriter(settings)
×
469
        if err != nil {
×
470
                return fmt.Errorf("can't make spam log writer, %w", err)
×
471
        }
×
472
        defer loggerWr.Close()
×
473

×
474
        // make spam logger
×
NEW
475
        spamLogger, err := makeSpamLogger(ctx, settings.InstanceID, loggerWr, dataDB)
×
476
        if err != nil {
×
477
                return fmt.Errorf("can't make spam logger, %w", err)
×
478
        }
×
479

480
        // make telegram listener
481
        tgListener := events.TelegramListener{
×
482
                TbAPI:               tbAPI,
×
483
                BotUsername:         tbAPI.Self.UserName,
×
NEW
484
                Group:               settings.Telegram.Group,
×
NEW
485
                IdleDuration:        settings.Telegram.IdleDuration,
×
NEW
486
                SuperUsers:          settings.Admin.SuperUsers,
×
487
                Bot:                 spamBot,
×
NEW
488
                StartupMsg:          settings.Message.Startup,
×
NEW
489
                WarnMsg:             settings.Message.Warn,
×
NEW
490
                NoSpamReply:         settings.NoSpamReply,
×
NEW
491
                SuppressJoinMessage: settings.SuppressJoinMessage,
×
NEW
492
                DeleteJoinMessages:  settings.Delete.JoinMessages,
×
NEW
493
                DeleteLeaveMessages: settings.Delete.LeaveMessages,
×
494
                SpamLogger:          spamLogger,
×
NEW
495
                AdminGroup:          settings.Admin.AdminGroup,
×
NEW
496
                TestingIDs:          settings.Admin.TestingIDs,
×
497
                Locator:             locator,
×
498
                ReportConfig: events.ReportConfig{
×
499
                        Storage:          reportsStore,
×
NEW
500
                        Enabled:          settings.Report.Enabled,
×
NEW
501
                        Threshold:        settings.Report.Threshold,
×
NEW
502
                        AutoBanThreshold: settings.Report.AutoBanThreshold,
×
NEW
503
                        RateLimit:        settings.Report.RateLimit,
×
NEW
504
                        RatePeriod:       settings.Report.RatePeriod,
×
505
                },
×
NEW
506
                TrainingMode:            settings.Training,
×
NEW
507
                SoftBanMode:             settings.SoftBan,
×
NEW
508
                DisableAdminSpamForward: settings.Admin.DisableAdminSpamForward,
×
NEW
509
                Dry:                     settings.Dry,
×
NEW
510
                AggressiveCleanup:       settings.AggressiveCleanup,
×
NEW
511
                AggressiveCleanupLimit:  settings.AggressiveCleanupLimit,
×
512
        }
×
513

×
NEW
514
        if settings.Delete.JoinMessages {
×
515
                log.Print("[INFO] delete join messages enabled")
×
516
        }
×
NEW
517
        if settings.Delete.LeaveMessages {
×
518
                log.Print("[INFO] delete leave messages enabled")
×
519
        }
×
520

521
        log.Printf("[DEBUG] telegram listener config: {bot: %s, group: %s, idle: %v, super: %v, admin: %s, "+
×
522
                "testing: %v, no-reply: %v, suppress: %v, dry: %v, training: %v}",
×
523
                tgListener.BotUsername, tgListener.Group, tgListener.IdleDuration, tgListener.SuperUsers,
×
524
                tgListener.AdminGroup, tgListener.TestingIDs, tgListener.NoSpamReply, tgListener.SuppressJoinMessage,
×
525
                tgListener.Dry, tgListener.TrainingMode)
×
526

×
527
        // activate web server if enabled, with DM users provider from the telegram listener
×
NEW
528
        if settings.Server.Enabled {
×
NEW
529
                if srvErr := activateServer(ctx, settings, spamBot, locator, dataDB, &tgListener,
×
NEW
530
                        tgListener.BotUsername, reloadNormalize); srvErr != nil {
×
531
                        return fmt.Errorf("can't activate web server, %w", srvErr)
×
532
                }
×
533
        }
534

535
        // run telegram listener and event processor loop
536
        if err := tgListener.Do(ctx); err != nil { //nolint:staticcheck // do() runs infinite loop, always returns error on exit
×
537
                return fmt.Errorf("telegram listener failed, %w", err)
×
538
        }
×
539
        return nil
×
540
}
541

542
// makeDB creates database connection based on the settings model
543
func makeDB(ctx context.Context, settings *config.Settings) (*engine.SQL, error) {
26✔
544
        if settings.Transient.DataBaseURL == "" {
26✔
545
                return nil, errors.New("empty database URL")
×
546
        }
×
547
        dbURL := settings.Transient.DataBaseURL
26✔
548

26✔
549
        // if dbURL has no path separator, assume it is a file name and add dynamic data path for sqlite
26✔
550
        if !strings.Contains(dbURL, "/") && !strings.Contains(dbURL, "\\") {
26✔
NEW
551
                dbURL = filepath.Join(settings.Files.DynamicDataPath, dbURL)
×
552
        }
×
553
        log.Printf("[DEBUG] data db: %s", dbURL)
26✔
554

26✔
555
        db, err := engine.New(ctx, dbURL, settings.InstanceID)
26✔
556
        if err != nil {
26✔
NEW
557
                return nil, fmt.Errorf("can't make db %s, %w", settings.Transient.DataBaseURL, err)
×
558
        }
×
559

560
        // backup db on version change for sqlite
561
        if db.Type() == engine.Sqlite {
52✔
562
                // get file name from dbURL for sqlite
26✔
563
                dbFile := dbURL
26✔
564
                dbFile = strings.TrimPrefix(dbFile, "file://")
26✔
565
                dbFile = strings.TrimPrefix(dbFile, "file:")
26✔
566

26✔
567
                // make backup of db on version change for sqlite
26✔
568
                if settings.MaxBackups > 0 {
26✔
NEW
569
                        if err := backupDB(dbFile, revision, settings.MaxBackups); err != nil {
×
570
                                return nil, fmt.Errorf("backup on version change failed, %w", err)
×
571
                        }
×
572
                } else {
26✔
573
                        log.Print("[WARN] database backups disabled")
26✔
574
                }
26✔
575
        }
576
        return db, nil
26✔
577
}
578

579
// checkVolumeMount checks if dynamic files location mounted in docker and shows warning if not
580
// returns true if running not in docker or dynamic files dir mounted
581
func checkVolumeMount(settings *config.Settings) (ok bool) {
9✔
582
        if os.Getenv("TGSPAM_IN_DOCKER") != "1" {
14✔
583
                return true
5✔
584
        }
5✔
585
        log.Printf("[DEBUG] running in docker")
4✔
586
        warnMsg := fmt.Sprintf("dynamic files dir %q is not mounted, changes will be lost on container restart",
4✔
587
                settings.Files.DynamicDataPath)
4✔
588

4✔
589
        // check if dynamic files dir not present. This means it is not mounted
4✔
590
        _, err := os.Stat(settings.Files.DynamicDataPath)
4✔
591
        if err != nil {
5✔
592
                log.Printf("[WARN] %s", warnMsg)
1✔
593
                // no dynamic files dir, no need to check further
1✔
594
                return false
1✔
595
        }
1✔
596

597
        // check if .not_mounted file missing, this means it is mounted
598
        if _, err = os.Stat(filepath.Join(settings.Files.DynamicDataPath, ".not_mounted")); err != nil {
5✔
599
                return true
2✔
600
        }
2✔
601

602
        // if .not_mounted file present, it can be mounted anyway with docker named volumes
603
        output, err := exec.Command("mount").Output()
1✔
604
        if err != nil {
1✔
605
                log.Printf("[WARN] %s, can't check mount: %v", warnMsg, err)
×
606
                return true
×
607
        }
×
608
        // check if the output contains the specified directory
609
        for line := range strings.SplitSeq(string(output), "\n") {
26✔
610
                if strings.Contains(line, settings.Files.DynamicDataPath) {
25✔
611
                        return true
×
612
                }
×
613
        }
614

615
        log.Printf("[WARN] %s", warnMsg)
1✔
616
        return false
1✔
617
}
618

619
func activateServer(ctx context.Context, settings *config.Settings, sf *bot.SpamFilter, loc *storage.Locator,
620
        db *engine.SQL, dmUsersProvider webapi.DMUsersProvider, botUsername string,
621
        reloadNormalize func(*config.Settings)) (err error) {
4✔
622
        // safety net: when --confdb leaves the web UI without any auth material, fall
4✔
623
        // back to generating a random password (matches legacy behavior where CLI
4✔
624
        // default --server.auth=auto would trigger random-password generation)
4✔
625
        applyAutoAuthFallback(settings)
4✔
626

4✔
627
        // handle authentication - always use bcrypt hash for security
4✔
628
        authPasswd := settings.Transient.WebAuthPasswd
4✔
629
        authHash := settings.Server.AuthHash
4✔
630

4✔
631
        // if hash is provided, use it directly
4✔
632
        if authHash != "" {
6✔
633
                log.Printf("[INFO] using provided bcrypt hash for authentication")
2✔
634
        } else if authPasswd != "" {
6✔
635
                // generate hash from password if no hash but password is provided
2✔
636
                // generateAuthHash handles the "auto" password case internally
2✔
637
                authHash, err = generateAuthHash(authPasswd)
2✔
638
                if err != nil {
2✔
NEW
639
                        return fmt.Errorf("can't handle authentication setup: %w", err)
×
640
                }
×
641
                // store the hash directly in the Server settings domain
642
                settings.Server.AuthHash = authHash
2✔
643
        }
644
        // when neither hash nor password is provided, auth will be disabled
645

646
        // make store and load approved users
647
        detectedSpamStore, dsErr := storage.NewDetectedSpam(ctx, db)
4✔
648
        if dsErr != nil {
4✔
649
                return fmt.Errorf("can't make detected spam store, %w", dsErr)
×
650
        }
×
651

652
        // create settings store for database access if config DB mode is enabled
653
        var settingsStore *config.Store
4✔
654
        if settings.Transient.ConfigDB {
4✔
NEW
655
                var storeOpts []config.StoreOption
×
NEW
656
                if settings.Transient.ConfigDBEncryptKey != "" {
×
NEW
657
                        crypter, cryptErr := config.NewCrypter(settings.Transient.ConfigDBEncryptKey, settings.InstanceID)
×
NEW
658
                        if cryptErr != nil {
×
NEW
659
                                return fmt.Errorf("invalid encryption key for settings store: %w", cryptErr)
×
NEW
660
                        }
×
NEW
661
                        storeOpts = append(storeOpts, config.WithCrypter(crypter))
×
662
                }
NEW
663
                store, err := config.NewStore(ctx, db, storeOpts...)
×
NEW
664
                if err != nil {
×
NEW
665
                        return fmt.Errorf("failed to create settings store: %w", err)
×
NEW
666
                }
×
NEW
667
                settingsStore = store
×
668
        }
669

670
        // make dictionary store for webapi
671
        dictionaryStore, dictErr := storage.NewDictionary(ctx, db)
4✔
672
        if dictErr != nil {
4✔
673
                return fmt.Errorf("can't make dictionary store, %w", dictErr)
×
674
        }
×
675

676
        cfg := webapi.Config{
4✔
677
                ListenAddr:      settings.Server.ListenAddr,
4✔
678
                Detector:        sf.Detector,
4✔
679
                SpamFilter:      sf,
4✔
680
                Locator:         loc,
4✔
681
                DetectedSpam:    detectedSpamStore,
4✔
682
                Dictionary:      dictionaryStore,
4✔
683
                StorageEngine:   db, // add database engine for backup functionality
4✔
684
                DMUsersProvider: dmUsersProvider,
4✔
685
                AuthUser:        settings.Server.AuthUser, // optional basic auth user (defaults to "tg-spam" when empty)
4✔
686
                AuthHash:        authHash,                 // use the hash (either from options or generated)
4✔
687
                Version:         revision,
4✔
688
                Dbg:             settings.Transient.Dbg,
4✔
689
                BotUsername:     botUsername,
4✔
690
                AppSettings:     settings,
4✔
691
                ConfigDBMode:    settings.Transient.ConfigDB, // indicate we're running with database config
4✔
692
                // applies startup-equivalent defaults-fill + operational CLI overrides on /config/reload
4✔
693
                ReloadNormalize: reloadNormalize,
4✔
694
        }
4✔
695
        if settingsStore != nil {
4✔
NEW
696
                cfg.SettingsStore = settingsStore // avoid nil-interface-wrapping-nil-pointer trap
×
NEW
697
        }
×
698
        srv := webapi.NewServer(cfg)
4✔
699

4✔
700
        go func() {
8✔
701
                if err := srv.Run(ctx); err != nil {
4✔
702
                        log.Printf("[ERROR] web server failed, %v", err)
×
703
                }
×
704
        }()
705
        return nil
4✔
706
}
707

708
// makeDetector creates spam detector with all checkers and updaters
709
// it loads samples and dynamic files
710
func makeDetector(settings *config.Settings) *tgspam.Detector {
10✔
711
        detectorConfig := tgspam.Config{
10✔
712
                MaxAllowedEmoji:     settings.MaxEmoji,
10✔
713
                MinMsgLen:           settings.MinMsgLen,
10✔
714
                SimilarityThreshold: settings.SimilarityThreshold,
10✔
715
                MinSpamProbability:  settings.MinSpamProbability,
10✔
716
                CasAPI:              settings.CAS.API,
10✔
717
                CasUserAgent:        settings.CAS.UserAgent,
10✔
718
                HTTPClient:          &http.Client{Timeout: settings.CAS.Timeout},
10✔
719
                FirstMessageOnly:    !settings.ParanoidMode,
10✔
720
                FirstMessagesCount:  settings.FirstMessagesCount,
10✔
721
                OpenAIVeto:          settings.OpenAI.Veto,
10✔
722
                OpenAIHistorySize:   settings.OpenAI.HistorySize, // how many last requests sent to openai
10✔
723
                GeminiVeto:          settings.Gemini.Veto,
10✔
724
                GeminiHistorySize:   settings.Gemini.HistorySize, // how many last requests sent to gemini
10✔
725
                LLMConsensus:        tgspam.LLMConsensusMode(settings.LLM.Consensus),
10✔
726
                LLMRequestTimeout:   settings.LLM.RequestTimeout,
10✔
727
                MultiLangWords:      settings.MultiLangWords,
10✔
728
                HistorySize:         settings.History.Size, // how many last request stored in memory
10✔
729
        }
10✔
730

10✔
731
        // FirstMessagesCount and ParanoidMode are mutually exclusive.
10✔
732
        // ParanoidMode still here for backward compatibility only.
10✔
733
        if settings.FirstMessagesCount > 0 { // if FirstMessagesCount is set, FirstMessageOnly is enforced
12✔
734
                detectorConfig.FirstMessageOnly = true
2✔
735
        }
2✔
736
        if settings.ParanoidMode { // if ParanoidMode is set, FirstMessagesCount is ignored
11✔
737
                detectorConfig.FirstMessageOnly = false
1✔
738
                detectorConfig.FirstMessagesCount = 0
1✔
739
        }
1✔
740
        if settings.Transient.StorageTimeout > 0 { // if StorageTimeout is non-zero, set it. If zero, storage timeout is disabled
10✔
NEW
741
                detectorConfig.StorageTimeout = settings.Transient.StorageTimeout
×
UNCOV
742
        }
×
743

744
        // set duplicate detection config
745
        detectorConfig.DuplicateDetection.Threshold = settings.Duplicates.Threshold
10✔
746
        detectorConfig.DuplicateDetection.Window = settings.Duplicates.Window
10✔
747
        if settings.Duplicates.Threshold > 0 {
10✔
748
                log.Printf("[INFO] duplicate messages check enabled, threshold: %d, window: %v",
×
NEW
749
                        settings.Duplicates.Threshold, settings.Duplicates.Window)
×
750
        }
×
751

752
        detectorConfig.ReactionSpam.MaxReactions = settings.Reactions.MaxReactions
10✔
753
        detectorConfig.ReactionSpam.Window = settings.Reactions.Window
10✔
754
        if settings.Reactions.MaxReactions > 0 {
10✔
755
                log.Printf("[INFO] reaction spam detection enabled, max reactions: %d, window: %v",
×
NEW
756
                        settings.Reactions.MaxReactions, settings.Reactions.Window)
×
757
        }
×
758

759
        detector := tgspam.NewDetector(detectorConfig)
10✔
760

10✔
761
        if settings.IsOpenAIEnabled() {
12✔
762
                log.Printf("[WARN] openai enabled")
2✔
763
                openAIConfig := tgspam.OpenAIConfig{
2✔
764
                        SystemPrompt:                 settings.OpenAI.Prompt,
2✔
765
                        CustomPrompts:                settings.OpenAI.CustomPrompts,
2✔
766
                        Model:                        settings.OpenAI.Model,
2✔
767
                        MaxTokensResponse:            settings.OpenAI.MaxTokensResponse,
2✔
768
                        MaxTokensRequest:             settings.OpenAI.MaxTokensRequest,
2✔
769
                        MaxSymbolsRequest:            settings.OpenAI.MaxSymbolsRequest,
2✔
770
                        RetryCount:                   settings.OpenAI.RetryCount,
2✔
771
                        ReasoningEffort:              settings.OpenAI.ReasoningEffort,
2✔
772
                        CheckShortMessagesWithOpenAI: settings.OpenAI.CheckShortMessages,
2✔
773
                }
2✔
774

2✔
775
                openaiConfig := openai.DefaultConfig(settings.OpenAI.Token)
2✔
776
                if settings.OpenAI.APIBase != "" {
2✔
NEW
777
                        openaiConfig.BaseURL = settings.OpenAI.APIBase
×
UNCOV
778
                }
×
779
                log.Printf("[DEBUG] openai config: %+v", openAIConfig)
2✔
780

2✔
781
                detector.WithOpenAIChecker(openai.NewClientWithConfig(openaiConfig), openAIConfig)
2✔
782
        }
783

784
        if settings.Gemini.Token != "" {
10✔
785
                log.Printf("[WARN] gemini enabled")
×
786
                geminiConfig := tgspam.GeminiConfig{
×
NEW
787
                        SystemPrompt:       settings.Gemini.Prompt,
×
NEW
788
                        CustomPrompts:      settings.Gemini.CustomPrompts,
×
NEW
789
                        Model:              settings.Gemini.Model,
×
NEW
790
                        MaxOutputTokens:    settings.Gemini.MaxTokensResponse,
×
NEW
791
                        MaxSymbolsRequest:  settings.Gemini.MaxSymbolsRequest,
×
NEW
792
                        RetryCount:         settings.Gemini.RetryCount,
×
NEW
793
                        CheckShortMessages: settings.Gemini.CheckShortMessages,
×
794
                }
×
795

×
796
                client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
×
NEW
797
                        APIKey:  settings.Gemini.Token,
×
798
                        Backend: genai.BackendGeminiAPI,
×
799
                })
×
800
                if err != nil {
×
801
                        log.Fatalf("[ERROR] failed to create gemini client: %v", err)
×
802
                }
×
803
                log.Printf("[DEBUG] gemini config: %+v", geminiConfig)
×
804
                detector.WithGeminiChecker(client.Models, geminiConfig)
×
805
        }
806

807
        if settings.AbnormalSpace.Enabled {
10✔
808
                log.Printf("[INFO] words spacing check enabled")
×
809
                detector.AbnormalSpacing.Enabled = true
×
NEW
810
                detector.AbnormalSpacing.ShortWordLen = settings.AbnormalSpace.ShortWordLen
×
NEW
811
                detector.AbnormalSpacing.ShortWordRatioThreshold = settings.AbnormalSpace.ShortWordRatioThreshold
×
NEW
812
                detector.AbnormalSpacing.SpaceRatioThreshold = settings.AbnormalSpace.SpaceRatioThreshold
×
NEW
813
                detector.AbnormalSpacing.MinWordsCount = settings.AbnormalSpace.MinWords
×
UNCOV
814
        }
×
815

816
        metaChecks := []tgspam.MetaCheck{}
10✔
817
        if settings.Meta.ImageOnly {
10✔
NEW
818
                log.Printf("[INFO] image only check enabled, min text len: %d", settings.MinMsgLen)
×
NEW
819
                metaChecks = append(metaChecks, tgspam.ImagesCheck(settings.MinMsgLen))
×
UNCOV
820
        }
×
821
        if settings.Meta.VideosOnly {
10✔
NEW
822
                log.Printf("[INFO] videos only check enabled, min text len: %d", settings.MinMsgLen)
×
NEW
823
                metaChecks = append(metaChecks, tgspam.VideosCheck(settings.MinMsgLen))
×
UNCOV
824
        }
×
825
        if settings.Meta.AudiosOnly {
10✔
NEW
826
                log.Printf("[INFO] audio only check enabled, min text len: %d", settings.MinMsgLen)
×
NEW
827
                metaChecks = append(metaChecks, tgspam.AudioCheck(settings.MinMsgLen))
×
UNCOV
828
        }
×
829
        if settings.Meta.LinksLimit >= 0 {
20✔
830
                log.Printf("[INFO] links check enabled, limit: %d", settings.Meta.LinksLimit)
10✔
831
                metaChecks = append(metaChecks, tgspam.LinksCheck(settings.Meta.LinksLimit))
10✔
832
        }
10✔
833
        if settings.Meta.MentionsLimit >= 0 {
20✔
834
                log.Printf("[INFO] mentions check enabled, limit: %d", settings.Meta.MentionsLimit)
10✔
835
                metaChecks = append(metaChecks, tgspam.MentionsCheck(settings.Meta.MentionsLimit))
10✔
836
        }
10✔
837
        if settings.Meta.LinksOnly {
10✔
838
                log.Printf("[INFO] links only check enabled")
×
839
                metaChecks = append(metaChecks, tgspam.LinkOnlyCheck())
×
840
        }
×
841
        if settings.Meta.Forward {
10✔
842
                log.Printf("[INFO] forward check enabled")
×
843
                metaChecks = append(metaChecks, tgspam.ForwardedCheck())
×
844
        }
×
845
        if settings.Meta.Keyboard {
10✔
846
                log.Printf("[INFO] keyboard check enabled")
×
847
                metaChecks = append(metaChecks, tgspam.KeyboardCheck())
×
848
        }
×
849
        if settings.Meta.ContactOnly {
10✔
850
                log.Printf("[INFO] contact only check enabled")
×
851
                metaChecks = append(metaChecks, tgspam.ContactCheck())
×
852
        }
×
853
        if settings.Meta.UsernameSymbols != "" {
10✔
NEW
854
                log.Printf("[INFO] username symbols check enabled, prohibited symbols: %q", settings.Meta.UsernameSymbols)
×
NEW
855
                metaChecks = append(metaChecks, tgspam.UsernameSymbolsCheck(settings.Meta.UsernameSymbols))
×
UNCOV
856
        }
×
857
        if settings.Meta.Giveaway {
10✔
858
                log.Printf("[INFO] giveaway check enabled")
×
859
                metaChecks = append(metaChecks, tgspam.GiveawayCheck())
×
860
        }
×
861
        detector.WithMetaChecks(metaChecks...)
10✔
862

10✔
863
        log.Printf("[DEBUG] detector config: %+v", detectorConfig)
10✔
864

10✔
865
        // initialize Lua plugins if enabled
10✔
866
        if settings.LuaPlugins.Enabled {
10✔
NEW
867
                initLuaPlugins(detector, settings)
×
UNCOV
868
        }
×
869

870
        return detector
10✔
871
}
872

873
// initLuaPlugins initializes Lua plugin engine and configures it
874
func initLuaPlugins(detector *tgspam.Detector, settings *config.Settings) {
2✔
875
        // copy Lua plugin settings to detector config
2✔
876
        detector.LuaPlugins.Enabled = true
2✔
877
        detector.LuaPlugins.PluginsDir = settings.LuaPlugins.PluginsDir
2✔
878
        detector.LuaPlugins.EnabledPlugins = settings.LuaPlugins.EnabledPlugins
2✔
879
        detector.LuaPlugins.DynamicReload = settings.LuaPlugins.DynamicReload
2✔
880

2✔
881
        // create and initialize the plugin engine
2✔
882
        luaEngine := plugin.NewChecker()
2✔
883
        if err := detector.WithLuaEngine(luaEngine); err != nil {
3✔
884
                log.Printf("[WARN] failed to initialize Lua plugins: %v", err)
1✔
885
                return
1✔
886
        }
1✔
887

888
        // log successful initialization
889
        log.Printf("[INFO] lua plugins enabled from directory: %s", settings.LuaPlugins.PluginsDir)
1✔
890

1✔
891
        // log which plugins are enabled
1✔
892
        if len(settings.LuaPlugins.EnabledPlugins) > 0 {
1✔
NEW
893
                log.Printf("[INFO] enabled Lua plugins: %v", settings.LuaPlugins.EnabledPlugins)
×
894
        } else {
1✔
895
                log.Print("[INFO] all Lua plugins from directory are enabled")
1✔
896
        }
1✔
897

898
        // log if dynamic reloading is enabled
899
        if settings.LuaPlugins.DynamicReload {
1✔
NEW
900
                log.Print("[INFO] dynamic reloading of Lua plugins enabled")
×
NEW
901
        }
×
902
}
903

904
func makeSpamBot(ctx context.Context, settings *config.Settings, dataDB *engine.SQL,
905
        detector *tgspam.Detector) (*bot.SpamFilter, error) {
6✔
906
        if dataDB == nil || detector == nil {
7✔
907
                return nil, errors.New("nil datadb or detector")
1✔
908
        }
1✔
909

910
        // make samples store
911
        samplesStore, err := storage.NewSamples(ctx, dataDB)
5✔
912
        if err != nil {
5✔
913
                return nil, fmt.Errorf("can't make samples store, %w", err)
×
914
        }
×
915
        if err = migrateSamples(ctx, settings, samplesStore); err != nil {
5✔
916
                return nil, fmt.Errorf("can't migrate samples, %w", err)
×
917
        }
×
918

919
        // make dictionary store
920
        dictionaryStore, err := storage.NewDictionary(ctx, dataDB)
5✔
921
        if err != nil {
5✔
922
                return nil, fmt.Errorf("can't make dictionary store, %w", err)
×
923
        }
×
924
        if err := migrateDicts(ctx, settings, dictionaryStore); err != nil {
5✔
925
                return nil, fmt.Errorf("can't migrate dictionary, %w", err)
×
926
        }
×
927

928
        spamBotParams := bot.SpamConfig{
5✔
929
                GroupID:      settings.InstanceID,
5✔
930
                SamplesStore: samplesStore,
5✔
931
                DictStore:    dictionaryStore,
5✔
932
                SpamMsg:      settings.Message.Spam,
5✔
933
                SpamDryMsg:   settings.Message.Dry,
5✔
934
                Dry:          settings.Dry,
5✔
935
        }
5✔
936
        spamBot := bot.NewSpamFilter(detector, spamBotParams)
5✔
937
        log.Printf("[DEBUG] spam bot config: %+v", spamBotParams)
5✔
938

5✔
939
        if err := spamBot.ReloadSamples(); err != nil {
5✔
940
                return nil, fmt.Errorf("can't reload samples, %w", err)
×
941
        }
×
942

943
        // set detector samples updaters
944
        detector.WithSpamUpdater(storage.NewSampleUpdater(samplesStore, storage.SampleTypeSpam, settings.Transient.StorageTimeout))
5✔
945
        detector.WithHamUpdater(storage.NewSampleUpdater(samplesStore, storage.SampleTypeHam, settings.Transient.StorageTimeout))
5✔
946

5✔
947
        return spamBot, nil
5✔
948
}
949

950
// normalizeFilePaths expands ~ and makes file paths absolute, applying the
951
// empty-samples-path fallback so SamplesDataPath inherits DynamicDataPath when
952
// the operator left it unset. Called at startup and from the reloadNormalize
953
// closure so POST /config/reload yields the same path shape as startup.
954
func normalizeFilePaths(s *config.Settings) {
3✔
955
        s.Files.DynamicDataPath = expandPath(s.Files.DynamicDataPath)
3✔
956
        if s.Files.SamplesDataPath == "" {
5✔
957
                s.Files.SamplesDataPath = s.Files.DynamicDataPath
2✔
958
                return
2✔
959
        }
2✔
960
        s.Files.SamplesDataPath = expandPath(s.Files.SamplesDataPath)
1✔
961
}
962

963
// expandPath expands ~ to home dir and makes the absolute path
964
func expandPath(path string) string {
14✔
965
        if path == "" {
15✔
966
                return ""
1✔
967
        }
1✔
968
        if path[0] == '~' {
18✔
969
                home, err := os.UserHomeDir()
5✔
970
                if err != nil {
5✔
971
                        return ""
×
972
                }
×
973
                return filepath.Join(home, path[1:])
5✔
974
        }
975
        ep, err := filepath.Abs(path)
8✔
976
        if err != nil {
8✔
977
                return path
×
978
        }
×
979
        return ep
8✔
980
}
981

982
type nopWriteCloser struct{ io.Writer }
983

984
func (n nopWriteCloser) Close() error { return nil }
×
985

986
// makeSpamLogger creates spam logger to keep reports about spam messages
987
// it writes json lines to the provided writer
988
func makeSpamLogger(ctx context.Context, gid string, wr io.Writer, dataDB *engine.SQL) (events.SpamLogger, error) {
1✔
989
        // make store and load approved users
1✔
990
        detectedSpamStore, auErr := storage.NewDetectedSpam(ctx, dataDB)
1✔
991
        if auErr != nil {
1✔
992
                return nil, fmt.Errorf("can't make approved users store, %w", auErr)
×
993
        }
×
994

995
        logWr := events.SpamLoggerFunc(func(msg *bot.Message, response *bot.Response) {
2✔
996
                userName := msg.From.Username
1✔
997
                if userName == "" {
1✔
998
                        userName = msg.From.DisplayName
×
999
                }
×
1000
                // write to log file
1001
                text := strings.ReplaceAll(msg.Text, "\n", " ")
1✔
1002
                text = strings.TrimSpace(text)
1✔
1003
                log.Printf("[DEBUG] spam detected from %v, text: %s", msg.From, text)
1✔
1004
                m := struct {
1✔
1005
                        TimeStamp   string `json:"ts"`
1✔
1006
                        DisplayName string `json:"display_name"`
1✔
1007
                        UserName    string `json:"user_name"`
1✔
1008
                        UserID      int64  `json:"user_id"`
1✔
1009
                        Text        string `json:"text"`
1✔
1010
                }{
1✔
1011
                        TimeStamp:   time.Now().In(time.Local).Format(time.RFC3339),
1✔
1012
                        DisplayName: msg.From.DisplayName,
1✔
1013
                        UserName:    msg.From.Username,
1✔
1014
                        UserID:      msg.From.ID,
1✔
1015
                        Text:        text,
1✔
1016
                }
1✔
1017
                line, err := json.Marshal(&m)
1✔
1018
                if err != nil {
1✔
1019
                        log.Printf("[WARN] can't marshal json, %v", err)
×
1020
                        return
×
1021
                }
×
1022
                if _, err := wr.Write(append(line, '\n')); err != nil {
1✔
1023
                        log.Printf("[WARN] can't write to log, %v", err)
×
1024
                }
×
1025

1026
                // write to db store
1027
                rec := storage.DetectedSpamInfo{
1✔
1028
                        Text:      text,
1✔
1029
                        UserID:    msg.From.ID,
1✔
1030
                        UserName:  userName,
1✔
1031
                        Timestamp: time.Now().In(time.Local),
1✔
1032
                        GID:       gid,
1✔
1033
                }
1✔
1034
                if err := detectedSpamStore.Write(ctx, rec, response.CheckResults); err != nil {
1✔
1035
                        log.Printf("[WARN] can't write to db, %v", err)
×
1036
                }
×
1037
        })
1038

1039
        return logWr, nil
1✔
1040
}
1041

1042
// makeSpamLogWriter creates spam log writer to keep reports about spam messages
1043
// it parses settings and makes lumberjack logger with rotation
1044
func makeSpamLogWriter(settings *config.Settings) (accessLog io.WriteCloser, err error) {
3✔
1045
        if !settings.Logger.Enabled {
4✔
1046
                return nopWriteCloser{io.Discard}, nil
1✔
1047
        }
1✔
1048

1049
        sizeParse := func(inp string) (uint64, error) {
4✔
1050
                if inp == "" {
2✔
1051
                        return 0, errors.New("empty value")
×
1052
                }
×
1053
                for i, sfx := range []string{"k", "m", "g", "t"} {
8✔
1054
                        if strings.HasSuffix(inp, strings.ToUpper(sfx)) || strings.HasSuffix(inp, strings.ToLower(sfx)) {
7✔
1055
                                val, err := strconv.Atoi(inp[:len(inp)-1])
1✔
1056
                                if err != nil {
1✔
1057
                                        return 0, fmt.Errorf("can't parse %s: %w", inp, err)
×
1058
                                }
×
1059
                                return uint64(float64(val) * math.Pow(float64(1024), float64(i+1))), nil
1✔
1060
                        }
1061
                }
1062
                return strconv.ParseUint(inp, 10, 64)
1✔
1063
        }
1064

1065
        maxSize, perr := sizeParse(settings.Logger.MaxSize)
2✔
1066
        if perr != nil {
3✔
1067
                return nil, fmt.Errorf("can't parse logger MaxSize: %w", perr)
1✔
1068
        }
1✔
1069

1070
        maxSize /= 1048576
1✔
1071

1✔
1072
        log.Printf("[INFO] logger enabled for %s, max size %dM", settings.Logger.FileName, maxSize)
1✔
1073
        return &lumberjack.Logger{
1✔
1074
                Filename:   settings.Logger.FileName,
1✔
1075
                MaxSize:    int(maxSize), //nolint:gosec // size in MB not that big to cause overflow
1✔
1076
                MaxBackups: settings.Logger.MaxBackups,
1✔
1077
                Compress:   true,
1✔
1078
                LocalTime:  true,
1✔
1079
        }, nil
1✔
1080
}
1081

1082
// migrateSamples runs migrations from legacy text files samples to db, if such files found
1083
func migrateSamples(ctx context.Context, settings *config.Settings, samplesDB *storage.Samples) error {
10✔
1084
        if settings.Convert == "disabled" {
10✔
1085
                log.Print("[DEBUG] samples migration disabled")
×
1086
                return nil
×
1087
        }
×
1088
        migrateSamples := func(file string, sampleType storage.SampleType, origin storage.SampleOrigin) (*storage.SamplesStats, error) {
46✔
1089
                if _, err := os.Stat(file); err != nil {
57✔
1090
                        log.Printf("[DEBUG] samples file %s not found, skip", file)
21✔
1091
                        return &storage.SamplesStats{}, nil
21✔
1092
                }
21✔
1093
                fh, err := os.Open(file) //nolint:gosec // file path is controlled by the app
15✔
1094
                if err != nil {
15✔
1095
                        return nil, fmt.Errorf("can't open samples file, %w", err)
×
1096
                }
×
1097
                defer fh.Close()
15✔
1098
                stats, err := samplesDB.Import(ctx, sampleType, origin, fh, true) // clean records before import
15✔
1099
                if err != nil {
15✔
1100
                        return nil, fmt.Errorf("can't load samples, %w", err)
×
1101
                }
×
1102
                if err := fh.Close(); err != nil {
15✔
1103
                        return nil, fmt.Errorf("can't close samples file, %w", err)
×
1104
                }
×
1105
                if err := os.Rename(file, file+".loaded"); err != nil {
15✔
1106
                        return nil, fmt.Errorf("can't rename samples file, %w", err)
×
1107
                }
×
1108
                return stats, nil
15✔
1109
        }
1110

1111
        if samplesDB == nil {
11✔
1112
                return errors.New("samples db is nil")
1✔
1113
        }
1✔
1114

1115
        // migrate preset spam samples if files exist
1116
        spamPresetFile := filepath.Join(settings.Files.SamplesDataPath, samplesSpamFile)
9✔
1117
        s, err := migrateSamples(spamPresetFile, storage.SampleTypeSpam, storage.SampleOriginPreset)
9✔
1118
        if err != nil {
9✔
1119
                return fmt.Errorf("can't migrate spam preset samples, %w", err)
×
1120
        }
×
1121
        if s.PresetHam > 0 {
9✔
1122
                log.Printf("[DEBUG] spam preset samples loaded: %s", s)
×
1123
        }
×
1124

1125
        // migrate preset ham samples if files exist
1126
        hamPresetFile := filepath.Join(settings.Files.SamplesDataPath, samplesHamFile)
9✔
1127
        s, err = migrateSamples(hamPresetFile, storage.SampleTypeHam, storage.SampleOriginPreset)
9✔
1128
        if err != nil {
9✔
1129
                return fmt.Errorf("can't migrate ham preset samples, %w", err)
×
1130
        }
×
1131
        if s.PresetHam > 0 {
14✔
1132
                log.Printf("[DEBUG] ham preset samples loaded: %s", s)
5✔
1133
        }
5✔
1134

1135
        // migrate dynamic spam samples if files exist
1136
        dynSpamFile := filepath.Join(settings.Files.DynamicDataPath, dynamicSpamFile)
9✔
1137
        s, err = migrateSamples(dynSpamFile, storage.SampleTypeSpam, storage.SampleOriginUser)
9✔
1138
        if err != nil {
9✔
1139
                return fmt.Errorf("can't migrate spam dynamic samples, %w", err)
×
1140
        }
×
1141
        if s.UserSpam > 0 {
10✔
1142
                log.Printf("[DEBUG] spam dynamic samples loaded: %s", s)
1✔
1143
        }
1✔
1144

1145
        // migrate dynamic ham samples if files exist
1146
        dynHamFile := filepath.Join(settings.Files.DynamicDataPath, dynamicHamFile)
9✔
1147
        s, err = migrateSamples(dynHamFile, storage.SampleTypeHam, storage.SampleOriginUser)
9✔
1148
        if err != nil {
9✔
1149
                return fmt.Errorf("can't migrate ham dynamic samples, %w", err)
×
1150
        }
×
1151
        if s.UserHam > 0 {
11✔
1152
                log.Printf("[DEBUG] ham dynamic samples loaded: %s", s)
2✔
1153
        }
2✔
1154

1155
        if s.TotalHam > 0 || s.TotalSpam > 0 {
11✔
1156
                log.Printf("[INFO] samples migration done: %s", s)
2✔
1157
        }
2✔
1158
        return nil
9✔
1159
}
1160

1161
// migrateDicts runs migrations from legacy dictionary text files to db, if needed
1162
func migrateDicts(ctx context.Context, settings *config.Settings, dictDB *storage.Dictionary) error {
10✔
1163
        if settings.Convert == "disabled" {
10✔
1164
                log.Print("[DEBUG] dictionary migration disabled")
×
1165
                return nil
×
1166
        }
×
1167

1168
        migrateDict := func(file string, dictType storage.DictionaryType) (*storage.DictionaryStats, error) {
28✔
1169
                if _, err := os.Stat(file); err != nil {
29✔
1170
                        log.Printf("[DEBUG] dictionary file %s not found, skip", file)
11✔
1171
                        return &storage.DictionaryStats{}, nil
11✔
1172
                }
11✔
1173
                fh, err := os.Open(file) //nolint:gosec // file path is controlled by the app
7✔
1174
                if err != nil {
7✔
1175
                        return nil, fmt.Errorf("can't open dictionary file, %w", err)
×
1176
                }
×
1177
                defer fh.Close()
7✔
1178
                stats, err := dictDB.Import(ctx, dictType, fh, true) // clean records before import
7✔
1179
                if err != nil {
7✔
1180
                        return nil, fmt.Errorf("can't load dictionary, %w", err)
×
1181
                }
×
1182
                if err := fh.Close(); err != nil {
7✔
1183
                        return nil, fmt.Errorf("can't close dictionary file, %w", err)
×
1184
                }
×
1185
                if err := os.Rename(file, file+".loaded"); err != nil {
7✔
1186
                        return nil, fmt.Errorf("can't rename dictionary file, %w", err)
×
1187
                }
×
1188
                return stats, nil
7✔
1189
        }
1190

1191
        if dictDB == nil {
11✔
1192
                return errors.New("dictionary db is nil")
1✔
1193
        }
1✔
1194

1195
        // migrate stop-words if files exist
1196
        stopWordsFile := filepath.Join(settings.Files.SamplesDataPath, stopWordsFile)
9✔
1197
        s, err := migrateDict(stopWordsFile, storage.DictionaryTypeStopPhrase)
9✔
1198
        if err != nil {
9✔
1199
                return fmt.Errorf("can't migrate stop words, %w", err)
×
1200
        }
×
1201
        if s.TotalStopPhrases > 0 {
11✔
1202
                log.Printf("[INFO] stop words loaded: %s", s)
2✔
1203
        }
2✔
1204

1205
        // migrate excluded tokens if files exist
1206
        excludeTokensFile := filepath.Join(settings.Files.SamplesDataPath, excludeTokensFile)
9✔
1207
        s, err = migrateDict(excludeTokensFile, storage.DictionaryTypeIgnoredWord)
9✔
1208
        if err != nil {
9✔
1209
                return fmt.Errorf("can't migrate excluded tokens, %w", err)
×
1210
        }
×
1211
        if s.TotalIgnoredWords > 0 {
12✔
1212
                log.Printf("[INFO] excluded tokens loaded: %s", s)
3✔
1213
        }
3✔
1214

1215
        if s.TotalIgnoredWords > 0 || s.TotalStopPhrases > 0 {
12✔
1216
                log.Printf("[DEBUG] dictionaries migration done: %s", s)
3✔
1217
        }
3✔
1218
        return nil
9✔
1219
}
1220

1221
// backupDB creates a backup of the db file if the version has changed. It copies the db file to a new db file
1222
// named as the original file with a version suffix, e.g., tg-spam.db.master-77e0bfd-20250107T23:17:34.
1223
// The file is created only if the version has changed and a backup file with the name tg-spam.db.<version> does not exist.
1224
// It keeps up to maxBackups files; if maxBackups is 0, no backups are made.
1225
// Files are removed based on the final part of the version, i.e., 20250107T23:17:34, with the oldest backups removed first.
1226
// If the backup file extension suffix with the timestamp is not found, the modification time of the file is used instead.
1227
func backupDB(dbFile, version string, maxBackups int) error {
6✔
1228
        if maxBackups == 0 {
7✔
1229
                return nil
1✔
1230
        }
1✔
1231
        backupFile := dbFile + "." + strings.ReplaceAll(version, ".", "_") // replace dots with underscores for file name
5✔
1232
        if _, err := os.Stat(backupFile); err == nil {
6✔
1233
                // backup file for the version already exists, no need to make it again
1✔
1234
                return nil
1✔
1235
        }
1✔
1236
        if _, err := os.Stat(dbFile); err != nil {
5✔
1237
                // db file not found, no need to backup. This is legit if the db is not created yet on the first run
1✔
1238
                log.Printf("[WARN] db file not found: %s, skip backup", dbFile)
1✔
1239
                return nil
1✔
1240
        }
1✔
1241

1242
        log.Printf("[DEBUG] db backup: %s -> %s", dbFile, backupFile)
3✔
1243
        // copy current db to the backup file
3✔
1244
        if err := fileutils.CopyFile(dbFile, backupFile); err != nil {
3✔
1245
                return fmt.Errorf("failed to copy db file: %w", err)
×
1246
        }
×
1247
        log.Printf("[INFO] db backup created: %s", backupFile)
3✔
1248

3✔
1249
        // cleanup old backups if needed
3✔
1250
        files, err := filepath.Glob(dbFile + ".*")
3✔
1251
        if err != nil {
3✔
1252
                return fmt.Errorf("failed to list backup files: %w", err)
×
1253
        }
×
1254

1255
        if len(files) <= maxBackups {
4✔
1256
                return nil
1✔
1257
        }
1✔
1258

1259
        // sort files by timestamp in version suffix or mod time if suffix not formatted as timestamp
1260
        sort.Slice(files, func(i, j int) bool {
8✔
1261
                getTime := func(f string) time.Time {
18✔
1262
                        base := filepath.Base(f) // file name like this: tg-spam.db.master-77e0bfd-20250107T23:17:34
12✔
1263
                        // try to get timestamp from version suffix first
12✔
1264
                        parts := strings.Split(base, "-")
12✔
1265
                        if len(parts) >= 3 {
22✔
1266
                                suffix := parts[len(parts)-1]
10✔
1267
                                if t, err := time.ParseInLocation("20060102T15:04:05", suffix, time.Local); err == nil {
20✔
1268
                                        return t
10✔
1269
                                }
10✔
1270
                        }
1271
                        // fallback to modification time for non-versioned files
1272
                        fi, err := os.Stat(f)
2✔
1273
                        if err != nil {
2✔
1274
                                log.Printf("[WARN] can't stat file %s: %v", f, err)
×
1275
                                return time.Now().Local() // treat errored files as newest to avoid deleting them
×
1276
                        }
×
1277
                        return fi.ModTime().Local() // convert to local for consistent comparison
2✔
1278
                }
1279
                return getTime(files[i]).Before(getTime(files[j]))
6✔
1280
        })
1281

1282
        // remove oldest files
1283
        for i := 0; i < len(files)-maxBackups; i++ {
5✔
1284
                if err := os.Remove(files[i]); err != nil {
3✔
1285
                        return fmt.Errorf("failed to remove old backup %s: %w", files[i], err)
×
1286
                }
×
1287
                log.Printf("[DEBUG] db backup removed: %s", files[i])
3✔
1288
        }
1289
        return nil
2✔
1290
}
1291

1292
// generateAuthHash creates a bcrypt hash from the given password
1293
// If the password is "auto", generates a random password first
1294
func generateAuthHash(password string) (string, error) {
3✔
1295
        var hashSource string
3✔
1296
        var randomPassword string
3✔
1297
        var err error
3✔
1298

3✔
1299
        // generate random password if needed
3✔
1300
        if password == "auto" {
5✔
1301
                randomPassword, err = webapi.GenerateRandomPassword(20)
2✔
1302
                if err != nil {
2✔
NEW
1303
                        return "", fmt.Errorf("can't generate random password: %w", err)
×
NEW
1304
                }
×
1305
                hashSource = randomPassword
2✔
1306
        } else {
1✔
1307
                hashSource = password
1✔
1308
        }
1✔
1309

1310
        // generate hash from the password or random password
1311
        hash, err := rest.GenerateBcryptHash(hashSource)
3✔
1312
        if err != nil {
3✔
NEW
1313
                return "", fmt.Errorf("can't generate bcrypt hash: %w", err)
×
NEW
1314
        }
×
1315

1316
        // log the appropriate message
1317
        if password == "auto" {
5✔
1318
                log.Printf("[WARN] generated basic auth password for user tg-spam: %q, bcrypt hash: %s", randomPassword, hash)
2✔
1319
        } else {
3✔
1320
                log.Printf("[INFO] generated bcrypt hash from provided password")
1✔
1321
        }
1✔
1322

1323
        return hash, nil
3✔
1324
}
1325

1326
func setupLog(dbg bool, secrets ...string) {
5✔
1327
        logOpts := []lgr.Option{lgr.Msec, lgr.LevelBraces, lgr.StackTraceOnError}
5✔
1328
        if dbg {
10✔
1329
                logOpts = []lgr.Option{lgr.Debug, lgr.CallerFile, lgr.CallerFunc, lgr.Msec, lgr.LevelBraces, lgr.StackTraceOnError}
5✔
1330
        }
5✔
1331

1332
        colorizer := lgr.Mapper{
5✔
1333
                ErrorFunc:  func(s string) string { return color.New(color.FgHiRed).Sprint(s) },
5✔
1334
                WarnFunc:   func(s string) string { return color.New(color.FgRed).Sprint(s) },
80✔
1335
                InfoFunc:   func(s string) string { return color.New(color.FgYellow).Sprint(s) },
254✔
1336
                DebugFunc:  func(s string) string { return color.New(color.FgWhite).Sprint(s) },
330✔
1337
                CallerFunc: func(s string) string { return color.New(color.FgBlue).Sprint(s) },
332✔
1338
                TimeFunc:   func(s string) string { return color.New(color.FgCyan).Sprint(s) },
332✔
1339
        }
1340
        logOpts = append(logOpts, lgr.Map(colorizer))
5✔
1341

5✔
1342
        if len(secrets) > 0 {
6✔
1343
                logOpts = append(logOpts, lgr.Secret(secrets...))
1✔
1344
        }
1✔
1345
        lgr.SetupStdLogger(logOpts...)
5✔
1346
        lgr.Setup(logOpts...)
5✔
1347
}
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