Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ jobs:
- name: Verify container identity and persistent migrations
run: |
set -eu
test "$(docker image inspect ngbot:ci --format '{{ index .Config.Labels \"org.opencontainers.image.revision\" }}')" = "$GITHUB_SHA"
test "$(docker image inspect ngbot:ci --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" = "$GITHUB_SHA"
test "$(docker run --rm --entrypoint ./ngbot ngbot:ci --version)" = "ngbot version=ci revision=$GITHUB_SHA build_date=1970-01-01T00:00:00Z"
docker volume create ngbot-ci-data >/dev/null
trap 'docker volume rm ngbot-ci-data >/dev/null' EXIT
docker run --rm --mount source=ngbot-ci-data,target=/data alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 chown 65532:65532 /data
for pass in 1 2; do
docker run --rm --mount source=ngbot-ci-data,target=/data \
--env NG_TOKEN=validation-token \
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.25.12-alpine AS build
FROM golang:1.25.13-alpine AS build
HEALTHCHECK NONE

ARG VERSION=dev
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/iamwavecut/ngbot

go 1.25.12
go 1.25.13

require (
github.com/OvyFlash/telegram-bot-api v0.0.0-20260715235732-aca8bf3898bb
Expand Down
44 changes: 25 additions & 19 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ import (
"time"
)

const (
testGatekeeperHandler = "gatekeeper"
testGeminiAPIKey = "gemini-key"
testOpenAIAPIKey = "openai-key"
)

func TestLoadUsesProviderSpecificCredential(t *testing.T) {
t.Setenv("NG_TOKEN", "telegram-token")
t.Setenv("NG_HANDLERS", "reactor")
t.Setenv("NG_LLM_API_TYPE", "gemini")
t.Setenv("NG_LLM_API_TYPE", LLMProviderGemini)
t.Setenv("NG_LLM_GEMINI_API_KEY", "gemini-specific")
t.Setenv("NG_LLM_OPENAI_API_KEY", "openai-unused")
t.Setenv("NG_LLM_API_KEY", "legacy-unused")
Expand All @@ -32,7 +38,7 @@ func TestLoadUsesProviderSpecificCredential(t *testing.T) {
func TestLoadUsesBoundedTelegramInboxDefaults(t *testing.T) {
t.Setenv("NG_TOKEN", "telegram-token")
t.Setenv("NG_HANDLERS", "reactor")
t.Setenv("NG_LLM_API_TYPE", "gemini")
t.Setenv("NG_LLM_API_TYPE", LLMProviderGemini)
t.Setenv("NG_LLM_GEMINI_API_KEY", "gemini-specific")
t.Setenv("NG_DOT_PATH", t.TempDir())

Expand All @@ -55,7 +61,7 @@ func TestLoadDefaultsNativeWebAppToLoopback(t *testing.T) {
t.Setenv("NG_TOKEN", "telegram-token")
t.Setenv("NG_HANDLERS", "admin,gatekeeper")
t.Setenv("NG_LLM_API_TYPE", LLMProviderGemini)
t.Setenv("NG_LLM_GEMINI_API_KEY", "gemini-key")
t.Setenv("NG_LLM_GEMINI_API_KEY", testGeminiAPIKey)
t.Setenv("NG_DOT_PATH", t.TempDir())
t.Setenv("NG_TELEGRAM_POLL_TIMEOUT", "60s")
t.Setenv("NG_TELEGRAM_REQUEST_TIMEOUT", "75s")
Expand Down Expand Up @@ -103,7 +109,7 @@ func TestValidateConfigRejectsInvalidWebAppAdmissionLimits(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := validConfigForLLM()
cfg.EnabledHandlers = []string{"admin", "gatekeeper"}
cfg.EnabledHandlers = []string{"admin", testGatekeeperHandler}
cfg.GatekeeperWebApp = tt.webApp
err := validateConfig(&cfg)
if err == nil || !strings.Contains(err.Error(), tt.want) {
Expand All @@ -116,7 +122,7 @@ func TestValidateConfigRejectsInvalidWebAppAdmissionLimits(t *testing.T) {
func TestValidateConfigRejectsTelegramTimeoutAtOrBeyondActionLease(t *testing.T) {
t.Parallel()
cfg := Config{
EnabledHandlers: []string{"gatekeeper"},
EnabledHandlers: []string{testGatekeeperHandler},
Telegram: Telegram{PollTimeout: time.Minute, RequestTimeout: 2 * time.Minute, RecoveryWindow: 10 * time.Minute},
SpamControl: SpamControl{MessageProbationDuration: time.Hour},
}
Expand Down Expand Up @@ -263,7 +269,7 @@ func TestValidateConfig(t *testing.T) {
EnabledHandlers: []string{"reactor"},
LLM: LLM{
APIKey: "legacy-key",
Type: "gemini",
Type: LLMProviderGemini,
},
SpamControl: SpamControl{MessageProbationDuration: 3 * time.Hour},
Telegram: Telegram{
Expand Down Expand Up @@ -295,7 +301,7 @@ func TestValidateConfig(t *testing.T) {
tt.cfg.GatekeeperWebApp.RequestsPerMinute = 120
if tt.cfg.LLM.Type == "" {
tt.cfg.LLM.Type = LLMProviderGemini
tt.cfg.LLM.GeminiAPIKey = "gemini-key"
tt.cfg.LLM.GeminiAPIKey = testGeminiAPIKey
}

err := validateConfig(&tt.cfg)
Expand Down Expand Up @@ -323,13 +329,13 @@ func TestValidateConfigNormalizesProviderConfiguration(t *testing.T) {
if err := validateConfig(&cfg); err != nil {
t.Fatalf("validateConfig returned error: %v", err)
}
if cfg.LLM.Type != "gemini" {
if cfg.LLM.Type != LLMProviderGemini {
t.Fatalf("provider = %q, want gemini", cfg.LLM.Type)
}
if cfg.LLM.Model != "gemini-2.5-flash-lite" {
t.Fatalf("model = %q", cfg.LLM.Model)
}
if cfg.LLM.GeminiAPIKey != "gemini-key" {
if cfg.LLM.GeminiAPIKey != testGeminiAPIKey {
t.Fatalf("gemini key was not normalized")
}
if cfg.LLM.BaseURL != "https://api.openai.com/v1" {
Expand All @@ -347,34 +353,34 @@ func TestValidateConfigRequiresOnlySelectedProviderCredential(t *testing.T) {
}{
{
name: "Gemini provider-specific credential",
llm: LLM{Type: "gemini", GeminiAPIKey: "gemini-key", RequestTimeout: 45 * time.Second},
llm: LLM{Type: LLMProviderGemini, GeminiAPIKey: testGeminiAPIKey, RequestTimeout: 45 * time.Second},
},
{
name: "OpenAI provider-specific credential",
llm: LLM{Type: "openai", OpenAIAPIKey: "openai-key", BaseURL: "https://api.openai.com/v1", RequestTimeout: 45 * time.Second},
llm: LLM{Type: "openai", OpenAIAPIKey: testOpenAIAPIKey, BaseURL: "https://api.openai.com/v1", RequestTimeout: 45 * time.Second},
},
{
name: "legacy Gemini credential fallback",
llm: LLM{Type: "gemini", APIKey: "legacy-key", RequestTimeout: 45 * time.Second},
llm: LLM{Type: LLMProviderGemini, APIKey: "legacy-key", RequestTimeout: 45 * time.Second},
},
{
name: "selected credential missing",
llm: LLM{Type: "gemini", OpenAIAPIKey: "wrong-provider-key", RequestTimeout: 45 * time.Second},
llm: LLM{Type: LLMProviderGemini, OpenAIAPIKey: "wrong-provider-key", RequestTimeout: 45 * time.Second},
wantErr: true,
},
{
name: "unsupported provider is not inferred from available key",
llm: LLM{Type: "other", GeminiAPIKey: "gemini-key", OpenAIAPIKey: "openai-key", RequestTimeout: 45 * time.Second},
llm: LLM{Type: "other", GeminiAPIKey: testGeminiAPIKey, OpenAIAPIKey: testOpenAIAPIKey, RequestTimeout: 45 * time.Second},
wantErr: true,
},
{
name: "OpenAI endpoint must use HTTPS",
llm: LLM{Type: "openai", OpenAIAPIKey: "openai-key", BaseURL: "http://api.openai.com/v1", RequestTimeout: 45 * time.Second},
llm: LLM{Type: "openai", OpenAIAPIKey: testOpenAIAPIKey, BaseURL: "http://api.openai.com/v1", RequestTimeout: 45 * time.Second},
wantErr: true,
},
{
name: "model must be one identifier",
llm: LLM{Type: "gemini", GeminiAPIKey: "gemini-key", Model: "two models", RequestTimeout: 45 * time.Second},
llm: LLM{Type: LLMProviderGemini, GeminiAPIKey: testGeminiAPIKey, Model: "two models", RequestTimeout: 45 * time.Second},
wantErr: true,
},
}
Expand All @@ -399,15 +405,15 @@ func TestValidateConfigRequiresLLMForMandatoryModeration(t *testing.T) {
t.Parallel()

cfg := validConfigForLLM()
cfg.EnabledHandlers = []string{"admin", "gatekeeper"}
cfg.EnabledHandlers = []string{"admin", testGatekeeperHandler}
cfg.LLM = LLM{}
if err := validateConfig(&cfg); err == nil {
t.Fatal("mandatory moderation accepted an empty LLM configuration")
}

cfg.LLM = LLM{
Type: LLMProviderOpenAI,
OpenAIAPIKey: "openai-key",
OpenAIAPIKey: testOpenAIAPIKey,
BaseURL: "http://api.example.test/v1",
RequestTimeout: 45 * time.Second,
}
Expand All @@ -421,7 +427,7 @@ func validConfigForLLM() Config {
EnabledHandlers: []string{"reactor"},
LLM: LLM{
Type: LLMProviderGemini,
GeminiAPIKey: "gemini-key",
GeminiAPIKey: testGeminiAPIKey,
RequestTimeout: 45 * time.Second,
},
SpamControl: SpamControl{MessageProbationDuration: 3 * time.Hour},
Expand Down
6 changes: 3 additions & 3 deletions internal/db/sqlite/client_challenges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestChallengeGenerationRejectsStaleOperations(t *testing.T) {
UserID: 2,
ChatID: 3,
Status: db.ChallengeStatusPending,
SuccessUUID: "first",
SuccessUUID: testFirstValue,
CreatedAt: now,
ExpiresAt: now.Add(time.Minute),
}
Expand Down Expand Up @@ -623,7 +623,7 @@ func TestBanCheckBoundaryCannotBeOverwritten(t *testing.T) {
}
t.Cleanup(func() { _ = client.Close() })
now := time.Now()
first := &db.Challenge{CommChatID: 11, UserID: 12, ChatID: -13, Status: db.ChallengeStatusBanCheckPending, JoinRequestQueryID: "first", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
first := &db.Challenge{CommChatID: 11, UserID: 12, ChatID: -13, Status: db.ChallengeStatusBanCheckPending, JoinRequestQueryID: testFirstValue, CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
if _, err := client.CreateChallenge(t.Context(), first); err != nil {
t.Fatal(err)
}
Expand All @@ -632,7 +632,7 @@ func TestBanCheckBoundaryCannotBeOverwritten(t *testing.T) {
t.Fatalf("duplicate boundary overwrite error=%v", err)
}
stored, err := client.GetChallengeByChatUser(t.Context(), first.ChatID, first.UserID)
if err != nil || stored == nil || stored.JoinRequestQueryID != "first" {
if err != nil || stored == nil || stored.JoinRequestQueryID != testFirstValue {
t.Fatalf("boundary overwritten: %#v err=%v", stored, err)
}
}
Expand Down
8 changes: 4 additions & 4 deletions internal/db/sqlite/client_spam_report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestSetSpamCasePreVoteRestrictedOnlyUpdatesPendingCase(t *testing.T) {
ChatID: -100,
UserID: 200,
MessageID: 40,
MessageText: "candidate",
MessageText: testCandidateMessage,
CreatedAt: now,
ResolveAt: &resolveAt,
Status: db.SpamCaseStatusPending,
Expand Down Expand Up @@ -192,7 +192,7 @@ func TestSpamVoteAndTimeoutRaceHasOneResolutionAndOneStat(t *testing.T) {
ChatID: -100,
UserID: 200,
MessageID: 40,
MessageText: "candidate",
MessageText: testCandidateMessage,
CreatedAt: now,
ResolveAt: &resolveAt,
Status: db.SpamCaseStatusPending,
Expand Down Expand Up @@ -296,7 +296,7 @@ func TestSpamResolutionAndReportQueueSurviveReopen(t *testing.T) {
spamCase, err := client.CreateSpamCase(ctx, &db.SpamCase{
ChatID: -100,
UserID: 200,
MessageText: "candidate",
MessageText: testCandidateMessage,
CreatedAt: now,
ResolveAt: &resolveAt,
Status: db.SpamCaseStatusPending,
Expand Down Expand Up @@ -539,7 +539,7 @@ func TestAddRestrictionRefreshesPersistedState(t *testing.T) {
UserID: userID,
RestrictedAt: now,
ExpiresAt: now.Add(time.Hour),
Reason: "first",
Reason: testFirstValue,
}
if err := client.AddRestriction(ctx, first); err != nil {
t.Fatalf("add first restriction: %v", err)
Expand Down
12 changes: 6 additions & 6 deletions internal/db/sqlite/client_update_inbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ func TestTelegramUpdateInboxAdmissionBoundsPendingRowsAndBytes(t *testing.T) {
})

for _, update := range []*db.TelegramUpdate{
{UpdateID: 1, DispatchKey: "chat:1", Payload: []byte("1234"), ReceivedAt: time.Now()},
{UpdateID: 2, DispatchKey: "chat:1", Payload: []byte("5678"), ReceivedAt: time.Now()},
{UpdateID: 1, DispatchKey: testDispatchKeyChatOne, Payload: []byte("1234"), ReceivedAt: time.Now()},
{UpdateID: 2, DispatchKey: testDispatchKeyChatOne, Payload: []byte("5678"), ReceivedAt: time.Now()},
} {
inserted, enqueueErr := client.EnqueueTelegramUpdate(t.Context(), update)
if enqueueErr != nil || !inserted {
Expand All @@ -62,7 +62,7 @@ func TestTelegramUpdateInboxAdmissionBoundsPendingRowsAndBytes(t *testing.T) {
}

inserted, err := client.EnqueueTelegramUpdate(t.Context(), &db.TelegramUpdate{
UpdateID: 3, DispatchKey: "chat:1", Payload: []byte("security"), SecurityRelevant: true, ReceivedAt: time.Now(),
UpdateID: 3, DispatchKey: testDispatchKeyChatOne, Payload: []byte("security"), SecurityRelevant: true, ReceivedAt: time.Now(),
})
if inserted || !errors.Is(err, db.ErrTelegramUpdateInboxCapacity) {
t.Fatalf("per-key overload = inserted=%t err=%v, want capacity backpressure", inserted, err)
Expand Down Expand Up @@ -137,7 +137,7 @@ func TestTelegramUpdateInboxDeduplicatesAndPreservesOriginalPayload(t *testing.T
receivedAt := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
inserted, err := client.EnqueueTelegramUpdate(t.Context(), &db.TelegramUpdate{
UpdateID: 100,
DispatchKey: "chat:-10",
DispatchKey: testDispatchKeyChatMinus10,
Payload: []byte(`{"update_id":100,"message":{"text":"first"}}`),
SecurityRelevant: true,
ReceivedAt: receivedAt,
Expand All @@ -162,7 +162,7 @@ func TestTelegramUpdateInboxDeduplicatesAndPreservesOriginalPayload(t *testing.T
if len(updates) != 1 {
t.Fatalf("runnable update count = %d, want 1", len(updates))
}
if updates[0].DispatchKey != "chat:-10" || !bytes.Contains(updates[0].Payload, []byte(`"first"`)) || !updates[0].SecurityRelevant {
if updates[0].DispatchKey != testDispatchKeyChatMinus10 || !bytes.Contains(updates[0].Payload, []byte(`"first"`)) || !updates[0].SecurityRelevant {
t.Fatalf("duplicate changed original update: %#v", updates[0])
}
}
Expand All @@ -180,7 +180,7 @@ func TestTelegramUpdateInboxBlocksLaterChatUpdateThroughRetry(t *testing.T) {
for _, updateID := range []int{10, 11} {
inserted, enqueueErr := client.EnqueueTelegramUpdate(t.Context(), &db.TelegramUpdate{
UpdateID: updateID,
DispatchKey: "chat:-10",
DispatchKey: testDispatchKeyChatMinus10,
Payload: []byte(`{"update_id":10}`),
ReceivedAt: now,
})
Expand Down
8 changes: 8 additions & 0 deletions internal/db/sqlite/test_constants_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package sqlite

const (
testCandidateMessage = "candidate"
testFirstValue = "first"
testDispatchKeyChatOne = "chat:1"
testDispatchKeyChatMinus10 = "chat:-10"
)
10 changes: 5 additions & 5 deletions internal/handlers/chat/banlist_guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ func TestBanlistGuardNoRightsStopsWithoutTelegramRetry(t *testing.T) {
guard := NewBanlistGuard(botAPI, &testNotSpammerStore{}, banService)
chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup}
user := &api.User{ID: 200}
message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "spam"}
message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: testSpamMessageText}

proceed, err := guard.Handle(context.Background(), &api.Update{Message: message}, chat, user)
if err != nil {
Expand All @@ -154,7 +154,7 @@ func TestBanlistGuardCapabilityUnknownReturnsRetryableFailure(t *testing.T) {
guard := NewBanlistGuard(&api.BotAPI{}, &testNotSpammerStore{}, banService)
chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup}
user := &api.User{ID: 200}
message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "spam"}
message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: testSpamMessageText}

proceed, err := guard.Handle(t.Context(), &api.Update{Message: message}, chat, user)
if proceed {
Expand Down Expand Up @@ -229,7 +229,7 @@ func TestBanlistGuardAllowsManuallyAllowlistedUser(t *testing.T) {
banService := &testBanService{knownBanned: true}
guard := NewBanlistGuard(botAPI, &testNotSpammerStore{isNotSpammer: true}, banService)
chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup}
user := &api.User{ID: 200, UserName: "allowlisted"}
user := &api.User{ID: 200, UserName: testAllowlistedUser}
message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "/settings"}

proceed, err := guard.Handle(context.Background(), &api.Update{Message: message}, chat, user)
Expand Down Expand Up @@ -257,7 +257,7 @@ func TestBanlistGuardAllowlistLookupFailureContinuesBan(t *testing.T) {
store := &testNotSpammerStore{notSpammerErr: errors.New("database unavailable")}
guard := NewBanlistGuard(botAPI, store, banService)
chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup}
user := &api.User{ID: 200, UserName: "candidate"}
user := &api.User{ID: 200, UserName: testCandidateValue}
message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "message"}

proceed, err := guard.Handle(context.Background(), &api.Update{Message: message}, chat, user)
Expand Down Expand Up @@ -289,7 +289,7 @@ func TestBanlistGuardDoesNotRepeatAmbiguousBanAfterCrash(t *testing.T) {
guard := NewBanlistGuard(botAPI, store, banService)
chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup}
user := &api.User{ID: 200}
message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "spam"}
message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: testSpamMessageText}
update := &api.Update{UpdateID: 900, Message: message}

if _, err := guard.Handle(t.Context(), update, chat, user); err == nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/handlers/chat/gatekeeper_captcha_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ func TestCreateCaptchaButtonsSupportsSmallVariantSet(t *testing.T) {
gk := &Gatekeeper{
Variants: map[string]map[string]string{
"en": {
"🍎": "apple",
"🍎": captchaFallbackWord,
"🐶": "dog",
"🚗": "car",
"🌟": "star",
Expand Down
Loading