├── GEMINI.md ├── supabase ├── .gitignore └── migrations │ ├── 20250806102813_new_extensions.sql │ ├── 20250807120000_captcha_refresh_controls.sql │ ├── 20250808033706_drop_filters_keyword_idx.sql │ ├── 20250809000000_add_user_activity_tracking.sql │ ├── 20250808102118_add_last_activity_to_chats.sql │ ├── 20250815000000_add_stored_messages_table.sql │ ├── 20250806093809_add_missing_channel_index.sql │ ├── 20250807123000_fix_fk_covering_indexes_and_captcha_dup_index.sql │ ├── 20250807103246_add_captcha_tables.sql │ ├── 20250814100000_fix_antiflood_column_duplication.sql │ ├── 20250808120328_fix_unused_indexes_and_missing_fk.sql │ ├── 20250806105636_drop_unused_indexes.sql │ ├── 20250806094457_restore_foreign_key_indexes.sql │ ├── 20250814100001_drop_unused_chat_users_table.sql │ └── 20250806093839_drop_unused_indexes.sql ├── scripts ├── check_translations │ ├── go.mod │ └── go.sum └── migrate │ ├── .env.example │ └── README.md ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.yml ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── dependabot-native-merge.yml │ └── release.yml ├── nixpacks.toml ├── alita ├── utils │ ├── helpers │ │ ├── channel_helpers.go │ │ └── telegram_helpers.go │ ├── debug_bot │ │ └── debug_bot.go │ ├── decorators │ │ ├── cmdDecorator │ │ │ └── cmdDecorator.go │ │ └── misc │ │ │ └── handler_vars.go │ ├── constants │ │ └── time.go │ ├── string_handling │ │ └── string_handling.go │ ├── chat_status │ │ └── helpers.go │ ├── errors │ │ └── errors.go │ ├── async │ │ └── async_processor.go │ ├── error_handling │ │ └── error_handling.go │ ├── cache │ │ ├── cache.go │ │ └── adminCache.go │ ├── shutdown │ │ └── graceful.go │ └── keyword_matcher │ │ ├── cache.go │ │ └── matcher.go ├── i18n │ ├── i18n.go │ ├── errors.go │ ├── types.go │ ├── loader.go │ └── manager.go ├── db │ ├── admin_db.go │ ├── locks_db.go │ ├── pin_db.go │ ├── rules_db.go │ ├── blacklists_db.go │ ├── antiflood_db.go │ ├── channels_db.go │ ├── connections_db.go │ ├── reports_db.go │ ├── disable_db.go │ ├── lang_db.go │ ├── cache_helpers.go │ ├── filters_db.go │ └── chats_db.go ├── config │ └── types.go ├── modules │ ├── antispam.go │ ├── language.go │ └── users.go ├── health │ └── health.go ├── metrics │ └── prometheus.go └── main.go ├── docker ├── goreleaser ├── alpine.debug ├── alpine └── pr-build ├── .gitignore ├── .pre-commit-config.yaml ├── locales └── config.yml ├── LICENSE ├── debug.docker-compose.yml ├── docker-compose.yml ├── AGENTS.md ├── go.mod ├── .goreleaser.yaml └── Makefile /GEMINI.md: -------------------------------------------------------------------------------- 1 | CLAUDE.md -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | 5 | # dotenvx 6 | .env.keys 7 | .env.local 8 | .env.*.local 9 | -------------------------------------------------------------------------------- /scripts/check_translations/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/divkix/Alita_Robot/scripts/check_translations 2 | 3 | go 1.21 4 | 5 | require gopkg.in/yaml.v3 v3.0.1 6 | -------------------------------------------------------------------------------- /supabase/migrations/20250806102813_new_extensions.sql: -------------------------------------------------------------------------------- 1 | create extension if not exists "hypopg" with schema "extensions"; 2 | 3 | create extension if not exists "index_advisor" with schema "extensions"; 4 | 5 | 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Divide Projects Community 4 | url: https://t.me/DivideSupport 5 | about: Join our community chat to get your questions answered easily! 6 | -------------------------------------------------------------------------------- /nixpacks.toml: -------------------------------------------------------------------------------- 1 | providers = ["go"] 2 | 3 | [phases.setup] 4 | aptPkgs = ["postgresql-client", "ca-certificates"] 5 | 6 | [phases.build] 7 | cmds = ["go mod download", "go build -o alita_robot"] 8 | 9 | [start] 10 | cmd = "./alita_robot" 11 | -------------------------------------------------------------------------------- /supabase/migrations/20250807120000_captcha_refresh_controls.sql: -------------------------------------------------------------------------------- 1 | -- Add refresh_count to captcha_attempts to cap refreshes per attempt 2 | ALTER TABLE IF EXISTS captcha_attempts 3 | ADD COLUMN IF NOT EXISTS refresh_count INTEGER DEFAULT 0; 4 | 5 | -- Note: updated_at trigger already exists on captcha_attempts 6 | 7 | 8 | -------------------------------------------------------------------------------- /scripts/migrate/.env.example: -------------------------------------------------------------------------------- 1 | # MongoDB Configuration 2 | MONGO_URI=mongodb://localhost:27017 3 | MONGO_DATABASE=alita 4 | 5 | # PostgreSQL Configuration 6 | DATABASE_URL=postgres://user:password@localhost:5432/alita_db?sslmode=disable 7 | 8 | # Migration Settings 9 | BATCH_SIZE=1000 10 | DRY_RUN=false 11 | VERBOSE=true -------------------------------------------------------------------------------- /supabase/migrations/20250808033706_drop_filters_keyword_idx.sql: -------------------------------------------------------------------------------- 1 | -- Drop the filters_keyword_idx index if it exists 2 | -- This index is not needed as all queries use both chat_id and keyword 3 | -- The existing composite index idx_filters_chat_keyword already covers these queries 4 | DROP INDEX IF EXISTS public.filters_keyword_idx; -------------------------------------------------------------------------------- /scripts/check_translations/go.sum: -------------------------------------------------------------------------------- 1 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 2 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 3 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 4 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 5 | -------------------------------------------------------------------------------- /alita/utils/helpers/channel_helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // IsChannelID checks if a given chat ID represents a channel. 9 | // In Telegram, channel IDs are negative numbers starting with -100. 10 | func IsChannelID(chatID int64) bool { 11 | // Convert to string to check the prefix 12 | chatIDStr := fmt.Sprintf("%d", chatID) 13 | return strings.HasPrefix(chatIDStr, "-100") 14 | } 15 | -------------------------------------------------------------------------------- /alita/utils/debug_bot/debug_bot.go: -------------------------------------------------------------------------------- 1 | package debug_bot 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // PrettyPrintStruct formats and prints a struct as indented JSON to stdout. 9 | // Returns the formatted JSON string for further use or logging. 10 | func PrettyPrintStruct(v any) string { 11 | prettyStruct, _ := json.MarshalIndent(v, "", " ") 12 | jsonStruct := string(prettyStruct) 13 | fmt.Printf("%s\n\n", jsonStruct) 14 | return jsonStruct 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest ideas, new features or enhancements 4 | labels: "enhancement" 5 | --- 6 | 7 | 8 | 9 | ## Checklist 10 | - [ ] I believe the idea is awesome and would benefit the community as well the bot. 11 | - [ ] I have searched in the issue tracker for similar requests, including closed ones. 12 | 13 | ## Description 14 | A detailed description of the request. 15 | -------------------------------------------------------------------------------- /alita/utils/decorators/cmdDecorator/cmdDecorator.go: -------------------------------------------------------------------------------- 1 | package cmdDecorator 2 | 3 | import ( 4 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 5 | 6 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" 7 | ) 8 | 9 | // MultiCommand registers multiple command aliases with the same handler function. 10 | // Useful for creating command shortcuts and alternative names for the same functionality. 11 | func MultiCommand(dispatcher *ext.Dispatcher, alias []string, r handlers.Response) { 12 | for _, i := range alias { 13 | dispatcher.AddHandler(handlers.NewCommand(i, r)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docker/goreleaser: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static-debian12 2 | WORKDIR /app 3 | COPY alita_robot /app/ 4 | COPY supabase /app/supabase 5 | ENTRYPOINT ["/app/alita_robot"] 6 | 7 | # Metadata 8 | LABEL org.opencontainers.image.authors="Divanshu Chauhan " 9 | LABEL org.opencontainers.image.url="https://divkix.me" 10 | LABEL org.opencontainers.image.source="https://github.com/divkix/Alita_Robot" 11 | LABEL org.opencontainers.image.title="Alita Go Robot" 12 | LABEL org.opencontainers.image.description="Official Alita Go Robot Docker Image" 13 | LABEL org.opencontainers.image.vendor="Divkix" 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | vendor/ 16 | dist/ 17 | tmp/ 18 | 19 | # Goland files 20 | .idea/ 21 | 22 | # Executables 23 | Alita_Robot 24 | Alita_Robot.exe 25 | .env 26 | .db.env 27 | local.docker-compose.yml 28 | 29 | # Mac OS DS_Store file 30 | .DS_Store 31 | 32 | # migrate 33 | scripts/migrate/migrate 34 | .mcp.json 35 | alita_test 36 | .gocache/ 37 | -------------------------------------------------------------------------------- /supabase/migrations/20250809000000_add_user_activity_tracking.sql: -------------------------------------------------------------------------------- 1 | -- Add last_activity column to track when users were last active 2 | ALTER TABLE users 3 | ADD COLUMN IF NOT EXISTS last_activity timestamp with time zone DEFAULT CURRENT_TIMESTAMP; 4 | 5 | -- Update existing records to set last_activity based on updated_at 6 | UPDATE users 7 | SET last_activity = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) 8 | WHERE last_activity IS NULL; 9 | 10 | -- Create index for efficient activity queries 11 | CREATE INDEX IF NOT EXISTS idx_users_last_activity ON users(last_activity DESC); 12 | 13 | -- Add comment explaining the column 14 | COMMENT ON COLUMN users.last_activity IS 'Timestamp of user''s last interaction with the bot'; -------------------------------------------------------------------------------- /alita/i18n/i18n.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // NewTranslator creates a new Translator instance using the modern LocaleManager. 8 | // This is the recommended way to handle translations in new code. 9 | func NewTranslator(langCode string) (*Translator, error) { 10 | manager := GetManager() 11 | return manager.GetTranslator(langCode) 12 | } 13 | 14 | // MustNewTranslator creates a new Translator instance and panics on error. 15 | // Useful for initialization where errors should be fatal. 16 | func MustNewTranslator(langCode string) *Translator { 17 | translator, err := NewTranslator(langCode) 18 | if err != nil { 19 | panic(fmt.Sprintf("Failed to create translator for %s: %v", langCode, err)) 20 | } 21 | return translator 22 | } 23 | -------------------------------------------------------------------------------- /supabase/migrations/20250808102118_add_last_activity_to_chats.sql: -------------------------------------------------------------------------------- 1 | -- Add last_activity column to track when groups were last active 2 | ALTER TABLE chats 3 | ADD COLUMN IF NOT EXISTS last_activity timestamp with time zone DEFAULT CURRENT_TIMESTAMP; 4 | 5 | -- Update existing records to set last_activity based on updated_at 6 | UPDATE chats 7 | SET last_activity = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) 8 | WHERE last_activity IS NULL; 9 | 10 | -- Create index for efficient activity queries 11 | CREATE INDEX IF NOT EXISTS idx_chats_last_activity ON chats(last_activity DESC); 12 | 13 | -- Create index for activity status queries 14 | CREATE INDEX IF NOT EXISTS idx_chats_activity_status ON chats(is_inactive, last_activity DESC) WHERE is_inactive = false; 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | 14 | 15 | ko_fi: divkix 16 | github: divkix 17 | -------------------------------------------------------------------------------- /alita/utils/decorators/misc/handler_vars.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import "sync" 4 | 5 | var ( 6 | AdminCmds = make([]string, 0) 7 | UserCmds = make([]string, 0) 8 | DisableCmds = make([]string, 0) 9 | mu = &sync.Mutex{} 10 | ) 11 | 12 | // addToArray safely appends multiple strings to a string slice using mutex protection. 13 | // Thread-safe function for concurrent access to shared command arrays. 14 | func addToArray(arr []string, val ...string) []string { 15 | mu.Lock() 16 | arr = append(arr, val...) 17 | mu.Unlock() 18 | return arr 19 | } 20 | 21 | // AddCmdToDisableable adds a command to the list of commands that can be disabled in chats. 22 | // Administrators can use this to control which commands are available to regular users. 23 | func AddCmdToDisableable(cmd string) { 24 | DisableCmds = addToArray(DisableCmds, cmd) 25 | } 26 | -------------------------------------------------------------------------------- /alita/utils/constants/time.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import "time" 4 | 5 | // Common time durations used throughout the application 6 | const ( 7 | // Cache durations 8 | AdminCacheTTL = 30 * time.Minute 9 | DefaultCacheTTL = 5 * time.Minute 10 | ShortCacheTTL = 1 * time.Minute 11 | LongCacheTTL = 1 * time.Hour 12 | 13 | // Update intervals 14 | UserUpdateInterval = 5 * time.Minute 15 | ChatUpdateInterval = 5 * time.Minute 16 | ChannelUpdateInterval = 5 * time.Minute 17 | 18 | // Timeout durations 19 | DefaultTimeout = 10 * time.Second 20 | ShortTimeout = 5 * time.Second 21 | LongTimeout = 30 * time.Second 22 | CaptchaTimeout = 30 * time.Second 23 | 24 | // Retry and delay durations 25 | RetryDelay = 2 * time.Second 26 | WebhookLatency = 10 * time.Millisecond 27 | 28 | // Activity monitoring 29 | MetricsStaleThreshold = 5 * time.Minute 30 | ) 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | args: ['--maxkb=1000'] 10 | - id: check-merge-conflict 11 | - id: detect-private-key 12 | 13 | - repo: https://github.com/golangci/golangci-lint 14 | rev: v1.62.2 15 | hooks: 16 | - id: golangci-lint 17 | args: ['--timeout=5m'] 18 | 19 | - repo: local 20 | hooks: 21 | - id: go-fmt 22 | name: go fmt 23 | entry: gofmt -l -w 24 | language: system 25 | types: [go] 26 | pass_filenames: true 27 | 28 | - id: go-mod-tidy 29 | name: go mod tidy 30 | entry: go mod tidy 31 | language: system 32 | pass_filenames: false 33 | files: go.mod 34 | -------------------------------------------------------------------------------- /locales/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | #stringcode: string 3 | db_default_welcome: "Hey {first}, how are you?" 4 | db_default_goodbye: "Sad to see you leaving {first}" 5 | db_warn_no_reason: "No Reason" 6 | alt_names: 7 | Admin: [admins, promote, demote, title] 8 | Antiflood: [flood] 9 | Bans: 10 | [ban, kick, dkick, restrict, kickme, unrestrict, sban, dban, tban, unban] 11 | Blacklists: [blacklist, unblacklist] 12 | Connections: [connection, connect] 13 | Disabling: [disable, enable] 14 | Filters: [filter] 15 | Formatting: [markdownhelp, mdhelp] 16 | Greetings: [welcome, goodbye, greeting] 17 | Locks: [lock, unlock] 18 | Languages: [language, lang] 19 | Misc: [extra, extras] 20 | Mutes: [mute, unmute, tmute, smute, dmute] 21 | Notes: [note, notes] 22 | Pins: [antichannelpin, cleanlinked, pins] 23 | Purges: [purge, del] 24 | Reports: [report, reporting] 25 | Rules: [rule] 26 | Warns: [warn, warning, warnings] 27 | -------------------------------------------------------------------------------- /alita/utils/string_handling/string_handling.go: -------------------------------------------------------------------------------- 1 | package string_handling 2 | 3 | import "slices" 4 | 5 | // FindInStringSlice searches for a string value in a string slice. 6 | // Returns true if the value is found, false otherwise. 7 | func FindInStringSlice(slice []string, val string) bool { 8 | return slices.Contains(slice, val) 9 | } 10 | 11 | // FindInInt64Slice searches for an int64 value in an int64 slice. 12 | // Returns true if the value is found, false otherwise. 13 | func FindInInt64Slice(slice []int64, val int64) bool { 14 | return slices.Contains(slice, val) 15 | } 16 | 17 | // IsDuplicateInStringSlice checks for duplicate strings in a string slice. 18 | // Returns the first duplicate found and true, or empty string and false if no duplicates. 19 | func IsDuplicateInStringSlice(arr []string) (string, bool) { 20 | visited := make(map[string]bool) 21 | for i := range arr { 22 | if visited[arr[i]] { 23 | return arr[i], true 24 | } else { 25 | visited[arr[i]] = true 26 | } 27 | } 28 | return "", false 29 | } 30 | -------------------------------------------------------------------------------- /supabase/migrations/20250815000000_add_stored_messages_table.sql: -------------------------------------------------------------------------------- 1 | -- Add StoredMessages table for pre-captcha message storage 2 | CREATE TABLE IF NOT EXISTS stored_messages ( 3 | id BIGSERIAL PRIMARY KEY, 4 | user_id BIGINT NOT NULL, 5 | chat_id BIGINT NOT NULL, 6 | message_type INTEGER NOT NULL DEFAULT 1, 7 | content TEXT, 8 | file_id TEXT, 9 | caption TEXT, 10 | attempt_id BIGINT NOT NULL, 11 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 12 | ); 13 | 14 | -- Create index for user+chat lookups 15 | CREATE INDEX IF NOT EXISTS idx_stored_user_chat ON stored_messages(user_id, chat_id); 16 | 17 | -- Create index for attempt lookups 18 | CREATE INDEX IF NOT EXISTS idx_stored_attempt ON stored_messages(attempt_id); 19 | 20 | -- Add foreign key constraint to captcha_attempts 21 | ALTER TABLE stored_messages 22 | ADD CONSTRAINT fk_stored_messages_attempt 23 | FOREIGN KEY (attempt_id) REFERENCES captcha_attempts(id) ON DELETE CASCADE; 24 | 25 | -- Add comment 26 | COMMENT ON TABLE stored_messages IS 'Stores messages sent by users before completing captcha verification'; 27 | -------------------------------------------------------------------------------- /docker/alpine.debug: -------------------------------------------------------------------------------- 1 | # Build Stage: Build bot using the alpine image 2 | # This is an debug image, it will not compress the binary 3 | FROM golang:alpine AS builder 4 | RUN apk add --no-cache curl wget gnupg git 5 | WORKDIR /app 6 | 7 | # Copy go mod files first for better layer caching 8 | COPY go.mod go.sum ./ 9 | 10 | # Download dependencies - this layer is cached unless go.mod/go.sum change 11 | RUN go mod download && go mod verify 12 | 13 | # Copy source code 14 | COPY . . 15 | 16 | # Build the binary (debug mode - no optimization flags) 17 | RUN go build -o out/alita_robot 18 | 19 | # Note: supabase directory is already copied with COPY . . above 20 | ENTRYPOINT ["/app/out/alita_robot"] 21 | 22 | # Metadata 23 | LABEL org.opencontainers.image.authors="Divanshu Chauhan " 24 | LABEL org.opencontainers.image.url="https://divkix.me" 25 | LABEL org.opencontainers.image.source="https://github.com/divkix/Alita_Robot" 26 | LABEL org.opencontainers.image.title="Alita Go Robot" 27 | LABEL org.opencontainers.image.description="Official Alita Go Robot Debug Docker Image" 28 | LABEL org.opencontainers.image.vendor="Divkix" 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2025 Divanshu Chauhan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /alita/utils/chat_status/helpers.go: -------------------------------------------------------------------------------- 1 | package chat_status 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/PaulSonOfLars/gotgbot/v2" 7 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 8 | 9 | "github.com/divkix/Alita_Robot/alita/db" 10 | "github.com/divkix/Alita_Robot/alita/i18n" 11 | ) 12 | 13 | // sendAnonAdminKeyboard sends an inline keyboard to verify anonymous admin identity. 14 | // Creates a callback button that anonymous admins can click to prove their admin status. 15 | func sendAnonAdminKeyboard(b *gotgbot.Bot, msg *gotgbot.Message, chat *gotgbot.Chat) (*gotgbot.Message, error) { 16 | // Create a minimal context to get the language 17 | ctx := &ext.Context{ 18 | EffectiveMessage: msg, 19 | } 20 | 21 | tr := i18n.MustNewTranslator(db.GetLanguage(ctx)) 22 | mainText, _ := tr.GetString("chat_status_anon_confirm") 23 | buttonText, _ := tr.GetString("chat_status_anon_prove_admin") 24 | 25 | return msg.Reply(b, 26 | mainText, 27 | &gotgbot.SendMessageOpts{ 28 | ReplyMarkup: gotgbot.InlineKeyboardMarkup{ 29 | InlineKeyboard: [][]gotgbot.InlineKeyboardButton{ 30 | {{ 31 | Text: buttonText, 32 | CallbackData: fmt.Sprintf("alita:anonAdmin:%d:%d", chat.Id, msg.MessageId), 33 | }}, 34 | }, 35 | }, 36 | }, 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /alita/utils/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | type WrappedError struct { 10 | Err error 11 | Message string 12 | File string 13 | Line int 14 | Function string 15 | } 16 | 17 | func (e *WrappedError) Error() string { 18 | return fmt.Sprintf("%s at %s:%d in %s: %v", e.Message, e.File, e.Line, e.Function, e.Err) 19 | } 20 | 21 | func (e *WrappedError) Unwrap() error { 22 | return e.Err 23 | } 24 | 25 | func Wrap(err error, message string) error { 26 | if err == nil { 27 | return nil 28 | } 29 | 30 | pc, file, line, ok := runtime.Caller(1) 31 | if !ok { 32 | return fmt.Errorf("%s: %w", message, err) 33 | } 34 | 35 | fn := runtime.FuncForPC(pc) 36 | funcName := "unknown" 37 | if fn != nil { 38 | funcName = fn.Name() 39 | parts := strings.Split(funcName, "/") 40 | if len(parts) > 0 { 41 | funcName = parts[len(parts)-1] 42 | } 43 | } 44 | 45 | parts := strings.Split(file, "/") 46 | if len(parts) > 2 { 47 | file = strings.Join(parts[len(parts)-2:], "/") 48 | } 49 | 50 | return &WrappedError{ 51 | Err: err, 52 | Message: message, 53 | File: file, 54 | Line: line, 55 | Function: funcName, 56 | } 57 | } 58 | 59 | func Wrapf(err error, format string, args ...any) error { 60 | return Wrap(err, fmt.Sprintf(format, args...)) 61 | } 62 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Go modules 4 | - package-ecosystem: gomod 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | day: "monday" 9 | commit-message: 10 | prefix: "deps(go)" 11 | labels: 12 | - "dependencies" 13 | - "go" 14 | open-pull-requests-limit: 5 15 | groups: 16 | minor-and-patch: 17 | update-types: 18 | - "minor" 19 | - "patch" 20 | 21 | # Docker images in /docker directory 22 | - package-ecosystem: docker 23 | directory: "/docker" 24 | schedule: 25 | interval: weekly 26 | day: "monday" 27 | commit-message: 28 | prefix: "deps(docker)" 29 | labels: 30 | - "dependencies" 31 | - "docker" 32 | open-pull-requests-limit: 3 33 | groups: 34 | minor-and-patch: 35 | update-types: 36 | - "minor" 37 | - "patch" 38 | 39 | # GitHub Actions 40 | - package-ecosystem: github-actions 41 | directory: "/" 42 | schedule: 43 | interval: weekly 44 | day: "monday" 45 | commit-message: 46 | prefix: "deps(actions)" 47 | labels: 48 | - "dependencies" 49 | - "github-actions" 50 | - "security-review" 51 | open-pull-requests-limit: 2 52 | groups: 53 | patch-only: 54 | update-types: 55 | - "patch" 56 | -------------------------------------------------------------------------------- /alita/utils/async/async_processor.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | log "github.com/sirupsen/logrus" 8 | 9 | "github.com/divkix/Alita_Robot/alita/config" 10 | ) 11 | 12 | // AsyncProcessor handles asynchronous processing of non-critical operations 13 | // This is a minimal stub to satisfy main.go requirements 14 | type AsyncProcessor struct { 15 | enabled bool 16 | ctx context.Context 17 | cancel context.CancelFunc 18 | wg sync.WaitGroup 19 | } 20 | 21 | // GlobalAsyncProcessor is the singleton instance 22 | var GlobalAsyncProcessor *AsyncProcessor 23 | 24 | // InitializeAsyncProcessor creates and starts the global async processor 25 | // This is a minimal implementation to satisfy main.go requirements 26 | func InitializeAsyncProcessor() { 27 | ctx, cancel := context.WithCancel(context.Background()) 28 | GlobalAsyncProcessor = &AsyncProcessor{ 29 | enabled: config.EnableAsyncProcessing, 30 | ctx: ctx, 31 | cancel: cancel, 32 | } 33 | 34 | if GlobalAsyncProcessor.enabled { 35 | log.Info("[AsyncProcessor] Initialized (minimal mode)") 36 | } 37 | } 38 | 39 | // StopAsyncProcessor stops the global async processor 40 | // This is a minimal implementation to satisfy main.go requirements 41 | func StopAsyncProcessor() { 42 | if GlobalAsyncProcessor != nil { 43 | if GlobalAsyncProcessor.cancel != nil { 44 | GlobalAsyncProcessor.cancel() 45 | } 46 | GlobalAsyncProcessor.wg.Wait() 47 | log.Info("[AsyncProcessor] Stopped") 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /alita/db/admin_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // GetAdminSettings Get admin settings for a chat 11 | func GetAdminSettings(chatID int64) *AdminSettings { 12 | return checkAdminSetting(chatID) 13 | } 14 | 15 | // checkAdminSetting retrieves or creates default admin settings for a chat. 16 | // It returns default settings if the record is not found or an error occurs. 17 | func checkAdminSetting(chatID int64) (adminSrc *AdminSettings) { 18 | adminSrc = &AdminSettings{} 19 | 20 | err := GetRecord(adminSrc, AdminSettings{ChatId: chatID}) 21 | if errors.Is(err, gorm.ErrRecordNotFound) { 22 | // Create default settings 23 | adminSrc = &AdminSettings{ChatId: chatID, AnonAdmin: false} 24 | err := CreateRecord(adminSrc) 25 | if err != nil { 26 | log.Errorf("[Database][checkAdminSetting]: %v ", err) 27 | } 28 | } else if err != nil { 29 | // Return default on error 30 | adminSrc = &AdminSettings{ChatId: chatID, AnonAdmin: false} 31 | log.Errorf("[Database][checkAdminSetting]: %v ", err) 32 | } 33 | return adminSrc 34 | } 35 | 36 | // SetAnonAdminMode Set anon admin mode for a chat 37 | func SetAnonAdminMode(chatID int64, val bool) { 38 | adminSrc := checkAdminSetting(chatID) 39 | adminSrc.AnonAdmin = val 40 | 41 | err := UpdateRecordWithZeroValues(&AdminSettings{}, AdminSettings{ChatId: chatID}, AdminSettings{AnonAdmin: val}) 42 | if err != nil { 43 | log.Errorf("[Database] SetAnonAdminMode: %v - %d", err, chatID) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a bug report affecting the bot 3 | title: "[BUG]" 4 | labels: ["bug :bug:"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: "## Welcome!" 10 | - type: markdown 11 | attributes: 12 | value: "Thanks for taking the time to fill out this bug!." 13 | - type: checkboxes 14 | id: cbox 15 | attributes: 16 | label: "I am sure the error is coming from Bot's code and not elsewhere." 17 | description: You may select more than one. 18 | options: 19 | - label: "I am sure the error is coming from Bot's code and not elsewhere." 20 | - label: "I have searched in the issue tracker for similar bug reports, including closed ones." 21 | - label: "I ran `go run main.go` in the project root and reproduced the issue using the latest development version." 22 | - label: "I am not here to spam and show that I'm stupid." 23 | - type: textarea 24 | id: repro 25 | attributes: 26 | label: Reproduction steps 27 | description: "How do you trigger this bug? Please walk us through it step by step." 28 | value: | 29 | 1. 30 | 2. 31 | 3. 32 | ... 33 | render: bash 34 | validations: 35 | required: true 36 | 37 | - type: textarea 38 | id: traceback 39 | attributes: 40 | label: Error Traceback 41 | description: "Please enter the traceback bellow (if applicable):" 42 | placeholder: "Enter error traceback here" 43 | validations: 44 | required: false 45 | -------------------------------------------------------------------------------- /alita/i18n/errors.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // I18nError represents all i18n related errors 8 | type I18nError struct { 9 | Op string // Operation that failed 10 | Lang string // Language code involved 11 | Key string // Translation key involved 12 | Message string // Error message 13 | Err error // Underlying error 14 | } 15 | 16 | // Error returns a formatted string representation of the I18nError. 17 | func (e *I18nError) Error() string { 18 | if e.Err != nil { 19 | return fmt.Sprintf("i18n %s failed for lang=%s key=%s: %s: %v", e.Op, e.Lang, e.Key, e.Message, e.Err) 20 | } 21 | return fmt.Sprintf("i18n %s failed for lang=%s key=%s: %s", e.Op, e.Lang, e.Key, e.Message) 22 | } 23 | 24 | // Unwrap returns the underlying error wrapped by this I18nError. 25 | func (e *I18nError) Unwrap() error { 26 | return e.Err 27 | } 28 | 29 | // NewI18nError creates a new i18n error with the specified operation, language, key, message and underlying error. 30 | func NewI18nError(op, lang, key, message string, err error) *I18nError { 31 | return &I18nError{ 32 | Op: op, 33 | Lang: lang, 34 | Key: key, 35 | Message: message, 36 | Err: err, 37 | } 38 | } 39 | 40 | // Predefined error types 41 | var ( 42 | ErrLocaleNotFound = fmt.Errorf("locale not found") 43 | ErrKeyNotFound = fmt.Errorf("translation key not found") 44 | ErrInvalidYAML = fmt.Errorf("invalid YAML format") 45 | ErrManagerNotInit = fmt.Errorf("locale manager not initialized") 46 | ErrRecursiveFallback = fmt.Errorf("recursive fallback detected") 47 | ErrInvalidParams = fmt.Errorf("invalid translation parameters") 48 | ) 49 | -------------------------------------------------------------------------------- /debug.docker-compose.yml: -------------------------------------------------------------------------------- 1 | # This is to run the alita-robot in debug mode with alpine image 2 | # This starts up fast and does not do any optimizations 3 | services: 4 | alita: 5 | build: 6 | context: . 7 | dockerfile: docker/alpine.debug 8 | container_name: alita-robot 9 | restart: always 10 | env_file: 11 | - .env 12 | environment: 13 | DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-password}@postgres:5432/${POSTGRES_DB:-alita_robot}?sslmode=disable 14 | REDIS_ADDRESS: redis:6379 15 | depends_on: 16 | postgres: 17 | condition: service_healthy 18 | redis: 19 | condition: service_started 20 | 21 | postgres: 22 | image: postgres:15-alpine 23 | container_name: alita-postgres-debug 24 | restart: always 25 | env_file: 26 | - .env 27 | environment: 28 | POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" 29 | volumes: 30 | - postgres_debug_data:/var/lib/postgresql/data 31 | ports: 32 | - "5432:5432" 33 | healthcheck: 34 | test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] 35 | interval: 5s 36 | timeout: 3s 37 | retries: 5 38 | 39 | redis: 40 | image: redis:latest 41 | restart: always 42 | command: redis-server --requirepass ${REDIS_PASSWORD:-redisPassword} 43 | ports: 44 | - "6379:6379" 45 | 46 | telegram-bot-api: 47 | image: aiogram/telegram-bot-api:latest 48 | restart: unless-stopped 49 | environment: 50 | TELEGRAM_API_ID: ${TELEGRAM_API_ID} 51 | TELEGRAM_API_HASH: ${TELEGRAM_API_HASH} 52 | volumes: 53 | - telegram-bot-api-data:/var/lib/telegram-bot-api 54 | # no ports published; internal use 55 | 56 | volumes: 57 | postgres_debug_data: 58 | telegram-bot-api-data: 59 | -------------------------------------------------------------------------------- /docker/alpine: -------------------------------------------------------------------------------- 1 | # Multi-stage build for production Docker images 2 | # Uses cross-compilation to avoid QEMU emulation issues with Alpine on ARM64 3 | 4 | # Stage 1: Build using native platform, cross-compile for target 5 | FROM --platform=$BUILDPLATFORM golang:alpine AS builder 6 | 7 | # Build arguments for cross-compilation 8 | ARG TARGETOS 9 | ARG TARGETARCH 10 | 11 | WORKDIR /app 12 | 13 | # Copy go mod files first for better layer caching 14 | COPY go.mod go.sum ./ 15 | 16 | # Download dependencies - runs on native platform, cached unless go.mod/go.sum change 17 | RUN --mount=type=cache,target=/go/pkg/mod \ 18 | --mount=type=cache,target=/root/.cache/go-build \ 19 | go mod download && go mod verify 20 | 21 | # Copy source code 22 | COPY . . 23 | 24 | # Cross-compile the binary for target platform (no QEMU needed - Go handles this natively) 25 | RUN --mount=type=cache,target=/go/pkg/mod \ 26 | --mount=type=cache,target=/root/.cache/go-build \ 27 | CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ 28 | -trimpath \ 29 | -ldflags="-s -w" \ 30 | -o alita_robot . 31 | 32 | # Stage 2: Minimal runtime image 33 | FROM gcr.io/distroless/static-debian12 34 | USER 65532:65532 35 | WORKDIR /app 36 | COPY --from=builder /app/alita_robot /app/alita_robot 37 | COPY --from=builder /app/supabase /app/supabase 38 | ENTRYPOINT ["/app/alita_robot"] 39 | 40 | # Metadata 41 | LABEL org.opencontainers.image.authors="Divanshu Chauhan " 42 | LABEL org.opencontainers.image.url="https://divkix.me" 43 | LABEL org.opencontainers.image.source="https://github.com/divkix/Alita_Robot" 44 | LABEL org.opencontainers.image.title="Alita Go Robot" 45 | LABEL org.opencontainers.image.description="Official Alita Go Robot Docker Image" 46 | LABEL org.opencontainers.image.vendor="Divkix" 47 | -------------------------------------------------------------------------------- /alita/config/types.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // typeConvertor is a struct that will convert a string to a specific type 9 | type typeConvertor struct { 10 | str string 11 | } 12 | 13 | // StringArray converts a comma-separated string into a slice of trimmed strings. 14 | // It splits the input string by commas and removes leading/trailing whitespace 15 | // from each element. 16 | func (t typeConvertor) StringArray() []string { 17 | allUpdates := strings.Split(t.str, ",") 18 | for i, j := range allUpdates { 19 | allUpdates[i] = strings.TrimSpace(j) // this will trim the whitespace 20 | } 21 | return allUpdates 22 | } 23 | 24 | // Int converts the string value to an integer. If the conversion fails, 25 | // it returns 0. This method ignores conversion errors for simplicity. 26 | func (t typeConvertor) Int() int { 27 | val, _ := strconv.Atoi(t.str) 28 | return val 29 | } 30 | 31 | // Int64 converts the string value to a 64-bit integer. If the conversion fails, 32 | // it returns 0. This method ignores conversion errors for simplicity. 33 | func (t typeConvertor) Int64() int64 { 34 | val, _ := strconv.ParseInt(t.str, 10, 64) 35 | return val 36 | } 37 | 38 | // Bool converts the string value to a boolean. It returns true if the string 39 | // equals "yes", "true", or "1" (case-insensitive), otherwise returns false. 40 | func (t typeConvertor) Bool() bool { 41 | lower := strings.ToLower(strings.TrimSpace(t.str)) 42 | return lower == "yes" || lower == "true" || lower == "1" 43 | } 44 | 45 | // Float64 converts the string value to a 64-bit float. If the conversion fails, 46 | // it returns 0.0. This method ignores conversion errors for simplicity. 47 | func (t typeConvertor) Float64() float64 { 48 | val, _ := strconv.ParseFloat(t.str, 64) 49 | return val 50 | } 51 | -------------------------------------------------------------------------------- /docker/pr-build: -------------------------------------------------------------------------------- 1 | # Multi-stage build for PR Docker images 2 | # Uses cross-compilation to avoid QEMU emulation issues with Alpine on ARM64 3 | 4 | # Stage 1: Build using native platform, cross-compile for target 5 | FROM --platform=$BUILDPLATFORM golang:alpine AS builder 6 | 7 | # Build arguments 8 | ARG PR_NUMBER=unknown 9 | ARG COMMIT_SHA=unknown 10 | ARG TARGETOS 11 | ARG TARGETARCH 12 | 13 | WORKDIR /app 14 | 15 | # Copy go mod and sum files first for better caching 16 | COPY go.mod go.sum ./ 17 | 18 | # Download dependencies - runs on native platform, cached 19 | RUN --mount=type=cache,target=/go/pkg/mod \ 20 | --mount=type=cache,target=/root/.cache/go-build \ 21 | go mod download && go mod verify 22 | 23 | # Copy source code 24 | COPY . . 25 | 26 | # Cross-compile the binary for target platform (no QEMU needed - Go handles this natively) 27 | RUN --mount=type=cache,target=/go/pkg/mod \ 28 | --mount=type=cache,target=/root/.cache/go-build \ 29 | CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ 30 | -trimpath \ 31 | -ldflags="-s -w -X main.version=pr-${PR_NUMBER} -X main.commit=${COMMIT_SHA}" \ 32 | -o alita_robot . 33 | 34 | # Stage 2: Minimal runtime image 35 | FROM gcr.io/distroless/static-debian12:nonroot 36 | WORKDIR /app 37 | COPY --from=builder /app/alita_robot . 38 | COPY supabase /app/supabase 39 | USER nonroot 40 | ENTRYPOINT ["./alita_robot"] 41 | 42 | # Metadata 43 | LABEL org.opencontainers.image.authors="Divanshu Chauhan " 44 | LABEL org.opencontainers.image.url="https://divkix.me" 45 | LABEL org.opencontainers.image.source="https://github.com/divkix/Alita_Robot" 46 | LABEL org.opencontainers.image.title="Alita Go Robot - PR Build" 47 | LABEL org.opencontainers.image.description="Pull Request build of Alita Go Robot" 48 | LABEL org.opencontainers.image.vendor="Divkix" 49 | -------------------------------------------------------------------------------- /alita/db/locks_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // GetChatLocks retrieves all lock settings for a specific chat ID. 11 | // Uses optimized queries with caching for better performance. 12 | // Returns an empty map if no locks are found or an error occurs. 13 | func GetChatLocks(chatID int64) map[string]bool { 14 | // Use optimized query with caching 15 | locks, err := GetOptimizedQueries().lockQueries.GetChatLocksOptimized(chatID) 16 | if err != nil { 17 | log.Errorf("[Database] GetChatLocks: %v - %d", err, chatID) 18 | return make(map[string]bool) 19 | } 20 | 21 | return locks 22 | } 23 | 24 | // UpdateLock modifies the value of a specific lock setting and updates it in the database. 25 | // Creates a new lock record if one doesn't exist for the given chat and permission type. 26 | func UpdateLock(chatID int64, perm string, val bool) { 27 | lockSetting := &LockSettings{ 28 | ChatId: chatID, 29 | LockType: perm, 30 | Locked: val, 31 | } 32 | 33 | // Try to update existing record first 34 | err := UpdateRecordWithZeroValues(&LockSettings{}, LockSettings{ChatId: chatID, LockType: perm}, LockSettings{Locked: val}) 35 | if errors.Is(err, gorm.ErrRecordNotFound) { 36 | // Create new record if not exists 37 | err = CreateRecord(lockSetting) 38 | } 39 | 40 | if err != nil { 41 | log.Errorf("[Database] UpdateLock: %v", err) 42 | } 43 | } 44 | 45 | // IsPermLocked checks whether a specific permission type is locked in the given chat. 46 | // Uses optimized cached queries for better performance. 47 | // Returns false if the permission is not locked or an error occurs. 48 | func IsPermLocked(chatID int64, perm string) bool { 49 | // Use optimized cached query 50 | locked, err := GetOptimizedQueries().GetLockStatusCached(chatID, perm) 51 | if err != nil { 52 | log.Errorf("[Database] IsPermLocked: %v - %d", err, chatID) 53 | return false 54 | } 55 | 56 | return locked 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-native-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Native Auto-merge 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, ready_for_review] 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-auto-merge: 13 | name: Auto-merge Dependabot PR 14 | runs-on: ubuntu-latest 15 | if: github.actor == 'dependabot[bot]' 16 | steps: 17 | - name: Get dependency metadata 18 | id: metadata 19 | uses: dependabot/fetch-metadata@v2 20 | with: 21 | github-token: "${{ secrets.GITHUB_TOKEN }}" 22 | 23 | - name: Auto-approve and enable auto-merge for minor/patch updates 24 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | PR_NUMBER: ${{ github.event.number }} 28 | run: | 29 | echo "Auto-approving ${{ steps.metadata.outputs.update-type }} update" 30 | gh pr review $PR_NUMBER --approve --repo ${{ github.repository }} 31 | gh pr merge $PR_NUMBER --auto --squash --repo ${{ github.repository }} 32 | 33 | - name: Comment on major updates 34 | if: steps.metadata.outputs.update-type == 'version-update:semver-major' 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | PR_NUMBER: ${{ github.event.number }} 38 | run: | 39 | comment_body="🔄 **Major version update detected!** 40 | 41 | **Update type:** \`${{ steps.metadata.outputs.update-type }}\` 42 | **Dependency:** \`${{ steps.metadata.outputs.dependency-names }}\` 43 | 44 | This PR requires manual review for potential breaking changes. 45 | 46 | @divkix please review this update carefully." 47 | 48 | gh pr comment $PR_NUMBER --body "$comment_body" --repo ${{ github.repository }} -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Production Docker Compose for Alita Robot 2 | # Uses pre-built images from GHCR, optimized for Dokploy deployment 3 | # 4 | # Environment variables to set in Dokploy UI: 5 | # BOT_TOKEN, OWNER_ID, MESSAGE_DUMP, WEBHOOK_DOMAIN, WEBHOOK_SECRET 6 | 7 | services: 8 | alita: 9 | image: ghcr.io/divkix/alita_robot:latest 10 | container_name: alita-robot 11 | restart: always 12 | environment: 13 | DATABASE_URL: postgresql://alita:alita@postgres:5432/alita 14 | REDIS_ADDRESS: redis:6379 15 | REDIS_PASSWORD: redis 16 | AUTO_MIGRATE: "false" 17 | USE_WEBHOOKS: "true" 18 | WEBHOOK_PORT: "8081" 19 | DROP_PENDING_UPDATES: "true" 20 | BOT_TOKEN: ${BOT_TOKEN} 21 | OWNER_ID: ${OWNER_ID} 22 | MESSAGE_DUMP: ${MESSAGE_DUMP} 23 | WEBHOOK_DOMAIN: ${WEBHOOK_DOMAIN} 24 | WEBHOOK_SECRET: ${WEBHOOK_SECRET} 25 | ports: 26 | - "8081:8081" 27 | depends_on: 28 | postgres: 29 | condition: service_healthy 30 | redis: 31 | condition: service_started 32 | healthcheck: 33 | test: ["CMD", "/alita_robot", "--health"] 34 | interval: 30s 35 | timeout: 10s 36 | retries: 3 37 | deploy: 38 | resources: 39 | limits: 40 | memory: 1G 41 | cpus: "1.0" 42 | reservations: 43 | memory: 256M 44 | cpus: "0.25" 45 | 46 | postgres: 47 | image: postgres:18-alpine 48 | container_name: alita-postgres 49 | restart: always 50 | environment: 51 | POSTGRES_USER: alita 52 | POSTGRES_PASSWORD: alita 53 | POSTGRES_DB: alita 54 | POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" 55 | volumes: 56 | - postgres_data:/var/lib/postgresql/data 57 | healthcheck: 58 | test: ["CMD-SHELL", "pg_isready -U alita -d alita"] 59 | interval: 10s 60 | timeout: 5s 61 | retries: 5 62 | deploy: 63 | resources: 64 | limits: 65 | memory: 512M 66 | cpus: "0.5" 67 | 68 | redis: 69 | image: redis:latest 70 | container_name: alita-redis 71 | restart: always 72 | command: redis-server --requirepass redis 73 | deploy: 74 | resources: 75 | limits: 76 | memory: 256M 77 | cpus: "0.25" 78 | 79 | volumes: 80 | postgres_data: 81 | -------------------------------------------------------------------------------- /supabase/migrations/20250806093809_add_missing_channel_index.sql: -------------------------------------------------------------------------------- 1 | -- ===================================================== 2 | -- Supabase Migration: Add Missing Channel Foreign Key Index 3 | -- ===================================================== 4 | -- Migration Name: add_missing_channel_index 5 | -- Description: Adds missing index for channels.channel_id foreign key 6 | -- Date: 2025-08-06 7 | -- ===================================================== 8 | 9 | BEGIN; 10 | 11 | -- ===================================================== 12 | -- Add Missing Foreign Key Index 13 | -- ===================================================== 14 | -- This index is critical for the fk_channels_channel foreign key 15 | -- Without it, referential integrity checks cause full table scans 16 | -- when deleting or updating chats that may be referenced as channels 17 | 18 | CREATE INDEX IF NOT EXISTS idx_channels_channel_id ON public.channels(channel_id); 19 | 20 | -- Add comment for documentation 21 | COMMENT ON INDEX idx_channels_channel_id IS 'Index for foreign key fk_channels_channel to improve referential integrity performance on CASCADE operations'; 22 | 23 | -- Update statistics for query planner 24 | ANALYZE channels; 25 | 26 | COMMIT; 27 | 28 | -- ===================================================== 29 | -- PERFORMANCE IMPACT 30 | -- ===================================================== 31 | -- This index will: 32 | -- 1. Significantly improve DELETE/UPDATE performance on chats table 33 | -- 2. Speed up queries that JOIN channels on channel_id 34 | -- 3. Eliminate full table scans during foreign key constraint checks 35 | -- 36 | -- Expected improvements: 37 | -- - CASCADE DELETE operations: 10-100x faster 38 | -- - Foreign key validation: Near instant vs full table scan 39 | 40 | -- ===================================================== 41 | -- ROLLBACK INSTRUCTIONS 42 | -- ===================================================== 43 | -- If you need to rollback this migration: 44 | /* 45 | DROP INDEX IF EXISTS idx_channels_channel_id; 46 | */ 47 | 48 | -- ===================================================== 49 | -- VERIFICATION QUERY 50 | -- ===================================================== 51 | -- After running this migration, verify with: 52 | /* 53 | SELECT 54 | indexname, 55 | indexdef, 56 | tablename 57 | FROM pg_indexes 58 | WHERE tablename = 'channels' 59 | AND indexname = 'idx_channels_channel_id'; 60 | */ -------------------------------------------------------------------------------- /alita/utils/error_handling/error_handling.go: -------------------------------------------------------------------------------- 1 | package error_handling 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | 7 | "github.com/getsentry/sentry-go" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // HandleErr handles errors by logging them. 12 | func HandleErr(err error) { 13 | if err != nil { 14 | log.Error(err) 15 | } 16 | } 17 | 18 | // RecoverFromPanic recovers from a panic and logs it as an error. 19 | // This should be used with defer in goroutines to prevent crashes. 20 | // If Sentry is enabled, it will also capture the panic with full context. 21 | func RecoverFromPanic(funcName, modName string) { 22 | if r := recover(); r != nil { 23 | err := fmt.Errorf("panic in %s.%s: %v", modName, funcName, r) 24 | stackTrace := string(debug.Stack()) 25 | 26 | log.Errorf("[%s][%s] Recovered from panic: %v\nStack trace:\n%s", 27 | modName, funcName, r, stackTrace) 28 | 29 | // Send to Sentry if available 30 | if hub := sentry.CurrentHub(); hub.Client() != nil { 31 | hub.WithScope(func(scope *sentry.Scope) { 32 | scope.SetTag("module", modName) 33 | scope.SetTag("function", funcName) 34 | scope.SetLevel(sentry.LevelFatal) 35 | scope.SetContext("panic", map[string]interface{}{ 36 | "recovered_value": fmt.Sprintf("%v", r), 37 | "stack_trace": stackTrace, 38 | }) 39 | hub.CaptureException(err) 40 | }) 41 | } 42 | } 43 | } 44 | 45 | // CaptureError captures an error to Sentry with additional context. 46 | // This is useful for capturing errors with custom tags and context 47 | // without triggering a panic recovery flow. 48 | func CaptureError(err error, tags map[string]string) { 49 | if err == nil { 50 | return 51 | } 52 | 53 | HandleErr(err) // Still log locally 54 | 55 | if hub := sentry.CurrentHub(); hub.Client() != nil { 56 | hub.WithScope(func(scope *sentry.Scope) { 57 | for key, value := range tags { 58 | scope.SetTag(key, value) 59 | } 60 | hub.CaptureException(err) 61 | }) 62 | } 63 | } 64 | 65 | // CaptureMessage sends a message to Sentry with optional tags. 66 | // This is useful for tracking events that aren't errors but are worth monitoring. 67 | func CaptureMessage(message string, tags map[string]string) { 68 | if hub := sentry.CurrentHub(); hub.Client() != nil { 69 | hub.WithScope(func(scope *sentry.Scope) { 70 | for key, value := range tags { 71 | scope.SetTag(key, value) 72 | } 73 | hub.CaptureMessage(message) 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /alita/i18n/types.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "embed" 5 | "sync" 6 | "time" 7 | 8 | "github.com/eko/gocache/lib/v4/cache" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | // TranslationParams represents parameters for translation interpolation 13 | type TranslationParams map[string]any 14 | 15 | // PluralRule defines pluralization rules for different languages 16 | type PluralRule struct { 17 | Zero string // "zero" form (e.g., 0 items) 18 | One string // "one" form (e.g., 1 item) 19 | Two string // "two" form (e.g., 2 items) 20 | Few string // "few" form (e.g., 2-4 items in some languages) 21 | Many string // "many" form (e.g., 5+ items in some languages) 22 | Other string // "other" form (default fallback) 23 | } 24 | 25 | // LocaleManager manages all locales with thread-safe operations 26 | type LocaleManager struct { 27 | mu sync.RWMutex 28 | viperCache map[string]*viper.Viper // Pre-compiled viper instances 29 | localeData map[string][]byte // Raw YAML data 30 | cacheClient *cache.Cache[any] // External cache for translations 31 | defaultLang string 32 | localeFS *embed.FS 33 | localePath string 34 | } 35 | 36 | // Translator provides translation methods for a specific language 37 | type Translator struct { 38 | langCode string 39 | manager *LocaleManager 40 | viper *viper.Viper // Pre-compiled viper instance 41 | cachePrefix string // Cache key prefix for this language 42 | } 43 | 44 | // CacheConfig defines cache configuration for translations 45 | type CacheConfig struct { 46 | TTL time.Duration 47 | EnableCache bool 48 | CacheKeyPrefix string 49 | MaxCacheSize int64 50 | InvalidateOnError bool 51 | } 52 | 53 | // LoaderConfig defines configuration for locale loading 54 | type LoaderConfig struct { 55 | DefaultLanguage string 56 | ValidateYAML bool 57 | StrictMode bool // Fail if any locale file has errors 58 | } 59 | 60 | // ManagerConfig combines all configuration options 61 | type ManagerConfig struct { 62 | Cache CacheConfig 63 | Loader LoaderConfig 64 | } 65 | 66 | // DefaultManagerConfig returns sensible defaults for ManagerConfig. 67 | func DefaultManagerConfig() ManagerConfig { 68 | return ManagerConfig{ 69 | Cache: CacheConfig{ 70 | TTL: 30 * time.Minute, 71 | EnableCache: true, 72 | CacheKeyPrefix: "i18n:", 73 | MaxCacheSize: 1000, 74 | InvalidateOnError: false, 75 | }, 76 | Loader: LoaderConfig{ 77 | DefaultLanguage: "en", 78 | ValidateYAML: true, 79 | StrictMode: false, 80 | }, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /alita/utils/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/PaulSonOfLars/gotgbot/v2" 8 | "github.com/divkix/Alita_Robot/alita/config" 9 | "github.com/eko/gocache/lib/v4/cache" 10 | "github.com/eko/gocache/lib/v4/marshaler" 11 | redis_store "github.com/eko/gocache/store/redis/v4" 12 | "github.com/redis/go-redis/v9" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | var ( 17 | Context = context.Background() 18 | Marshal *marshaler.Marshaler 19 | Manager *cache.Cache[any] 20 | redisClient *redis.Client 21 | ) 22 | 23 | type AdminCache struct { 24 | ChatId int64 25 | UserInfo []gotgbot.MergedChatMember 26 | Cached bool 27 | } 28 | 29 | // InitCache initializes the Redis-only cache system. 30 | // It establishes connection to Redis and returns an error if initialization fails. 31 | func InitCache() error { 32 | // Initialize Redis client 33 | redisClient = redis.NewClient(&redis.Options{ 34 | Addr: config.RedisAddress, 35 | Password: config.RedisPassword, // no password set 36 | DB: config.RedisDB, // use default DB 37 | }) 38 | 39 | // Test Redis connection 40 | if err := redisClient.Ping(Context).Err(); err != nil { 41 | return fmt.Errorf("failed to connect to Redis: %w", err) 42 | } 43 | 44 | // Clear all caches on startup if configured to do so 45 | if config.ClearCacheOnStartup { 46 | if err := ClearAllCaches(); err != nil { 47 | log.Warnf("[Cache] Failed to clear caches on startup: %v", err) 48 | } 49 | } 50 | 51 | // Initialize cache manager with Redis only 52 | redisStore := redis_store.NewRedis(redisClient) 53 | cacheManager := cache.New[any](redisStore) 54 | 55 | // Initializes marshaler 56 | Marshal = marshaler.New(cacheManager) 57 | Manager = cacheManager 58 | 59 | return nil 60 | } 61 | 62 | // ClearAllCaches clears all cache entries from Redis using FLUSHDB. 63 | // This function is called on bot startup to ensure fresh data and eliminate cache coherence issues. 64 | // Since Redis is dedicated to the bot, FLUSHDB safely clears all keys in the current database. 65 | func ClearAllCaches() error { 66 | if redisClient == nil { 67 | return fmt.Errorf("redis client not initialized") 68 | } 69 | 70 | log.Info("[Cache] Clearing all caches using FLUSHDB...") 71 | 72 | // Use FLUSHDB to clear all keys in current database 73 | // This is safe since Redis is dedicated to the bot 74 | if err := redisClient.FlushDB(Context).Err(); err != nil { 75 | return fmt.Errorf("failed to flush database: %w", err) 76 | } 77 | 78 | log.Info("[Cache] Successfully cleared all cache entries") 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /alita/db/pin_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // GetPinData retrieves or creates default pin settings for the specified chat ID. 11 | // Returns default settings with message ID 0 if no settings exist or an error occurs. 12 | func GetPinData(chatID int64) (pinrc *PinSettings) { 13 | pinrc = &PinSettings{} 14 | err := GetRecord(pinrc, PinSettings{ChatId: chatID}) 15 | if errors.Is(err, gorm.ErrRecordNotFound) { 16 | // Create default settings 17 | pinrc = &PinSettings{ChatId: chatID, MsgId: 0} 18 | err := CreateRecord(pinrc) 19 | if err != nil { 20 | log.Errorf("[Database] GetPinData: %v - %d", err, chatID) 21 | } 22 | } else if err != nil { 23 | // Return default on error 24 | pinrc = &PinSettings{ChatId: chatID, MsgId: 0} 25 | log.Errorf("[Database] GetPinData: %v - %d", err, chatID) 26 | } 27 | log.Infof("[Database] GetPinData: %d", chatID) 28 | return 29 | } 30 | 31 | // SetCleanLinked updates the clean linked messages preference for the specified chat. 32 | // When enabled, linked channel messages are automatically cleaned from the chat. 33 | func SetCleanLinked(chatID int64, pref bool) { 34 | err := UpdateRecordWithZeroValues(&PinSettings{}, PinSettings{ChatId: chatID}, PinSettings{CleanLinked: pref}) 35 | if err != nil { 36 | log.Errorf("[Database] SetCleanLinked: %v", err) 37 | } 38 | } 39 | 40 | // SetAntiChannelPin updates the anti-channel pin preference for the specified chat. 41 | // When enabled, prevents channel messages from being automatically pinned. 42 | func SetAntiChannelPin(chatID int64, pref bool) { 43 | err := UpdateRecordWithZeroValues(&PinSettings{}, PinSettings{ChatId: chatID}, PinSettings{AntiChannelPin: pref}) 44 | if err != nil { 45 | log.Errorf("[Database] SetAntiChannelPin: %v", err) 46 | } 47 | } 48 | 49 | // LoadPinStats returns statistics about pin features across all chats. 50 | // Returns the count of chats with AntiChannelPin enabled and CleanLinked enabled. 51 | func LoadPinStats() (acCount, clCount int64) { 52 | // Count chats with AntiChannelPin enabled 53 | err := DB.Model(&PinSettings{}).Where("anti_channel_pin = ?", true).Count(&acCount).Error 54 | if err != nil { 55 | log.Errorf("[Database] LoadPinStats: Error counting AntiChannelPin: %v", err) 56 | } 57 | 58 | // Count chats with CleanLinked enabled 59 | err = DB.Model(&PinSettings{}).Where("clean_linked = ?", true).Count(&clCount).Error 60 | if err != nil { 61 | log.Errorf("[Database] LoadPinStats: Error counting CleanLinked: %v", err) 62 | } 63 | 64 | log.Infof("[Database] LoadPinStats: AntiChannelPin=%d, CleanLinked=%d", acCount, clCount) 65 | return acCount, clCount 66 | } 67 | -------------------------------------------------------------------------------- /alita/utils/shutdown/graceful.go: -------------------------------------------------------------------------------- 1 | package shutdown 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "sync" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/divkix/Alita_Robot/alita/utils/error_handling" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | // Manager handles graceful shutdown of the application 16 | type Manager struct { 17 | handlers []func() error 18 | mu sync.RWMutex 19 | once sync.Once 20 | } 21 | 22 | // NewManager creates a new shutdown manager 23 | func NewManager() *Manager { 24 | return &Manager{ 25 | handlers: make([]func() error, 0), 26 | } 27 | } 28 | 29 | // RegisterHandler registers a shutdown handler 30 | func (m *Manager) RegisterHandler(handler func() error) { 31 | m.mu.Lock() 32 | defer m.mu.Unlock() 33 | m.handlers = append(m.handlers, handler) 34 | } 35 | 36 | // WaitForShutdown waits for shutdown signals and executes handlers 37 | func (m *Manager) WaitForShutdown() { 38 | sigChan := make(chan os.Signal, 1) 39 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGINT) 40 | 41 | // Wait for signal 42 | sig := <-sigChan 43 | log.Infof("[Shutdown] Received signal: %v", sig) 44 | 45 | m.shutdown() 46 | } 47 | 48 | // executeHandler safely executes a single shutdown handler with panic recovery. 49 | // Returns the error from the handler, or nil if the handler panicked (panic is logged separately). 50 | func (m *Manager) executeHandler(handler func() error, index int) (err error) { 51 | defer func() { 52 | if r := recover(); r != nil { 53 | log.Errorf("[Shutdown] Handler %d panicked: %v", index, r) 54 | } 55 | }() 56 | return handler() 57 | } 58 | 59 | // shutdown performs graceful shutdown 60 | func (m *Manager) shutdown() { 61 | m.once.Do(func() { 62 | defer error_handling.RecoverFromPanic("shutdown", "shutdown") 63 | 64 | log.Info("[Shutdown] Starting graceful shutdown...") 65 | 66 | // Create context with timeout for shutdown 67 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 68 | defer cancel() 69 | 70 | // Execute shutdown handlers in reverse order 71 | m.mu.RLock() 72 | handlers := make([]func() error, len(m.handlers)) 73 | copy(handlers, m.handlers) 74 | m.mu.RUnlock() 75 | 76 | // Execute handlers in reverse order (LIFO) 77 | for i := len(handlers) - 1; i >= 0; i-- { 78 | select { 79 | case <-ctx.Done(): 80 | log.Warn("[Shutdown] Timeout reached, forcing exit") 81 | os.Exit(1) 82 | default: 83 | if err := m.executeHandler(handlers[i], i); err != nil { 84 | log.Errorf("[Shutdown] Handler error: %v", err) 85 | } 86 | } 87 | } 88 | 89 | log.Info("[Shutdown] Graceful shutdown completed") 90 | os.Exit(0) 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Project Structure & Module Organization 4 | Core source lives in `alita/`, split into `modules` for bot features, `utils` for shared helpers, `config` for runtime settings, `health`/`metrics` for probes, and `db` for repositories. `main.go` wires the bot entrypoint. Localized strings sit in `locales/` (YAML) and should be updated with every user-facing change. Database schema changes go in `supabase/migrations`, while automation scripts reside in `scripts/`. Build artifacts end up in `dist/`, and supplemental docs live under `docs/`. 5 | 6 | ## Build, Test, and Development Commands 7 | - `make run`: start the bot locally using Go modules from `main.go`. 8 | - `make lint`: run `golangci-lint`; required before every PR. 9 | - `make build`: produce a snapshot release via GoReleaser, matching the CI pipeline. 10 | - `go test ./...`: execute unit tests across all packages. 11 | - `make check-translations`: ensure new keys exist across locale bundles. 12 | For container workflows, use `docker-compose -f local.docker-compose.yml up` to launch Postgres, Redis, and the bot together. 13 | 14 | ## Coding Style & Naming Conventions 15 | Format Go code with `gofmt` (tabs, exported identifiers in PascalCase, package-scope helpers in lowerCamelCase). Keep module files focused: each feature lives in a dedicated file under `alita/modules`. Reuse constants from `alita/utils/constants` and prefer structured errors from `alita/utils/errors`. Run `golangci-lint` locally; extend its configuration rather than silencing warnings inline. 16 | 17 | ## Testing Guidelines 18 | Tests belong next to their packages as `*_test.go` files using Go’s standard testing toolkit. Target new logic with table-driven cases and add mocks under `alita/utils` only when shared. Aim for meaningful coverage on critical paths (moderation flows, cache layers). Combine `go test ./...` with `make lint` in CI; keep tests hermetic so they run without external services unless flagged with build tags. 19 | 20 | ## Commit & Pull Request Guidelines 21 | Follow Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`) as already used in history. Commits should be small, focused, and include locale updates or migrations when applicable. PRs must describe intent, list validation commands (`make lint`, `go test ./...`), and link issues or spec discussions. Attach screenshots or Telegram command transcripts when altering user-visible behavior, and note any config or migration steps in the PR body. 22 | 23 | ## Security & Configuration Tips 24 | Never commit real tokens; base your `.env` on `sample.env`. When touching database logic, run `make psql-prepare` to sync cleaned migrations and document secrets required for Supabase. Review `docker/` templates before deploying and ensure webhook URLs or API keys are provided through environment variables. 25 | -------------------------------------------------------------------------------- /supabase/migrations/20250807123000_fix_fk_covering_indexes_and_captcha_dup_index.sql: -------------------------------------------------------------------------------- 1 | -- ===================================================== 2 | -- Supabase Migration: Fix FK Covering Indexes & Drop Duplicate Captcha Index 3 | -- ===================================================== 4 | -- Migration Name: fix_fk_covering_indexes_and_captcha_dup_index 5 | -- Description: 6 | -- - Drops duplicate unique index on captcha_settings.chat_id 7 | -- (keeps the constraint-backed index captcha_settings_chat_id_key) 8 | -- - Ensures covering indexes exist for the following foreign keys: 9 | -- * fk_channels_channel -> channels(channel_id) 10 | -- * fk_chat_users_user -> chat_users(user_id) 11 | -- * fk_connection_chat -> connection(chat_id) 12 | -- Date: 2025-08-07 13 | -- ===================================================== 14 | 15 | BEGIN; 16 | 17 | -- ===================================================== 18 | -- 1) Remove duplicate unique index on captcha_settings.chat_id 19 | -- The UNIQUE column definition created constraint/index 20 | -- captcha_settings_chat_id_key. The explicit index 21 | -- uk_captcha_settings_chat_id is redundant. 22 | -- ===================================================== 23 | 24 | DROP INDEX IF EXISTS public.uk_captcha_settings_chat_id; 25 | 26 | -- ===================================================== 27 | -- 2) Ensure covering indexes for critical foreign keys 28 | -- ===================================================== 29 | 30 | -- channels.channel_id covers fk_channels_channel 31 | CREATE INDEX IF NOT EXISTS idx_channels_channel_id 32 | ON public.channels(channel_id); 33 | COMMENT ON INDEX idx_channels_channel_id IS 'Covering index for foreign key fk_channels_channel'; 34 | 35 | -- chat_users.user_id covers fk_chat_users_user 36 | CREATE INDEX IF NOT EXISTS idx_chat_users_user_id 37 | ON public.chat_users(user_id); 38 | COMMENT ON INDEX idx_chat_users_user_id IS 'Covering index for foreign key fk_chat_users_user'; 39 | 40 | -- connection.chat_id covers fk_connection_chat 41 | CREATE INDEX IF NOT EXISTS idx_connection_chat_id 42 | ON public.connection(chat_id); 43 | COMMENT ON INDEX idx_connection_chat_id IS 'Covering index for foreign key fk_connection_chat'; 44 | 45 | -- Update statistics for planner 46 | ANALYZE channels; 47 | ANALYZE chat_users; 48 | ANALYZE connection; 49 | 50 | COMMIT; 51 | 52 | -- ===================================================== 53 | -- VERIFICATION QUERIES (run manually after migration) 54 | -- ===================================================== 55 | -- 1) Confirm duplicate captcha index is gone and constraint-backed remains 56 | -- SELECT indexname FROM pg_indexes WHERE tablename = 'captcha_settings'; 57 | -- 58 | -- 2) Confirm covering indexes exist 59 | -- SELECT tablename, indexname FROM pg_indexes 60 | -- WHERE schemaname = 'public' 61 | -- AND indexname IN ('idx_channels_channel_id','idx_chat_users_user_id','idx_connection_chat_id') 62 | -- ORDER BY tablename, indexname; 63 | 64 | 65 | -------------------------------------------------------------------------------- /alita/modules/antispam.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/PaulSonOfLars/gotgbot/v2" 8 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 9 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" 10 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message" 11 | ) 12 | 13 | var antispamModule = moduleStruct{ 14 | moduleName: "antispam", 15 | antiSpam: map[int64]*antiSpamInfo{}, 16 | } 17 | 18 | // antiSpamMutex protects concurrent access to the antiSpam map 19 | var antiSpamMutex sync.RWMutex 20 | 21 | // checkSpammed evaluates if a chat has exceeded spam detection levels. 22 | // Returns true if any configured spam threshold has been violated. 23 | func (moduleStruct) checkSpammed(chatId int64, levels []antiSpamLevel) bool { 24 | antiSpamMutex.Lock() 25 | _asInfo, ok := antispamModule.antiSpam[chatId] 26 | if !ok { 27 | // Assign a new AntiSpamInfo to the chatId because not found 28 | antispamModule.antiSpam[chatId] = &antiSpamInfo{ 29 | Levels: levels, 30 | } 31 | antiSpamMutex.Unlock() 32 | return false 33 | } 34 | antiSpamMutex.Unlock() 35 | 36 | newLevels := make([]antiSpamLevel, len(_asInfo.Levels)) 37 | var spammed bool 38 | for n, level := range _asInfo.Levels { 39 | // Expire the _asInfo if current time becomes greater than expiration time 40 | if level.CurrTime+level.Expiry <= time.Duration(time.Now().UnixNano()) { 41 | // Allocate a new 'current time' with count reset to 0 if expired 42 | level.CurrTime = time.Duration(time.Now().UnixNano()) 43 | level.Count = 0 44 | level.Spammed = false 45 | } 46 | level.Count += 1 47 | if level.Count >= level.Limit { 48 | // fmt.Println("level", n, "has been spammed with count", level.Count, "while the limit was", level.Limit) 49 | level.Spammed = true 50 | } 51 | newLevels[n] = level 52 | if !spammed && level.Spammed { 53 | spammed = true 54 | } 55 | } 56 | _asInfo.Levels = newLevels 57 | 58 | antiSpamMutex.Lock() 59 | antispamModule.antiSpam[chatId] = _asInfo 60 | antiSpamMutex.Unlock() 61 | 62 | return spammed 63 | } 64 | 65 | // spamCheck performs spam detection for a specific chat. 66 | // Checks against a default threshold of 18 messages per second. 67 | func (moduleStruct) spamCheck(chatId int64) bool { 68 | // if sql.IsUserSudo(chatId) { 69 | // return false 70 | // } 71 | curr := time.Duration(time.Now().UnixNano()) 72 | return antispamModule.checkSpammed(chatId, []antiSpamLevel{ 73 | { 74 | CurrTime: curr, 75 | Limit: 18, 76 | Expiry: time.Second, 77 | }, 78 | }) 79 | } 80 | 81 | // LoadAntispam registers the antispam message handler with the dispatcher. 82 | // Sets up spam detection monitoring for all incoming messages. 83 | func LoadAntispam(dispatcher *ext.Dispatcher) { 84 | dispatcher.AddHandlerToGroup( 85 | handlers.NewMessage( 86 | message.All, 87 | func(bot *gotgbot.Bot, ctx *ext.Context) error { 88 | if antispamModule.spamCheck(ctx.EffectiveChat.Id) { 89 | return ext.EndGroups 90 | } 91 | return ext.ContinueGroups 92 | }, 93 | ), -2, 94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /supabase/migrations/20250807103246_add_captcha_tables.sql: -------------------------------------------------------------------------------- 1 | -- Create captcha_settings table for chat-specific captcha configuration 2 | CREATE TABLE IF NOT EXISTS captcha_settings ( 3 | id SERIAL PRIMARY KEY, 4 | chat_id BIGINT NOT NULL UNIQUE, 5 | enabled BOOLEAN DEFAULT FALSE, 6 | captcha_mode VARCHAR(10) DEFAULT 'math' CHECK (captcha_mode IN ('math', 'text')), 7 | timeout INTEGER DEFAULT 2 CHECK (timeout > 0 AND timeout <= 10), 8 | failure_action VARCHAR(10) DEFAULT 'kick' CHECK (failure_action IN ('kick', 'ban', 'mute')), 9 | max_attempts INTEGER DEFAULT 3 CHECK (max_attempts > 0 AND max_attempts <= 10), 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 12 | CONSTRAINT fk_captcha_settings_chat FOREIGN KEY (chat_id) REFERENCES chats(chat_id) ON DELETE CASCADE 13 | ); 14 | 15 | -- Create unique index on chat_id for fast lookups 16 | CREATE UNIQUE INDEX IF NOT EXISTS uk_captcha_settings_chat_id ON captcha_settings(chat_id); 17 | 18 | -- Create captcha_attempts table for tracking active captcha attempts 19 | CREATE TABLE IF NOT EXISTS captcha_attempts ( 20 | id SERIAL PRIMARY KEY, 21 | user_id BIGINT NOT NULL, 22 | chat_id BIGINT NOT NULL, 23 | answer VARCHAR(255) NOT NULL, 24 | attempts INTEGER DEFAULT 0, 25 | message_id BIGINT, 26 | expires_at TIMESTAMP NOT NULL, 27 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 28 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 29 | CONSTRAINT fk_captcha_attempts_user FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE, 30 | CONSTRAINT fk_captcha_attempts_chat FOREIGN KEY (chat_id) REFERENCES chats(chat_id) ON DELETE CASCADE 31 | ); 32 | 33 | -- Create composite index for fast lookups by user and chat 34 | CREATE INDEX IF NOT EXISTS idx_captcha_user_chat ON captcha_attempts(user_id, chat_id); 35 | 36 | -- Create index for expired attempts cleanup 37 | CREATE INDEX IF NOT EXISTS idx_captcha_expires_at ON captcha_attempts(expires_at); 38 | 39 | -- Create function to automatically clean up expired captcha attempts 40 | CREATE OR REPLACE FUNCTION cleanup_expired_captcha_attempts() 41 | RETURNS INTEGER AS $$ 42 | DECLARE 43 | deleted_count INTEGER; 44 | BEGIN 45 | DELETE FROM captcha_attempts 46 | WHERE expires_at < CURRENT_TIMESTAMP; 47 | GET DIAGNOSTICS deleted_count = ROW_COUNT; 48 | RETURN deleted_count; 49 | END; 50 | $$ LANGUAGE plpgsql; 51 | 52 | -- Create trigger to update updated_at timestamp 53 | CREATE OR REPLACE FUNCTION update_captcha_updated_at() 54 | RETURNS TRIGGER AS $$ 55 | BEGIN 56 | NEW.updated_at = CURRENT_TIMESTAMP; 57 | RETURN NEW; 58 | END; 59 | $$ LANGUAGE plpgsql; 60 | 61 | -- Add triggers to update updated_at on both tables 62 | CREATE TRIGGER update_captcha_settings_updated_at 63 | BEFORE UPDATE ON captcha_settings 64 | FOR EACH ROW 65 | EXECUTE FUNCTION update_captcha_updated_at(); 66 | 67 | CREATE TRIGGER update_captcha_attempts_updated_at 68 | BEFORE UPDATE ON captcha_attempts 69 | FOR EACH ROW 70 | EXECUTE FUNCTION update_captcha_updated_at(); -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/divkix/Alita_Robot 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/PaulSonOfLars/gotg_md2html v0.0.0-20240605215313-a6fdd2445f54 7 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.33 8 | github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396 9 | github.com/dustin/go-humanize v1.0.1 10 | github.com/eko/gocache/lib/v4 v4.2.2 11 | github.com/eko/gocache/store/redis/v4 v4.2.5 12 | github.com/getsentry/sentry-go v0.40.0 13 | github.com/getsentry/sentry-go/logrus v0.40.0 14 | github.com/google/uuid v1.6.0 15 | github.com/joho/godotenv v1.5.1 16 | github.com/mojocn/base64Captcha v1.3.8 17 | github.com/prometheus/client_golang v1.23.2 18 | github.com/redis/go-redis/v9 v9.17.1 19 | github.com/sirupsen/logrus v1.9.3 20 | github.com/spf13/viper v1.21.0 21 | go.mongodb.org/mongo-driver v1.17.6 22 | golang.org/x/sync v0.18.0 23 | golang.org/x/text v0.31.0 24 | gopkg.in/yaml.v3 v3.0.1 25 | gorm.io/driver/postgres v1.6.0 26 | gorm.io/gorm v1.31.1 27 | ) 28 | 29 | require ( 30 | github.com/beorn7/perks v1.0.1 // indirect 31 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 32 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 33 | github.com/fsnotify/fsnotify v1.9.0 // indirect 34 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 35 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 36 | github.com/golang/snappy v1.0.0 // indirect 37 | github.com/jackc/pgpassfile v1.0.0 // indirect 38 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 39 | github.com/jackc/pgx/v5 v5.7.6 // indirect 40 | github.com/jackc/puddle/v2 v2.2.2 // indirect 41 | github.com/jinzhu/inflection v1.0.0 // indirect 42 | github.com/jinzhu/now v1.1.5 // indirect 43 | github.com/klauspost/compress v1.18.1 // indirect 44 | github.com/montanaflynn/stats v0.7.1 // indirect 45 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 46 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 47 | github.com/prometheus/client_model v0.6.2 // indirect 48 | github.com/prometheus/common v0.67.4 // indirect 49 | github.com/prometheus/procfs v0.19.2 // indirect 50 | github.com/sagikazarmark/locafero v0.12.0 // indirect 51 | github.com/spf13/afero v1.15.0 // indirect 52 | github.com/spf13/cast v1.10.0 // indirect 53 | github.com/spf13/pflag v1.0.10 // indirect 54 | github.com/subosito/gotenv v1.6.0 // indirect 55 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 56 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 57 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 58 | github.com/xdg-go/scram v1.2.0 // indirect 59 | github.com/xdg-go/stringprep v1.0.4 // indirect 60 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 61 | go.yaml.in/yaml/v2 v2.4.3 // indirect 62 | go.yaml.in/yaml/v3 v3.0.4 // indirect 63 | golang.org/x/crypto v0.45.0 // indirect 64 | golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect 65 | golang.org/x/image v0.33.0 // indirect 66 | golang.org/x/sys v0.38.0 // indirect 67 | google.golang.org/protobuf v1.36.10 // indirect 68 | ) 69 | -------------------------------------------------------------------------------- /alita/health/health.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/divkix/Alita_Robot/alita/config" 10 | "github.com/divkix/Alita_Robot/alita/db" 11 | "github.com/divkix/Alita_Robot/alita/utils/cache" 12 | "github.com/eko/gocache/lib/v4/store" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // HealthStatus represents the health status of the application 17 | type HealthStatus struct { 18 | Status string `json:"status"` 19 | Checks map[string]bool `json:"checks"` 20 | Version string `json:"version"` 21 | Uptime string `json:"uptime"` 22 | } 23 | 24 | var startTime = time.Now() 25 | 26 | // checkDatabase checks if the database connection is healthy 27 | func checkDatabase() bool { 28 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 29 | defer cancel() 30 | 31 | sqlDB, err := db.DB.DB() 32 | if err != nil { 33 | return false 34 | } 35 | 36 | return sqlDB.PingContext(ctx) == nil 37 | } 38 | 39 | // checkRedis checks if the Redis connection is healthy 40 | func checkRedis() bool { 41 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 42 | defer cancel() 43 | 44 | // Try to set and get a test key 45 | testKey := "health_check_test" 46 | err := cache.Manager.Set(ctx, testKey, "ok", store.WithExpiration(5*time.Second)) 47 | if err != nil { 48 | return false 49 | } 50 | 51 | _, err = cache.Manager.Get(ctx, testKey) 52 | // Delete the test key 53 | _ = cache.Manager.Delete(ctx, testKey) 54 | 55 | return err == nil 56 | } 57 | 58 | // HealthHandler handles health check requests 59 | func HealthHandler(w http.ResponseWriter, r *http.Request) { 60 | dbHealthy := checkDatabase() 61 | redisHealthy := checkRedis() 62 | 63 | status := HealthStatus{ 64 | Status: "healthy", 65 | Checks: map[string]bool{ 66 | "database": dbHealthy, 67 | "redis": redisHealthy, 68 | }, 69 | Version: config.BotVersion, 70 | Uptime: time.Since(startTime).String(), 71 | } 72 | 73 | if !dbHealthy || !redisHealthy { 74 | status.Status = "unhealthy" 75 | w.WriteHeader(http.StatusServiceUnavailable) 76 | } 77 | 78 | w.Header().Set("Content-Type", "application/json") 79 | if err := json.NewEncoder(w).Encode(status); err != nil { 80 | log.Errorf("[Health] Failed to encode health status: %v", err) 81 | } 82 | } 83 | 84 | // RegisterHealthEndpoint registers the health check endpoint 85 | func RegisterHealthEndpoint() { 86 | mux := http.NewServeMux() 87 | mux.HandleFunc("/health", HealthHandler) 88 | 89 | go func() { 90 | // Health check always on port 8080 for consistency across modes 91 | port := "8080" 92 | 93 | server := &http.Server{ 94 | Addr: ":" + port, 95 | Handler: mux, 96 | ReadTimeout: 10 * time.Second, 97 | WriteTimeout: 10 * time.Second, 98 | IdleTimeout: 60 * time.Second, 99 | } 100 | 101 | log.Infof("[Health] Starting health check endpoint on port %s", port) 102 | if err := server.ListenAndServe(); err != nil { 103 | // Log but don't fail - health endpoint is optional 104 | log.Warnf("[Health] Health endpoint failed to start: %v", err) 105 | } 106 | }() 107 | } 108 | -------------------------------------------------------------------------------- /alita/utils/keyword_matcher/cache.go: -------------------------------------------------------------------------------- 1 | package keyword_matcher 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | 9 | "github.com/divkix/Alita_Robot/alita/utils/error_handling" 10 | ) 11 | 12 | // Cache manages keyword matchers for different chats 13 | type Cache struct { 14 | matchers map[int64]*KeywordMatcher 15 | mu sync.RWMutex 16 | ttl time.Duration 17 | lastUsed map[int64]time.Time 18 | } 19 | 20 | // NewCache creates a new keyword matcher cache 21 | func NewCache(ttl time.Duration) *Cache { 22 | return &Cache{ 23 | matchers: make(map[int64]*KeywordMatcher), 24 | lastUsed: make(map[int64]time.Time), 25 | ttl: ttl, 26 | } 27 | } 28 | 29 | // GetOrCreateMatcher gets or creates a keyword matcher for the given chat 30 | func (c *Cache) GetOrCreateMatcher(chatID int64, patterns []string) *KeywordMatcher { 31 | c.mu.Lock() 32 | defer c.mu.Unlock() 33 | 34 | // Update last used time 35 | c.lastUsed[chatID] = time.Now() 36 | 37 | // Check if matcher exists 38 | if matcher, exists := c.matchers[chatID]; exists { 39 | // Check if patterns have changed 40 | existingPatterns := matcher.GetPatterns() 41 | if patternsEqual(existingPatterns, patterns) { 42 | return matcher 43 | } 44 | } 45 | 46 | // Create new matcher 47 | matcher := NewKeywordMatcher(patterns) 48 | c.matchers[chatID] = matcher 49 | 50 | log.WithFields(log.Fields{ 51 | "chatID": chatID, 52 | "pattern_count": len(patterns), 53 | }).Debug("Created/updated keyword matcher") 54 | 55 | return matcher 56 | } 57 | 58 | // CleanupExpired removes expired matchers based on TTL 59 | func (c *Cache) CleanupExpired() { 60 | c.mu.Lock() 61 | defer c.mu.Unlock() 62 | 63 | now := time.Now() 64 | expiredChats := make([]int64, 0) 65 | 66 | for chatID, lastUsed := range c.lastUsed { 67 | if now.Sub(lastUsed) > c.ttl { 68 | expiredChats = append(expiredChats, chatID) 69 | } 70 | } 71 | 72 | for _, chatID := range expiredChats { 73 | delete(c.matchers, chatID) 74 | delete(c.lastUsed, chatID) 75 | } 76 | 77 | if len(expiredChats) > 0 { 78 | log.WithField("expired_count", len(expiredChats)).Debug("Cleaned up expired keyword matchers") 79 | } 80 | } 81 | 82 | // patternsEqual checks if two pattern slices are equal 83 | func patternsEqual(a, b []string) bool { 84 | if len(a) != len(b) { 85 | return false 86 | } 87 | 88 | // Create maps for efficient comparison 89 | aMap := make(map[string]bool) 90 | for _, pattern := range a { 91 | aMap[pattern] = true 92 | } 93 | 94 | for _, pattern := range b { 95 | if !aMap[pattern] { 96 | return false 97 | } 98 | } 99 | 100 | return true 101 | } 102 | 103 | // Global cache instance 104 | var ( 105 | globalCache *Cache 106 | once sync.Once 107 | ) 108 | 109 | // GetGlobalCache returns the singleton keyword matcher cache 110 | func GetGlobalCache() *Cache { 111 | once.Do(func() { 112 | globalCache = NewCache(30 * time.Minute) // 30 minute TTL 113 | // Start cleanup routine 114 | go func() { 115 | defer error_handling.RecoverFromPanic("GetGlobalCache.cleanupRoutine", "keyword_matcher") 116 | ticker := time.NewTicker(10 * time.Minute) 117 | defer ticker.Stop() 118 | for range ticker.C { 119 | globalCache.CleanupExpired() 120 | } 121 | }() 122 | }) 123 | return globalCache 124 | } 125 | -------------------------------------------------------------------------------- /supabase/migrations/20250814100000_fix_antiflood_column_duplication.sql: -------------------------------------------------------------------------------- 1 | -- ===================================================== 2 | -- ANTIFLOOD COLUMN CLEANUP MIGRATION 3 | -- ===================================================== 4 | -- This migration fixes the antiflood_settings table by: 5 | -- 1. Removing the unused 'limit' column (GORM uses 'flood_limit' only) 6 | -- 2. Dropping the incorrect index on 'limit' > 0 7 | -- 3. Creating the correct index on 'flood_limit' > 0 8 | -- 9 | -- Background: The table has both 'limit' and 'flood_limit' columns. 10 | -- The Go code only uses 'flood_limit', but the performance index 11 | -- was created on 'limit', causing it to never be used by queries. 12 | 13 | -- ===================================================== 14 | -- Step 1: Drop the incorrect index on 'limit' column 15 | -- ===================================================== 16 | DROP INDEX IF EXISTS idx_antiflood_chat_active; 17 | 18 | -- ===================================================== 19 | -- Step 2: Drop the unused 'limit' column 20 | -- ===================================================== 21 | -- First check if the column exists before dropping it 22 | DO $$ 23 | BEGIN 24 | IF EXISTS ( 25 | SELECT 1 FROM information_schema.columns 26 | WHERE table_schema = 'public' 27 | AND table_name = 'antiflood_settings' 28 | AND column_name = 'limit' 29 | ) THEN 30 | ALTER TABLE antiflood_settings DROP COLUMN "limit"; 31 | RAISE NOTICE 'Dropped unused limit column from antiflood_settings'; 32 | ELSE 33 | RAISE NOTICE 'limit column does not exist in antiflood_settings, skipping'; 34 | END IF; 35 | END $$; 36 | 37 | -- ===================================================== 38 | -- Step 3: Create the correct index on 'flood_limit' > 0 39 | -- ===================================================== 40 | -- This index will actually be used by the application queries 41 | -- which check for active antiflood settings (flood_limit > 0) 42 | CREATE INDEX IF NOT EXISTS idx_antiflood_chat_flood_active 43 | ON antiflood_settings(chat_id) 44 | WHERE flood_limit > 0; 45 | 46 | -- ===================================================== 47 | -- Step 4: Update table statistics 48 | -- ===================================================== 49 | -- Ensure the query planner has up-to-date statistics 50 | ANALYZE antiflood_settings; 51 | 52 | -- ===================================================== 53 | -- Validation and logging 54 | -- ===================================================== 55 | DO $$ 56 | DECLARE 57 | col_count INTEGER; 58 | idx_count INTEGER; 59 | BEGIN 60 | -- Check if limit column was successfully removed 61 | SELECT COUNT(*) INTO col_count 62 | FROM information_schema.columns 63 | WHERE table_schema = 'public' 64 | AND table_name = 'antiflood_settings' 65 | AND column_name = 'limit'; 66 | 67 | -- Check if new index was created 68 | SELECT COUNT(*) INTO idx_count 69 | FROM pg_indexes 70 | WHERE tablename = 'antiflood_settings' 71 | AND indexname = 'idx_antiflood_chat_flood_active'; 72 | 73 | RAISE NOTICE 'Antiflood column cleanup completed:'; 74 | RAISE NOTICE '- limit column exists: %', (col_count > 0); 75 | RAISE NOTICE '- new index created: %', (idx_count > 0); 76 | 77 | IF col_count = 0 AND idx_count > 0 THEN 78 | RAISE NOTICE 'Migration completed successfully!'; 79 | ELSE 80 | RAISE WARNING 'Migration may not have completed as expected'; 81 | END IF; 82 | END $$; 83 | -------------------------------------------------------------------------------- /alita/db/rules_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // checkRulesSetting retrieves or creates default rules settings for a chat. 11 | // Used internally before performing any rules-related operation. 12 | // Returns default settings with empty rules if the chat doesn't exist. 13 | func checkRulesSetting(chatID int64) (rulesrc *RulesSettings) { 14 | rulesrc = &RulesSettings{} 15 | err := GetRecord(rulesrc, RulesSettings{ChatId: chatID}) 16 | if errors.Is(err, gorm.ErrRecordNotFound) { 17 | // Ensure chat exists in database before creating rules to satisfy foreign key constraint 18 | if err := EnsureChatInDb(chatID, ""); err != nil { 19 | log.Errorf("[Database] checkRulesSetting: Failed to ensure chat exists for %d: %v", chatID, err) 20 | return &RulesSettings{ChatId: chatID, Rules: ""} 21 | } 22 | 23 | // Create default settings 24 | rulesrc = &RulesSettings{ChatId: chatID, Rules: ""} 25 | err := CreateRecord(rulesrc) 26 | if err != nil { 27 | log.Errorf("[Database] checkRulesSetting: %v - %d", err, chatID) 28 | } 29 | } else if err != nil { 30 | // Return default on error 31 | rulesrc = &RulesSettings{ChatId: chatID, Rules: ""} 32 | log.Errorf("[Database] checkRulesSetting: %v - %d", err, chatID) 33 | } 34 | return rulesrc 35 | } 36 | 37 | // GetChatRulesInfo returns the rules settings for the specified chat ID. 38 | // This is the public interface to access chat rules information. 39 | func GetChatRulesInfo(chatId int64) *RulesSettings { 40 | return checkRulesSetting(chatId) 41 | } 42 | 43 | // SetChatRules updates the rules text for the specified chat. 44 | // Creates default rules settings if they don't exist. 45 | func SetChatRules(chatId int64, rules string) { 46 | err := UpdateRecord(&RulesSettings{}, RulesSettings{ChatId: chatId}, RulesSettings{Rules: rules}) 47 | if err != nil { 48 | log.Errorf("[Database] SetChatRules: %v - %d", err, chatId) 49 | } 50 | } 51 | 52 | // SetChatRulesButton updates the rules button text for the specified chat. 53 | // The button is used to display rules in a more interactive format. 54 | func SetChatRulesButton(chatId int64, rulesButton string) { 55 | err := UpdateRecord(&RulesSettings{}, RulesSettings{ChatId: chatId}, RulesSettings{RulesBtn: rulesButton}) 56 | if err != nil { 57 | log.Errorf("[Database] SetChatRulesButton: %v", err) 58 | } 59 | } 60 | 61 | // SetPrivateRules sets whether rules should be sent privately to users instead of in the group. 62 | // When enabled, rules are sent as a private message to the requesting user. 63 | func SetPrivateRules(chatId int64, pref bool) { 64 | err := UpdateRecordWithZeroValues(&RulesSettings{}, RulesSettings{ChatId: chatId}, RulesSettings{Private: pref}) 65 | if err != nil { 66 | log.Errorf("[Database] SetPrivateRules: %v", err) 67 | } 68 | } 69 | 70 | // LoadRulesStats returns statistics about rules features across all chats. 71 | // Returns the count of chats with rules set and chats with private rules enabled. 72 | func LoadRulesStats() (setRules, pvtRules int64) { 73 | // Count chats with rules set (non-empty rules) 74 | err := DB.Model(&RulesSettings{}).Where("rules != ?", "").Count(&setRules).Error 75 | if err != nil { 76 | log.Errorf("[Database] LoadRulesStats (set rules): %v", err) 77 | } 78 | 79 | // Count chats with private rules enabled 80 | err = DB.Model(&RulesSettings{}).Where("private = ?", true).Count(&pvtRules).Error 81 | if err != nil { 82 | log.Errorf("[Database] LoadRulesStats (private rules): %v", err) 83 | } 84 | 85 | return 86 | } 87 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | 3 | version: 2 4 | 5 | project_name: alita_robot 6 | 7 | release: 8 | replace_existing_draft: true 9 | header: | 10 | Welcome to this new release! 11 | 12 | footer: | 13 | Docker 🐳 images are available at: 14 | `docker pull ghcr.io/divkix/{{ .ProjectName }}:{{ .Tag }}` 15 | 16 | gomod: 17 | env: 18 | - CGO_ENABLED=1 19 | 20 | before: 21 | hooks: 22 | - go mod tidy 23 | - go mod download 24 | 25 | builds: 26 | - binary: alita_robot 27 | env: 28 | - CGO_ENABLED=0 29 | goos: 30 | - darwin 31 | - linux 32 | - windows 33 | goarch: 34 | - amd64 35 | - arm64 36 | mod_timestamp: "{{ .CommitTimestamp }}" 37 | flags: 38 | - -trimpath 39 | ldflags: 40 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{ .CommitDate }} 41 | 42 | archives: 43 | # - rlcp: true 44 | - name_template: >- 45 | {{ .ProjectName }}_ 46 | {{- .Version }}_ 47 | {{- if eq .Os "darwin" }}macOS 48 | {{- else }}{{ .Os }}{{ end }}_ 49 | {{- if eq .Arch "386" }}i386 50 | {{- else }}{{ .Arch }}{{ end }} 51 | {{- with .Arm }}v{{ . }}{{ end }} 52 | {{- with .Mips }}_{{ . }}{{ end }} 53 | {{- if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }} 54 | format_overrides: 55 | - goos: windows 56 | formats: [zip] 57 | 58 | changelog: 59 | sort: asc 60 | filters: 61 | exclude: 62 | - "^docs:" 63 | - "^test:" 64 | - "^chore:" 65 | - "^ci:" 66 | - Merge pull request 67 | - Merge branch 68 | 69 | checksum: 70 | name_template: "checksums.txt" 71 | algorithm: sha256 72 | 73 | snapshot: 74 | version_template: "{{ incpatch .Version }}-next" 75 | 76 | dockers: 77 | - goarch: amd64 78 | dockerfile: docker/goreleaser 79 | use: buildx 80 | image_templates: 81 | - "ghcr.io/divkix/{{ .ProjectName }}:{{ .Tag }}-amd64" 82 | build_flag_templates: 83 | - "--platform=linux/amd64" 84 | - "--pull" 85 | - "--label=org.opencontainers.image.created={{.Date}}" 86 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 87 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 88 | - "--label=org.opencontainers.image.version={{.Version}}" 89 | extra_files: 90 | - supabase 91 | - goarch: arm64 92 | dockerfile: docker/goreleaser 93 | use: buildx 94 | image_templates: 95 | - "ghcr.io/divkix/{{ .ProjectName }}:{{ .Tag }}-arm64v8" 96 | build_flag_templates: 97 | - "--platform=linux/arm64/v8" 98 | - "--pull" 99 | - "--label=org.opencontainers.image.created={{.Date}}" 100 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 101 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 102 | - "--label=org.opencontainers.image.version={{.Version}}" 103 | extra_files: 104 | - supabase 105 | 106 | docker_manifests: 107 | - name_template: "ghcr.io/divkix/{{ .ProjectName }}:{{ .Version }}" 108 | image_templates: 109 | - "ghcr.io/divkix/{{ .ProjectName }}:{{ .Tag }}-amd64" 110 | - "ghcr.io/divkix/{{ .ProjectName }}:{{ .Tag }}-arm64v8" 111 | - name_template: "ghcr.io/divkix/{{ .ProjectName }}:latest" 112 | image_templates: 113 | - "ghcr.io/divkix/{{ .ProjectName }}:{{ .Tag }}-amd64" 114 | - "ghcr.io/divkix/{{ .ProjectName }}:{{ .Tag }}-arm64v8" 115 | -------------------------------------------------------------------------------- /alita/utils/helpers/telegram_helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/PaulSonOfLars/gotgbot/v2" 7 | "github.com/divkix/Alita_Robot/alita/utils/errors" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func DeleteMessageWithErrorHandling(bot *gotgbot.Bot, chatId, messageId int64) error { 12 | _, err := bot.DeleteMessage(chatId, messageId, nil) 13 | if err != nil { 14 | errStr := err.Error() 15 | if strings.Contains(errStr, "message to delete not found") || 16 | strings.Contains(errStr, "message can't be deleted") { 17 | log.WithFields(log.Fields{ 18 | "chat_id": chatId, 19 | "message_id": messageId, 20 | "error": errStr, 21 | }).Debug("Message already deleted or can't be deleted") 22 | return nil 23 | } 24 | return errors.Wrapf(err, "failed to delete message %d in chat %d", messageId, chatId) 25 | } 26 | return nil 27 | } 28 | 29 | // SendMessageWithErrorHandling wraps bot.SendMessage with graceful error handling for expected permission errors. 30 | // This prevents Sentry spam when the bot lacks send message permissions in a chat. 31 | // Returns (*Message, nil) for suppressed permission errors to allow callers to continue execution. 32 | func SendMessageWithErrorHandling(bot *gotgbot.Bot, chatId int64, text string, opts *gotgbot.SendMessageOpts) (*gotgbot.Message, error) { 33 | msg, err := bot.SendMessage(chatId, text, opts) 34 | if err != nil { 35 | errStr := err.Error() 36 | // Check for expected permission-related errors 37 | if strings.Contains(errStr, "not enough rights to send text messages") || 38 | strings.Contains(errStr, "have no rights to send a message") || 39 | strings.Contains(errStr, "CHAT_WRITE_FORBIDDEN") || 40 | strings.Contains(errStr, "CHAT_RESTRICTED") || 41 | strings.Contains(errStr, "need administrator rights in the channel chat") { 42 | log.WithFields(log.Fields{ 43 | "chat_id": chatId, 44 | "error": errStr, 45 | }).Warning("Bot lacks permission to send messages in this chat") 46 | return nil, nil 47 | } 48 | return nil, errors.Wrapf(err, "failed to send message to chat %d", chatId) 49 | } 50 | return msg, nil 51 | } 52 | 53 | // ShouldSuppressFromSentry checks if an error should be suppressed from Sentry reporting. 54 | // Returns true for expected Telegram API errors that occur during normal bot operations. 55 | func ShouldSuppressFromSentry(err error) bool { 56 | if err == nil { 57 | return false 58 | } 59 | 60 | errStr := err.Error() 61 | 62 | // Check for expected Telegram API errors 63 | expectedErrors := []string{ 64 | // Bot access errors (kicked, banned, or restricted) 65 | "CHAT_RESTRICTED", 66 | "bot was kicked from the", 67 | "bot was blocked by the user", 68 | "Forbidden: bot was kicked", 69 | "Forbidden: bot is not a member", 70 | 71 | // Thread/topic errors 72 | "message thread not found", 73 | "thread not found", 74 | 75 | // Chat state errors 76 | "group chat was deactivated", 77 | "chat not found", 78 | "group chat was upgraded to a supergroup", 79 | 80 | // Network and timeout errors (expected during Telegram API slowness) 81 | "timeout awaiting response headers", 82 | "http2: timeout", 83 | "context deadline exceeded", 84 | 85 | // Permission errors (expected when bot lacks required permissions) 86 | "not enough rights to restrict/unrestrict chat member", 87 | "not enough rights to send text messages", 88 | "not enough rights to", 89 | } 90 | 91 | for _, expectedErr := range expectedErrors { 92 | if strings.Contains(errStr, expectedErr) { 93 | return true 94 | } 95 | } 96 | 97 | return false 98 | } 99 | -------------------------------------------------------------------------------- /alita/metrics/prometheus.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promauto" 9 | "github.com/prometheus/client_golang/prometheus/promhttp" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var ( 14 | // CommandsProcessed tracks total commands processed 15 | CommandsProcessed = promauto.NewCounterVec( 16 | prometheus.CounterOpts{ 17 | Name: "alita_commands_processed_total", 18 | Help: "Total number of commands processed", 19 | }, 20 | []string{"command", "status"}, 21 | ) 22 | 23 | // MessagesProcessed tracks total messages processed 24 | MessagesProcessed = promauto.NewCounter( 25 | prometheus.CounterOpts{ 26 | Name: "alita_messages_processed_total", 27 | Help: "Total number of messages processed", 28 | }, 29 | ) 30 | 31 | // DatabaseQueries tracks database query durations 32 | DatabaseQueries = promauto.NewHistogramVec( 33 | prometheus.HistogramOpts{ 34 | Name: "alita_database_queries_duration_seconds", 35 | Help: "Database query duration", 36 | Buckets: prometheus.DefBuckets, 37 | }, 38 | []string{"operation", "table"}, 39 | ) 40 | 41 | // CacheHits tracks cache hit/miss rates 42 | CacheHits = promauto.NewCounterVec( 43 | prometheus.CounterOpts{ 44 | Name: "alita_cache_hits_total", 45 | Help: "Total number of cache hits", 46 | }, 47 | []string{"cache_type", "result"}, 48 | ) 49 | 50 | // ActiveUsers tracks number of active users 51 | ActiveUsers = promauto.NewGauge( 52 | prometheus.GaugeOpts{ 53 | Name: "alita_active_users", 54 | Help: "Number of active users", 55 | }, 56 | ) 57 | 58 | // ActiveChats tracks number of active chats 59 | ActiveChats = promauto.NewGauge( 60 | prometheus.GaugeOpts{ 61 | Name: "alita_active_chats", 62 | Help: "Number of active chats", 63 | }, 64 | ) 65 | 66 | // ErrorRate tracks error occurrences 67 | ErrorRate = promauto.NewCounterVec( 68 | prometheus.CounterOpts{ 69 | Name: "alita_errors_total", 70 | Help: "Total number of errors", 71 | }, 72 | []string{"error_type"}, 73 | ) 74 | 75 | // ResponseTime tracks API response times 76 | ResponseTime = promauto.NewHistogramVec( 77 | prometheus.HistogramOpts{ 78 | Name: "alita_response_time_seconds", 79 | Help: "API response time in seconds", 80 | Buckets: []float64{0.1, 0.25, 0.5, 1, 2.5, 5, 10}, 81 | }, 82 | []string{"endpoint"}, 83 | ) 84 | 85 | // GoroutineCount tracks current goroutine count 86 | GoroutineCount = promauto.NewGauge( 87 | prometheus.GaugeOpts{ 88 | Name: "alita_goroutines", 89 | Help: "Current number of goroutines", 90 | }, 91 | ) 92 | 93 | // MemoryUsage tracks memory usage in MB 94 | MemoryUsage = promauto.NewGauge( 95 | prometheus.GaugeOpts{ 96 | Name: "alita_memory_usage_mb", 97 | Help: "Current memory usage in MB", 98 | }, 99 | ) 100 | ) 101 | 102 | // StartMetricsServer starts the Prometheus metrics server 103 | func StartMetricsServer(port string) { 104 | mux := http.NewServeMux() 105 | mux.Handle("/metrics", promhttp.Handler()) 106 | 107 | server := &http.Server{ 108 | Addr: ":" + port, 109 | Handler: mux, 110 | ReadTimeout: 10 * time.Second, 111 | WriteTimeout: 10 * time.Second, 112 | IdleTimeout: 60 * time.Second, 113 | } 114 | 115 | go func() { 116 | log.Infof("[Metrics] Starting Prometheus metrics server on port %s", port) 117 | if err := server.ListenAndServe(); err != nil { 118 | log.Warnf("[Metrics] Metrics server failed to start: %v", err) 119 | } 120 | }() 121 | } 122 | -------------------------------------------------------------------------------- /supabase/migrations/20250808120328_fix_unused_indexes_and_missing_fk.sql: -------------------------------------------------------------------------------- 1 | -- ===================================================== 2 | -- Fix Unused Indexes and Missing Foreign Key Index 3 | -- ===================================================== 4 | -- This migration addresses Supabase linter suggestions: 5 | -- 1. Adds missing covering index for captcha_attempts.chat_id FK 6 | -- 2. Drops unused indexes that were unnecessarily recreated 7 | -- Date: 2025-08-08 8 | -- ===================================================== 9 | 10 | BEGIN; 11 | 12 | -- ===================================================== 13 | -- 1. Add Missing Foreign Key Covering Index 14 | -- ===================================================== 15 | -- The foreign key fk_captcha_attempts_chat on captcha_attempts(chat_id) 16 | -- needs a covering index for optimal JOIN performance. 17 | -- This resolves: "Unindexed foreign keys" linter warning 18 | 19 | CREATE INDEX IF NOT EXISTS idx_captcha_attempts_chat_id 20 | ON public.captcha_attempts(chat_id); 21 | COMMENT ON INDEX idx_captcha_attempts_chat_id IS 'Covering index for foreign key fk_captcha_attempts_chat'; 22 | 23 | -- ===================================================== 24 | -- 2. Drop Unused Indexes (0 scans in production) 25 | -- ===================================================== 26 | -- These indexes were previously dropped in migration 20250806105636 27 | -- but were mistakenly recreated in migration 20250807123000. 28 | -- Supabase linter confirms they have never been used. 29 | 30 | -- captcha_attempts.expires_at index - likely cleanup uses direct timestamp comparison 31 | DROP INDEX IF EXISTS public.idx_captcha_expires_at; 32 | 33 | -- channels.channel_id index - redundant, was recreated but still shows 0 scans 34 | DROP INDEX IF EXISTS public.idx_channels_channel_id; 35 | 36 | -- chat_users.user_id index - redundant, was recreated but still shows 0 scans 37 | DROP INDEX IF EXISTS public.idx_chat_users_user_id; 38 | 39 | -- connection.chat_id index - redundant, was recreated but still shows 0 scans 40 | DROP INDEX IF EXISTS public.idx_connection_chat_id; 41 | 42 | -- ===================================================== 43 | -- 3. Update Statistics for Query Planner 44 | -- ===================================================== 45 | ANALYZE captcha_attempts; 46 | ANALYZE channels; 47 | ANALYZE chat_users; 48 | ANALYZE connection; 49 | 50 | COMMIT; 51 | 52 | -- ===================================================== 53 | -- VERIFICATION QUERIES (run manually after migration) 54 | -- ===================================================== 55 | -- 1. Confirm new covering index exists: 56 | -- SELECT indexname FROM pg_indexes 57 | -- WHERE tablename = 'captcha_attempts' 58 | -- AND indexname = 'idx_captcha_attempts_chat_id'; 59 | -- 60 | -- 2. Confirm unused indexes are dropped: 61 | -- SELECT indexname FROM pg_indexes 62 | -- WHERE schemaname = 'public' 63 | -- AND indexname IN ( 64 | -- 'idx_captcha_expires_at', 65 | -- 'idx_channels_channel_id', 66 | -- 'idx_chat_users_user_id', 67 | -- 'idx_connection_chat_id' 68 | -- ); 69 | -- (Should return 0 rows) 70 | -- 71 | -- 3. Check foreign key has covering index: 72 | -- SELECT 73 | -- tc.constraint_name, 74 | -- tc.table_name, 75 | -- kcu.column_name, 76 | -- (SELECT COUNT(*) FROM pg_indexes i 77 | -- WHERE i.tablename = tc.table_name 78 | -- AND i.indexdef LIKE '%' || kcu.column_name || '%') as index_count 79 | -- FROM information_schema.table_constraints tc 80 | -- JOIN information_schema.key_column_usage kcu 81 | -- ON tc.constraint_name = kcu.constraint_name 82 | -- WHERE tc.constraint_type = 'FOREIGN KEY' 83 | -- AND tc.table_name = 'captcha_attempts' 84 | -- AND tc.constraint_name = 'fk_captcha_attempts_chat'; 85 | -- (Should show index_count > 0) -------------------------------------------------------------------------------- /alita/db/blacklists_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "strings" 5 | 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // AddBlacklist adds a new blacklist word to a chat with default 'warn' action. 10 | // The trigger is converted to lowercase before storage. 11 | func AddBlacklist(chatId int64, trigger string) { 12 | // Create a new blacklist entry 13 | blacklist := &BlacklistSettings{ 14 | ChatId: chatId, 15 | Word: strings.ToLower(trigger), 16 | Action: "warn", // default action 17 | } 18 | 19 | err := CreateRecord(blacklist) 20 | if err != nil { 21 | log.Errorf("[Database] AddBlacklist: %v - %d", err, chatId) 22 | } 23 | 24 | // Invalidate cache after adding blacklist 25 | deleteCache(blacklistCacheKey(chatId)) 26 | } 27 | 28 | // RemoveBlacklist removes a specific blacklist word from a chat. 29 | // The trigger is converted to lowercase before removal. 30 | func RemoveBlacklist(chatId int64, trigger string) { 31 | result := DB.Where("chat_id = ? AND word = ?", chatId, strings.ToLower(trigger)).Delete(&BlacklistSettings{}) 32 | if result.Error != nil { 33 | log.Errorf("[Database] RemoveBlacklist: %v - %d", result.Error, chatId) 34 | } 35 | 36 | // Invalidate cache if something was deleted 37 | if result.RowsAffected > 0 { 38 | deleteCache(blacklistCacheKey(chatId)) 39 | } 40 | } 41 | 42 | // RemoveAllBlacklist removes all blacklist entries for a specific chat. 43 | func RemoveAllBlacklist(chatId int64) { 44 | err := DB.Where("chat_id = ?", chatId).Delete(&BlacklistSettings{}).Error 45 | if err != nil { 46 | log.Errorf("[Database] RemoveAllBlacklist: %v - %d", err, chatId) 47 | } 48 | 49 | // Invalidate cache after removing all blacklist entries 50 | deleteCache(blacklistCacheKey(chatId)) 51 | } 52 | 53 | // SetBlacklistAction updates the action for all blacklist entries in a chat. 54 | // The action is converted to lowercase before storage. 55 | func SetBlacklistAction(chatId int64, action string) { 56 | err := DB.Model(&BlacklistSettings{}).Where("chat_id = ?", chatId).Update("action", strings.ToLower(action)).Error 57 | if err != nil { 58 | log.Errorf("[Database] SetBlacklistAction: %v - %d", err, chatId) 59 | } 60 | 61 | // Invalidate cache after updating action 62 | deleteCache(blacklistCacheKey(chatId)) 63 | } 64 | 65 | // GetBlacklistSettings retrieves all blacklist settings for a chat with caching support. 66 | // Returns an empty slice if no blacklists are found or on error. 67 | func GetBlacklistSettings(chatId int64) BlacklistSettingsSlice { 68 | // Try to get from cache first 69 | cacheKey := blacklistCacheKey(chatId) 70 | result, err := getFromCacheOrLoad(cacheKey, CacheTTLBlacklist, func() (BlacklistSettingsSlice, error) { 71 | var blacklists []*BlacklistSettings 72 | err := GetRecords(&blacklists, BlacklistSettings{ChatId: chatId}) 73 | if err != nil { 74 | log.Errorf("[Database] GetBlacklistSettings: %v - %d", err, chatId) 75 | return BlacklistSettingsSlice{}, err 76 | } 77 | return BlacklistSettingsSlice(blacklists), nil 78 | }) 79 | if err != nil { 80 | return BlacklistSettingsSlice{} 81 | } 82 | return result 83 | } 84 | 85 | // LoadBlacklistsStats returns statistics about blacklist usage. 86 | // Returns the total number of blacklist entries and distinct chats using blacklists. 87 | func LoadBlacklistsStats() (blacklistTriggers, blacklistChats int64) { 88 | // Count total blacklist entries 89 | err := DB.Model(&BlacklistSettings{}).Count(&blacklistTriggers).Error 90 | if err != nil { 91 | log.Errorf("[Database] LoadBlacklistsStats (triggers): %v", err) 92 | return 0, 0 93 | } 94 | 95 | // Count distinct chats with blacklists 96 | err = DB.Model(&BlacklistSettings{}).Distinct("chat_id").Count(&blacklistChats).Error 97 | if err != nil { 98 | log.Errorf("[Database] LoadBlacklistsStats (chats): %v", err) 99 | return blacklistTriggers, 0 100 | } 101 | 102 | return 103 | } 104 | -------------------------------------------------------------------------------- /alita/i18n/loader.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/spf13/viper" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | // loadLocaleFiles loads all locale files from the embedded filesystem. 14 | func (lm *LocaleManager) loadLocaleFiles() error { 15 | if lm.localeFS == nil || lm.localePath == "" { 16 | return NewI18nError("load_files", "", "", "filesystem or path not set", fmt.Errorf("invalid configuration")) 17 | } 18 | 19 | entries, err := lm.localeFS.ReadDir(lm.localePath) 20 | if err != nil { 21 | return NewI18nError("load_files", "", "", "failed to read locale directory", err) 22 | } 23 | 24 | var loadErrors []error 25 | 26 | for _, entry := range entries { 27 | if entry.IsDir() { 28 | continue 29 | } 30 | 31 | // Only process YAML files 32 | fileName := entry.Name() 33 | if !isYAMLFile(fileName) { 34 | continue 35 | } 36 | 37 | // Skip config files - they contain module configuration, not translations 38 | if fileName == "config.yml" || fileName == "config.yaml" { 39 | continue 40 | } 41 | 42 | filePath := filepath.Join(lm.localePath, fileName) 43 | langCode := extractLangCode(fileName) 44 | 45 | if err := lm.loadSingleLocaleFile(filePath, langCode); err != nil { 46 | loadErrors = append(loadErrors, err) 47 | // Continue loading other files even if one fails 48 | continue 49 | } 50 | } 51 | 52 | if len(loadErrors) > 0 { 53 | return fmt.Errorf("failed to load %d locale files: %v", len(loadErrors), loadErrors) 54 | } 55 | 56 | return nil 57 | } 58 | 59 | // loadSingleLocaleFile loads and validates a single locale file. 60 | func (lm *LocaleManager) loadSingleLocaleFile(filePath, langCode string) error { 61 | // Read file content 62 | content, err := lm.localeFS.ReadFile(filePath) 63 | if err != nil { 64 | return NewI18nError("load_file", langCode, "", "failed to read file", err) 65 | } 66 | 67 | // Validate YAML structure 68 | if err := validateYAMLStructure(content); err != nil { 69 | return NewI18nError("load_file", langCode, "", "invalid YAML structure", err) 70 | } 71 | 72 | // Store raw data 73 | lm.localeData[langCode] = content 74 | 75 | // Pre-compile viper instance 76 | viperInstance, err := compileViper(content) 77 | if err != nil { 78 | return NewI18nError("load_file", langCode, "", "failed to compile viper", err) 79 | } 80 | 81 | lm.viperCache[langCode] = viperInstance 82 | 83 | return nil 84 | } 85 | 86 | // validateYAMLStructure validates that the YAML content is well-formed. 87 | func validateYAMLStructure(content []byte) error { 88 | var data any 89 | if err := yaml.Unmarshal(content, &data); err != nil { 90 | return NewI18nError("validate_yaml", "", "", "YAML parsing failed", err) 91 | } 92 | 93 | // Check if it's a map structure (required for translations) 94 | if _, ok := data.(map[string]any); !ok { 95 | return NewI18nError("validate_yaml", "", "", "root element must be a map", ErrInvalidYAML) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | // extractLangCode extracts the language code from a filename. 102 | func extractLangCode(fileName string) string { 103 | langCode := strings.TrimSuffix(fileName, filepath.Ext(fileName)) 104 | // Handle common YAML extensions 105 | langCode = strings.TrimSuffix(langCode, ".yml") 106 | langCode = strings.TrimSuffix(langCode, ".yaml") 107 | return langCode 108 | } 109 | 110 | // compileViper creates and configures a viper instance from YAML content. 111 | func compileViper(content []byte) (*viper.Viper, error) { 112 | vi := viper.New() 113 | vi.SetConfigType("yaml") 114 | 115 | if err := vi.ReadConfig(bytes.NewBuffer(content)); err != nil { 116 | return nil, fmt.Errorf("failed to parse YAML: %w", err) 117 | } 118 | 119 | return vi, nil 120 | } 121 | 122 | // isYAMLFile checks if a filename has a YAML extension. 123 | func isYAMLFile(fileName string) bool { 124 | ext := strings.ToLower(filepath.Ext(fileName)) 125 | return ext == ".yml" || ext == ".yaml" 126 | } 127 | -------------------------------------------------------------------------------- /alita/db/antiflood_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // default mode is 'mute' 11 | const defaultFloodsettingsMode string = "mute" 12 | 13 | // GetFlood Get flood settings for a chat 14 | func GetFlood(chatID int64) *AntifloodSettings { 15 | return checkFloodSetting(chatID) 16 | } 17 | 18 | // checkFloodSetting retrieves or returns default antiflood settings for a chat. 19 | // Uses optimized cached queries and returns default settings if not found. 20 | func checkFloodSetting(chatID int64) (floodSrc *AntifloodSettings) { 21 | // Use optimized cached query instead of SELECT * 22 | floodSrc, err := GetOptimizedQueries().GetAntifloodSettingsCached(chatID) 23 | if err != nil { 24 | if errors.Is(err, gorm.ErrRecordNotFound) { 25 | // Return default settings 26 | return &AntifloodSettings{ChatId: chatID, Limit: 0, Action: defaultFloodsettingsMode} 27 | } 28 | log.Errorf("[Database][checkFloodSetting]: %v", err) 29 | return &AntifloodSettings{ChatId: chatID, Limit: 0, Action: defaultFloodsettingsMode} 30 | } 31 | return floodSrc 32 | } 33 | 34 | // SetFlood set Flood Setting for a Chat 35 | func SetFlood(chatID int64, limit int) { 36 | floodSrc := checkFloodSetting(chatID) 37 | 38 | // Check if update is actually needed 39 | if floodSrc.Limit == limit { 40 | return 41 | } 42 | 43 | action := floodSrc.Action 44 | if action == "" { 45 | action = defaultFloodsettingsMode 46 | } 47 | 48 | // create or update the value in db 49 | settings := AntifloodSettings{ 50 | ChatId: chatID, 51 | Limit: limit, 52 | Action: action, 53 | } 54 | err := DB.Where(AntifloodSettings{ChatId: chatID}). 55 | Assign(settings). 56 | FirstOrCreate(&settings).Error 57 | if err != nil { 58 | log.Errorf("[Database] SetFlood: %v - %d", err, chatID) 59 | } 60 | // Invalidate cache after update 61 | deleteCache(optimizedAntifloodCacheKey(chatID)) 62 | } 63 | 64 | // SetFloodMode Set flood mode for a chat 65 | func SetFloodMode(chatID int64, mode string) { 66 | floodSrc := checkFloodSetting(chatID) 67 | // Check if update is actually needed 68 | if floodSrc.Action == mode { 69 | return 70 | } 71 | // create or update the mode in db 72 | settings := AntifloodSettings{ChatId: chatID} 73 | err := DB.Where(AntifloodSettings{ChatId: chatID}). 74 | Assign(AntifloodSettings{Action: mode}). 75 | FirstOrCreate(&settings).Error 76 | if err != nil { 77 | log.Errorf("[Database] SetFloodMode: %v - %d", err, chatID) 78 | } 79 | // Invalidate cache after update 80 | deleteCache(optimizedAntifloodCacheKey(chatID)) 81 | } 82 | 83 | // SetFloodMsgDel Set flood message deletion setting for a chat 84 | func SetFloodMsgDel(chatID int64, val bool) { 85 | floodSrc := checkFloodSetting(chatID) 86 | // Check if update is actually needed 87 | if floodSrc.DeleteAntifloodMessage == val { 88 | return 89 | } 90 | // create or update the message deletion setting in db 91 | settings := AntifloodSettings{ChatId: chatID} 92 | err := DB.Where(AntifloodSettings{ChatId: chatID}). 93 | Assign(AntifloodSettings{DeleteAntifloodMessage: val}). 94 | FirstOrCreate(&settings).Error 95 | if err != nil { 96 | log.Errorf("[Database] SetFloodMsgDel: %v", err) 97 | return 98 | } 99 | // Invalidate cache after update 100 | deleteCache(optimizedAntifloodCacheKey(chatID)) 101 | } 102 | 103 | // LoadAntifloodStats returns the count of chats with antiflood enabled (limit > 0). 104 | func LoadAntifloodStats() (antiCount int64) { 105 | var totalCount int64 106 | var noAntiCount int64 107 | 108 | // Count total antiflood settings 109 | err := DB.Model(&AntifloodSettings{}).Count(&totalCount).Error 110 | if err != nil { 111 | log.Errorf("[Database] LoadAntifloodStats: %v", err) 112 | return 0 113 | } 114 | 115 | // Count settings with limit 0 (disabled) 116 | err = DB.Model(&AntifloodSettings{}).Where("flood_limit = ?", 0).Count(&noAntiCount).Error 117 | if err != nil { 118 | log.Errorf("[Database] LoadAntifloodStats: %v", err) 119 | return 0 120 | } 121 | 122 | antiCount = totalCount - noAntiCount // gives chats which have enabled anti flood 123 | 124 | return 125 | } 126 | -------------------------------------------------------------------------------- /alita/db/channels_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // GetChannelSettings retrieves channel settings from cache or database. 11 | // Returns nil if the channel is not found or an error occurs. 12 | func GetChannelSettings(channelId int64) (channelSrc *ChannelSettings) { 13 | // Use optimized cached query instead of SELECT * 14 | channelSrc, err := GetOptimizedQueries().GetChannelSettingsCached(channelId) 15 | if err != nil { 16 | if errors.Is(err, gorm.ErrRecordNotFound) { 17 | return nil 18 | } 19 | log.Errorf("[Database] GetChannelSettings: %v - %d", err, channelId) 20 | return nil 21 | } 22 | return channelSrc 23 | } 24 | 25 | // EnsureChatExists ensures a chat record exists before creating related records. 26 | // Creates a minimal chat record with default settings if it doesn't exist. 27 | func EnsureChatExists(chatId int64, chatName string) error { 28 | if ChatExists(chatId) { 29 | return nil 30 | } 31 | 32 | // Create minimal chat record 33 | chat := &Chat{ 34 | ChatId: chatId, 35 | ChatName: chatName, 36 | Language: "en", // default language 37 | Users: Int64Array{}, 38 | IsInactive: false, 39 | } 40 | 41 | err := CreateRecord(chat) 42 | if err != nil { 43 | log.Errorf("[Database] EnsureChatExists: Failed to create chat %d: %v", chatId, err) 44 | return err 45 | } 46 | 47 | log.Infof("[Database] EnsureChatExists: Created chat record for %d", chatId) 48 | return nil 49 | } 50 | 51 | // UpdateChannel updates or creates a channel record. 52 | // Ensures the chat exists before creating channel settings and invalidates cache after updates. 53 | func UpdateChannel(channelId int64, channelName, username string) { 54 | // Check if channel already exists 55 | channelSrc := GetChannelSettings(channelId) 56 | 57 | if channelSrc != nil && channelSrc.ChannelId == channelId { 58 | // Channel already exists with same ID, no update needed 59 | return 60 | } 61 | 62 | // Ensure the chat exists before creating/updating channel 63 | if err := EnsureChatExists(channelId, channelName); err != nil { 64 | log.Errorf("[Database] UpdateChannel: Failed to ensure chat exists for %d (%s): %v", channelId, username, err) 65 | return 66 | } 67 | 68 | if channelSrc == nil { 69 | // Create new channel - Note: The original Channel struct doesn't map well to ChannelSettings 70 | // ChannelSettings is for chat->channel mapping, not channel info storage 71 | channelSrc = &ChannelSettings{ 72 | ChatId: channelId, 73 | ChannelId: channelId, // Assuming this is the mapping 74 | } 75 | err := CreateRecord(channelSrc) 76 | if err != nil { 77 | log.Errorf("[Database] UpdateChannel: %v - %d (%s)", err, channelId, username) 78 | return 79 | } 80 | // Invalidate cache after create 81 | deleteCache(channelCacheKey(channelId)) 82 | log.Debugf("[Database] UpdateChannel: created channel %d", channelId) 83 | } 84 | } 85 | 86 | // GetChannelIdByUserName attempts to find a channel ID by username. 87 | // Returns 0 as this function is not supported with the current model structure. 88 | func GetChannelIdByUserName(username string) int64 { 89 | // Note: The new ChannelSettings model doesn't store username 90 | // This function cannot be implemented with the current model structure 91 | log.Warnf("[Database] GetChannelIdByUserName: Function not supported with current model structure") 92 | return 0 93 | } 94 | 95 | // GetChannelInfoById retrieves channel information by user ID. 96 | // Returns empty strings for username and name as the current model doesn't store this data. 97 | func GetChannelInfoById(userId int64) (username, name string, found bool) { 98 | channel := GetChannelSettings(userId) 99 | if channel != nil { 100 | // Note: The new model doesn't store username/name, only IDs 101 | username = "" 102 | name = "" 103 | found = true 104 | } 105 | return 106 | } 107 | 108 | // LoadChannelStats returns the total count of channel settings records. 109 | func LoadChannelStats() (count int64) { 110 | err := DB.Model(&ChannelSettings{}).Count(&count).Error 111 | if err != nil { 112 | log.Errorf("[Database] loadChannelStats: %v", err) 113 | return 0 114 | } 115 | return 116 | } 117 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: run tidy vendor build lint check-translations psql-prepare psql-migrate psql-status psql-rollback psql-reset psql-verify 2 | 3 | GO_CMD = go 4 | GORELEASER_CMD = goreleaser 5 | GOLANGCI_LINT_CMD = golangci-lint 6 | 7 | # PostgreSQL Migration Variables 8 | PSQL_SCRIPT = scripts/migrate_psql.sh 9 | PSQL_MIGRATIONS_DIR ?= tmp/migrations_cleaned 10 | SUPABASE_MIGRATIONS_DIR ?= supabase/migrations 11 | 12 | run: 13 | $(GO_CMD) run main.go 14 | 15 | tidy: 16 | $(GO_CMD) mod tidy 17 | 18 | vendor: 19 | $(GO_CMD) mod vendor 20 | 21 | build: 22 | $(GORELEASER_CMD) release --snapshot --skip=publish --clean --skip=sign 23 | 24 | lint: 25 | @which $(GOLANGCI_LINT_CMD) > /dev/null || (echo "golangci-lint not found, install it from https://golangci-lint.run/usage/install/" && exit 1) 26 | $(GOLANGCI_LINT_CMD) run 27 | 28 | check-translations: 29 | @echo "🔍 Checking for missing translations..." 30 | @cd scripts/check_translations && $(GO_CMD) mod tidy && $(GO_CMD) run main.go 31 | 32 | # PostgreSQL Migration Targets 33 | psql-prepare: 34 | @echo "🔧 Preparing PostgreSQL migrations (cleaning Supabase SQL)..." 35 | @mkdir -p $(PSQL_MIGRATIONS_DIR) 36 | @for file in $(SUPABASE_MIGRATIONS_DIR)/*.sql; do \ 37 | filename=$$(basename "$$file"); \ 38 | echo " Processing $$filename..."; \ 39 | sed -E '/(grant|GRANT).*(anon|authenticated|service_role)/d' "$$file" | \ 40 | sed 's/ with schema "extensions"//g' | \ 41 | sed 's/create extension if not exists/CREATE EXTENSION IF NOT EXISTS/g' | \ 42 | sed 's/create extension/CREATE EXTENSION IF NOT EXISTS/g' > "$(PSQL_MIGRATIONS_DIR)/$$filename"; \ 43 | done 44 | @echo "✅ PostgreSQL migrations prepared in $(PSQL_MIGRATIONS_DIR)" 45 | @echo "📋 Found $$(ls -1 $(PSQL_MIGRATIONS_DIR)/*.sql 2>/dev/null | wc -l) migration files" 46 | 47 | psql-migrate: 48 | @echo "🚀 Applying PostgreSQL migrations..." 49 | @if [ -z "$(PSQL_DB_HOST)" ] || [ -z "$(PSQL_DB_NAME)" ] || [ -z "$(PSQL_DB_USER)" ]; then \ 50 | echo "❌ Error: Required environment variables not set"; \ 51 | echo " Please set: PSQL_DB_HOST, PSQL_DB_NAME, PSQL_DB_USER, PSQL_DB_PASSWORD"; \ 52 | exit 1; \ 53 | fi 54 | @chmod +x $(PSQL_SCRIPT) 2>/dev/null || true 55 | @bash $(PSQL_SCRIPT) 56 | 57 | psql-status: 58 | @echo "📊 PostgreSQL Migration Status" 59 | @if [ -z "$(PSQL_DB_HOST)" ] || [ -z "$(PSQL_DB_NAME)" ] || [ -z "$(PSQL_DB_USER)" ]; then \ 60 | echo "❌ Error: Required environment variables not set"; \ 61 | echo " Please set: PSQL_DB_HOST, PSQL_DB_NAME, PSQL_DB_USER, PSQL_DB_PASSWORD"; \ 62 | exit 1; \ 63 | fi 64 | @echo "🔍 Checking migration status..." 65 | @PGPASSWORD=$(PSQL_DB_PASSWORD) psql -h $(PSQL_DB_HOST) -p $${PSQL_DB_PORT:-5432} -U $(PSQL_DB_USER) -d $(PSQL_DB_NAME) -c \ 66 | "SELECT version, executed_at FROM schema_migrations ORDER BY executed_at DESC;" 2>/dev/null || \ 67 | echo "⚠️ No migrations table found. Run 'make psql-migrate' to initialize." 68 | 69 | psql-rollback: 70 | @echo "⏪ Rolling back last PostgreSQL migration..." 71 | @if [ -z "$(PSQL_DB_HOST)" ] || [ -z "$(PSQL_DB_NAME)" ] || [ -z "$(PSQL_DB_USER)" ]; then \ 72 | echo "❌ Error: Required environment variables not set"; \ 73 | exit 1; \ 74 | fi 75 | @echo "⚠️ Rollback functionality requires manual intervention" 76 | @echo " Last applied migration:" 77 | @PGPASSWORD=$(PSQL_DB_PASSWORD) psql -h $(PSQL_DB_HOST) -p $${PSQL_DB_PORT:-5432} -U $(PSQL_DB_USER) -d $(PSQL_DB_NAME) -t -c \ 78 | "SELECT version FROM schema_migrations ORDER BY executed_at DESC LIMIT 1;" 2>/dev/null 79 | 80 | psql-reset: 81 | @echo "🔥 WARNING: This will DROP ALL TABLES in the database!" 82 | @echo " Database: $(PSQL_DB_NAME) on $(PSQL_DB_HOST)" 83 | @echo " Type 'yes' to confirm: " && read confirm && [ "$$confirm" = "yes" ] || (echo "Cancelled" && exit 1) 84 | @echo "💣 Resetting database..." 85 | @PGPASSWORD=$(PSQL_DB_PASSWORD) psql -h $(PSQL_DB_HOST) -p $${PSQL_DB_PORT:-5432} -U $(PSQL_DB_USER) -d $(PSQL_DB_NAME) -c \ 86 | "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" 87 | @echo "✅ Database reset complete" 88 | 89 | psql-verify: 90 | @echo "🔎 Verifying cleaned migrations are in sync" 91 | @TMP=$$(mktemp -d); \ 92 | echo "Using temp dir: $$TMP"; \ 93 | $(MAKE) --no-print-directory psql-prepare PSQL_MIGRATIONS_DIR="$$TMP"; \ 94 | git diff --no-index --exit-code $(PSQL_MIGRATIONS_DIR) "$$TMP" || (echo "❌ Drift detected between supabase/migrations and $(PSQL_MIGRATIONS_DIR)" && exit 1) -------------------------------------------------------------------------------- /supabase/migrations/20250806105636_drop_unused_indexes.sql: -------------------------------------------------------------------------------- 1 | -- ===================================================== 2 | -- DROP UNUSED INDEXES MIGRATION 3 | -- ===================================================== 4 | -- This migration removes indexes that have been identified as unused through 5 | -- production monitoring. These indexes were consuming storage and slowing down 6 | -- writes without providing any query performance benefits. 7 | -- 8 | -- Analysis showed these indexes had 0 scans while the unique constraint indexes 9 | -- (uk_*) were handling all queries efficiently. 10 | -- ===================================================== 11 | 12 | -- ===================================================== 13 | -- 1. UNUSED PERFORMANCE INDEXES FROM critical_performance_indexes.sql 14 | -- ===================================================== 15 | 16 | -- Partial index with WHERE locked = true, but queries don't include this condition 17 | -- uk_locks_chat_type is handling all 72,907 queries efficiently 18 | DROP INDEX IF EXISTS idx_locks_chat_lock_lookup; 19 | 20 | -- Covering index not being used, queries are using uk_locks_chat_type instead 21 | DROP INDEX IF EXISTS idx_locks_covering; 22 | 23 | -- Partial index with WHERE user_id IS NOT NULL, but uk_users_user_id handles all queries 24 | -- This was consuming 5.4MB with 0 scans 25 | DROP INDEX IF EXISTS idx_users_user_id_active; 26 | 27 | -- Partial index with WHERE is_inactive = false, but queries don't include this condition 28 | -- uk_chats_chat_id is handling all 132,292 queries efficiently 29 | DROP INDEX IF EXISTS idx_chats_chat_id_active; 30 | 31 | -- Partial index with WHERE limit > 0, but queries don't include this condition 32 | -- idx_antiflood_settings_chat_id is handling all 16,685 queries efficiently 33 | DROP INDEX IF EXISTS idx_antiflood_chat_active; 34 | 35 | -- Optimized index not being used, uk_blacklists_chat_word handles all 12,930 queries 36 | DROP INDEX IF EXISTS idx_blacklists_chat_word_optimized; 37 | 38 | -- Update-specific index not being used for channel updates 39 | DROP INDEX IF EXISTS idx_channels_chat_update; 40 | 41 | -- Partial index with WHERE conditions for enabled greetings, but queries don't match 42 | DROP INDEX IF EXISTS idx_greetings_chat_enabled; 43 | 44 | -- Composite index with WHERE num_warns > 0, but queries don't include this condition 45 | -- uk_warns_users_user_chat is handling all 489 queries efficiently 46 | DROP INDEX IF EXISTS idx_warns_users_composite; 47 | 48 | -- Duplicate of uk_notes_chat_name which is being used for all 17 queries 49 | DROP INDEX IF EXISTS idx_notes_chat_name; 50 | 51 | -- Duplicate of idx_pins_chat_id which is being used for all 104 queries 52 | DROP INDEX IF EXISTS idx_pins_chat; 53 | 54 | -- Partial index for inactive chats, but queries don't filter by is_inactive 55 | DROP INDEX IF EXISTS idx_chats_inactive; 56 | 57 | -- GIN index for JSONB users field, consuming 400KB with 0 scans 58 | -- Not needed for current query patterns 59 | DROP INDEX IF EXISTS idx_chats_users_gin; 60 | 61 | -- ===================================================== 62 | -- 2. OTHER UNUSED INDEXES 63 | -- ===================================================== 64 | 65 | -- Not being used, idx_channels_chat_id handles all 340 queries 66 | DROP INDEX IF EXISTS idx_channels_channel_id; 67 | 68 | -- Connection queries using uk_connection_user_chat instead (36 queries) 69 | DROP INDEX IF EXISTS idx_connection_chat_id; 70 | 71 | -- Chat users queries not using this index 72 | DROP INDEX IF EXISTS idx_chat_users_user_id; 73 | 74 | -- ===================================================== 75 | -- INDEXES BEING KEPT (for reference) 76 | -- ===================================================== 77 | -- The following indexes ARE being used and should be kept: 78 | -- 79 | -- HEAVILY USED: 80 | -- - uk_users_user_id: 309,275 scans (primary user lookups) 81 | -- - uk_chats_chat_id: 132,292 scans (primary chat lookups) 82 | -- - uk_locks_chat_type: 72,907 scans (lock checks) 83 | -- - uk_filters_chat_keyword: 35,746 scans (filter lookups) 84 | -- - idx_antiflood_settings_chat_id: 16,685 scans 85 | -- - uk_blacklists_chat_word: 12,930 scans 86 | -- 87 | -- COVERING INDEXES THAT WORK: 88 | -- - idx_users_covering: 3,696 scans (optimized user queries) 89 | -- - idx_chats_covering: 1,263 scans (optimized chat queries) 90 | -- - idx_filters_chat_optimized: 16 scans 91 | -- 92 | -- These indexes are sufficient for current query patterns and are being 93 | -- efficiently utilized by the PostgreSQL query planner. 94 | 95 | -- ===================================================== 96 | -- SUMMARY 97 | -- ===================================================== 98 | -- Dropped: 16 unused indexes 99 | -- Storage freed: ~6MB 100 | -- Expected impact: Faster writes, no change to read performance 101 | -- ===================================================== -------------------------------------------------------------------------------- /alita/modules/language.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "strings" 5 | 6 | log "github.com/sirupsen/logrus" 7 | 8 | "github.com/PaulSonOfLars/gotgbot/v2" 9 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 10 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" 11 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery" 12 | 13 | "github.com/divkix/Alita_Robot/alita/db" 14 | "github.com/divkix/Alita_Robot/alita/i18n" 15 | "github.com/divkix/Alita_Robot/alita/utils/chat_status" 16 | "github.com/divkix/Alita_Robot/alita/utils/helpers" 17 | ) 18 | 19 | var languagesModule = moduleStruct{moduleName: "Languages"} 20 | 21 | // genFullLanguageKb generates the complete language selection keyboard. 22 | // Creates inline buttons for all available languages plus a translation contribution link. 23 | func (moduleStruct) genFullLanguageKb() [][]gotgbot.InlineKeyboardButton { 24 | keyboard := helpers.MakeLanguageKeyboard() 25 | keyboard = append( 26 | keyboard, 27 | []gotgbot.InlineKeyboardButton{ 28 | { 29 | Text: "Help Us Translate 🌎", // This can stay hardcoded as it's a universal call to action 30 | Url: "https://crowdin.com/project/alita_robot", 31 | }, 32 | }, 33 | ) 34 | return keyboard 35 | } 36 | 37 | // changeLanguage displays the language selection menu for users or groups. 38 | // Shows current language and allows users/admins to select a different interface language. 39 | func (m moduleStruct) changeLanguage(b *gotgbot.Bot, ctx *ext.Context) error { 40 | user := ctx.EffectiveSender.User 41 | chat := ctx.EffectiveChat 42 | msg := ctx.EffectiveMessage 43 | 44 | var replyString string 45 | 46 | cLang := db.GetLanguage(ctx) 47 | tr := i18n.MustNewTranslator(cLang) 48 | 49 | if ctx.Message.Chat.Type == "private" { 50 | replyString, _ = tr.GetString("language_current_user", i18n.TranslationParams{"s": helpers.GetLangFormat(cLang)}) 51 | } else { 52 | 53 | // language won't be changed if user is not admin 54 | if !chat_status.RequireUserAdmin(b, ctx, chat, user.Id, false) { 55 | return ext.EndGroups 56 | } 57 | 58 | replyString, _ = tr.GetString("language_current_group", i18n.TranslationParams{"s": helpers.GetLangFormat(cLang)}) 59 | } 60 | 61 | _, err := msg.Reply( 62 | b, 63 | replyString, 64 | &gotgbot.SendMessageOpts{ 65 | ReplyMarkup: gotgbot.InlineKeyboardMarkup{ 66 | InlineKeyboard: m.genFullLanguageKb(), 67 | }, 68 | }, 69 | ) 70 | if err != nil { 71 | log.Error(err) 72 | return err 73 | } 74 | 75 | return ext.EndGroups 76 | } 77 | 78 | // langBtnHandler processes language selection callback queries from the language menu. 79 | // Updates user or group language preferences based on admin permissions and context. 80 | func (moduleStruct) langBtnHandler(b *gotgbot.Bot, ctx *ext.Context) error { 81 | query := ctx.CallbackQuery 82 | chat := ctx.EffectiveChat 83 | user := query.From 84 | 85 | var replyString string 86 | language := strings.Split(query.Data, ".")[1] 87 | 88 | tr := i18n.MustNewTranslator(language) 89 | if chat.Type == "private" { 90 | db.ChangeUserLanguage(user.Id, language) 91 | replyString, _ = tr.GetString("language_changed_user", i18n.TranslationParams{"s": helpers.GetLangFormat(language)}) 92 | } else { 93 | // Check if user is admin before changing group language 94 | if !chat_status.RequireUserAdmin(b, ctx, chat, user.Id, false) { 95 | // Use current language for error message since we can't change it 96 | currentLang := db.GetLanguage(ctx) 97 | tr := i18n.MustNewTranslator(currentLang) 98 | replyString, _ = tr.GetString("language_admin_required", i18n.TranslationParams{}) 99 | if replyString == "" { 100 | replyString = "You need to be an admin to change the group language." 101 | } 102 | } else { 103 | db.ChangeGroupLanguage(chat.Id, language) 104 | replyString, _ = tr.GetString("language_changed_group", i18n.TranslationParams{"s": helpers.GetLangFormat(language)}) 105 | } 106 | } 107 | 108 | _, _, err := query.Message.EditText( 109 | b, 110 | replyString, 111 | &gotgbot.EditMessageTextOpts{ 112 | ParseMode: helpers.HTML, 113 | LinkPreviewOptions: &gotgbot.LinkPreviewOptions{ 114 | IsDisabled: true, 115 | }, 116 | }, 117 | ) 118 | if err != nil { 119 | log.Error(err) 120 | return err 121 | } 122 | 123 | return ext.EndGroups 124 | } 125 | 126 | // LoadLanguage registers language-related command and callback handlers. 127 | // Sets up language selection commands and keyboard navigation for internationalization. 128 | func LoadLanguage(dispatcher *ext.Dispatcher) { 129 | HelpModule.AbleMap.Store(languagesModule.moduleName, true) 130 | HelpModule.helpableKb[languagesModule.moduleName] = languagesModule.genFullLanguageKb() 131 | 132 | dispatcher.AddHandler(handlers.NewCallback(callbackquery.Prefix("change_language."), languagesModule.langBtnHandler)) 133 | dispatcher.AddHandler(handlers.NewCommand("lang", languagesModule.changeLanguage)) 134 | } 135 | -------------------------------------------------------------------------------- /alita/db/connections_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // ToggleAllowConnect enables or disables connection functionality for a chat. 11 | func ToggleAllowConnect(chatID int64, pref bool) { 12 | err := UpdateRecordWithZeroValues(&ConnectionChatSettings{}, ConnectionChatSettings{ChatId: chatID}, ConnectionChatSettings{Enabled: pref}) 13 | if err != nil { 14 | log.Errorf("[Database] ToggleAllowConnect: %d - %v", chatID, err) 15 | } 16 | } 17 | 18 | // GetChatConnectionSetting retrieves connection settings for a chat. 19 | // Creates default settings (disabled) if not found. 20 | func GetChatConnectionSetting(chatID int64) (connectionSrc *ConnectionChatSettings) { 21 | connectionSrc = &ConnectionChatSettings{} 22 | err := GetRecord(connectionSrc, ConnectionChatSettings{ChatId: chatID}) 23 | if errors.Is(err, gorm.ErrRecordNotFound) { 24 | // Ensure chat exists in database before creating settings to satisfy foreign key constraint 25 | if err := EnsureChatInDb(chatID, ""); err != nil { 26 | log.Errorf("[Database] GetChatConnectionSetting: Failed to ensure chat exists for %d: %v", chatID, err) 27 | return &ConnectionChatSettings{ChatId: chatID, Enabled: false} 28 | } 29 | 30 | // Create default settings 31 | connectionSrc = &ConnectionChatSettings{ChatId: chatID, Enabled: false} 32 | err := CreateRecord(connectionSrc) 33 | if err != nil { 34 | log.Errorf("[Database] GetChatConnectionSetting: %d - %v", chatID, err) 35 | } 36 | } else if err != nil { 37 | // Return default on error 38 | connectionSrc = &ConnectionChatSettings{ChatId: chatID, Enabled: false} 39 | log.Errorf("[Database] GetChatConnectionSetting: %d - %v", chatID, err) 40 | } 41 | return connectionSrc 42 | } 43 | 44 | // getUserConnectionSetting retrieves connection settings for a user. 45 | // Creates default settings (not connected) if not found. 46 | func getUserConnectionSetting(userID int64) (connectionSrc *ConnectionSettings) { 47 | connectionSrc = &ConnectionSettings{} 48 | err := GetRecord(connectionSrc, ConnectionSettings{UserId: userID}) 49 | if errors.Is(err, gorm.ErrRecordNotFound) { 50 | // Create default settings 51 | connectionSrc = &ConnectionSettings{UserId: userID, Connected: false} 52 | err := CreateRecord(connectionSrc) 53 | if err != nil { 54 | log.Errorf("[Database] getUserConnectionSetting: %d - %v", userID, err) 55 | } 56 | } else if err != nil { 57 | // Return default on error 58 | connectionSrc = &ConnectionSettings{UserId: userID, Connected: false} 59 | log.Errorf("[Database] getUserConnectionSetting: %d - %v", userID, err) 60 | } 61 | 62 | return connectionSrc 63 | } 64 | 65 | // Connection returns the connection settings for a user. 66 | // This is a wrapper around getUserConnectionSetting. 67 | func Connection(UserID int64) *ConnectionSettings { 68 | return getUserConnectionSetting(UserID) 69 | } 70 | 71 | // ConnectId connects a user to a specific chat. 72 | // Sets the user's connection status to true and associates them with the chat. 73 | func ConnectId(UserID, chatID int64) { 74 | err := UpdateRecord(&ConnectionSettings{}, ConnectionSettings{UserId: UserID}, ConnectionSettings{Connected: true, ChatId: chatID}) 75 | if err != nil { 76 | log.Errorf("[Database] ConnectId: %v - %d", err, chatID) 77 | } 78 | } 79 | 80 | // DisconnectId disconnects a user from their current chat connection. 81 | // Sets the user's connection status to false. 82 | func DisconnectId(UserID int64) { 83 | err := UpdateRecordWithZeroValues(&ConnectionSettings{}, ConnectionSettings{UserId: UserID}, ConnectionSettings{Connected: false}) 84 | if err != nil { 85 | log.Errorf("[Database] DisconnectId: %v - %d", err, UserID) 86 | } 87 | } 88 | 89 | // ReconnectId reconnects a user to their previously connected chat. 90 | // Returns the chat ID the user was reconnected to, or 0 if an error occurs. 91 | func ReconnectId(UserID int64) int64 { 92 | connectionUpdate := Connection(UserID) 93 | err := UpdateRecord(&ConnectionSettings{}, ConnectionSettings{UserId: UserID}, ConnectionSettings{Connected: true}) 94 | if err != nil { 95 | log.Errorf("[Database] ReconnectId: %v - %d", err, UserID) 96 | return 0 97 | } 98 | return connectionUpdate.ChatId 99 | } 100 | 101 | // LoadConnectionStats returns statistics about connection usage. 102 | // Returns the count of connected users and chats that allow connections. 103 | func LoadConnectionStats() (connectedUsers, connectedChats int64) { 104 | // Count chats that allow connections 105 | err := DB.Model(&ConnectionChatSettings{}).Where("enabled = ?", true).Count(&connectedChats).Error 106 | if err != nil { 107 | log.Error(err) 108 | return 109 | } 110 | 111 | // Count connected users 112 | err = DB.Model(&ConnectionSettings{}).Where("connected = ?", true).Count(&connectedUsers).Error 113 | if err != nil { 114 | log.Error(err) 115 | return 116 | } 117 | 118 | return 119 | } 120 | -------------------------------------------------------------------------------- /alita/utils/keyword_matcher/matcher.go: -------------------------------------------------------------------------------- 1 | package keyword_matcher 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | "github.com/cloudflare/ahocorasick" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // KeywordMatcher provides efficient multi-pattern matching using Aho-Corasick algorithm 14 | type KeywordMatcher struct { 15 | matcher *ahocorasick.Matcher 16 | patterns []string 17 | mu sync.RWMutex 18 | lastBuild time.Time 19 | } 20 | 21 | // MatchResult contains information about a matched pattern 22 | type MatchResult struct { 23 | Pattern string // The original pattern that matched 24 | Start int // Start position of match in text 25 | End int // End position of match in text 26 | } 27 | 28 | // NewKeywordMatcher creates a new keyword matcher with the given patterns 29 | func NewKeywordMatcher(patterns []string) *KeywordMatcher { 30 | km := &KeywordMatcher{ 31 | patterns: make([]string, len(patterns)), 32 | } 33 | copy(km.patterns, patterns) 34 | km.build() 35 | return km 36 | } 37 | 38 | // build compiles the patterns into an Aho-Corasick matcher 39 | func (km *KeywordMatcher) build() { 40 | if len(km.patterns) == 0 { 41 | km.matcher = nil 42 | return 43 | } 44 | 45 | // Convert patterns to lowercase for case-insensitive matching 46 | lowerPatterns := make([]string, len(km.patterns)) 47 | for i, pattern := range km.patterns { 48 | lowerPatterns[i] = strings.ToLower(pattern) 49 | } 50 | 51 | km.matcher = ahocorasick.NewStringMatcher(lowerPatterns) 52 | km.lastBuild = time.Now() 53 | 54 | log.WithFields(log.Fields{ 55 | "patterns_count": len(km.patterns), 56 | "build_time": time.Since(km.lastBuild), 57 | }).Debug("Built Aho-Corasick matcher") 58 | } 59 | 60 | // FindMatches returns all matches in the given text 61 | func (km *KeywordMatcher) FindMatches(text string) []MatchResult { 62 | km.mu.RLock() 63 | defer km.mu.RUnlock() 64 | 65 | if km.matcher == nil { 66 | return nil 67 | } 68 | 69 | lowerText := strings.ToLower(text) 70 | matches := km.findMatchesWithPositions([]byte(lowerText)) 71 | 72 | if len(matches) == 0 { 73 | return nil 74 | } 75 | 76 | results := make([]MatchResult, 0, len(matches)) 77 | for _, match := range matches { 78 | if match.PatternIndex < len(km.patterns) { 79 | pattern := km.patterns[match.PatternIndex] 80 | results = append(results, MatchResult{ 81 | Pattern: pattern, 82 | Start: match.Start, 83 | End: match.End, 84 | }) 85 | } 86 | } 87 | 88 | return results 89 | } 90 | 91 | // matchInfo holds information about a match including position 92 | type matchInfo struct { 93 | PatternIndex int 94 | Start int 95 | End int 96 | } 97 | 98 | // findMatchesWithPositions finds all matches with their positions in the text 99 | // This implementation uses the Aho-Corasick matcher for efficient multi-pattern matching 100 | func (km *KeywordMatcher) findMatchesWithPositions(text []byte) []matchInfo { 101 | if len(text) == 0 || len(km.patterns) == 0 || km.matcher == nil { 102 | return nil 103 | } 104 | 105 | // Use the Aho-Corasick matcher to find all matches 106 | hits := km.matcher.Match(text) 107 | if len(hits) == 0 { 108 | return nil 109 | } 110 | 111 | var allMatches []matchInfo 112 | seen := make(map[string]bool) 113 | 114 | // Convert hits to matchInfo 115 | for _, hit := range hits { 116 | // hit contains the pattern index and end position 117 | patternIdx := hit 118 | if patternIdx >= len(km.patterns) { 119 | continue 120 | } 121 | 122 | pattern := strings.ToLower(km.patterns[patternIdx]) 123 | patternLen := len(pattern) 124 | 125 | // Find all occurrences of this pattern in the text 126 | textStr := string(text) 127 | lowerTextStr := strings.ToLower(textStr) 128 | searchStart := 0 129 | 130 | for { 131 | pos := strings.Index(lowerTextStr[searchStart:], pattern) 132 | if pos == -1 { 133 | break 134 | } 135 | 136 | actualPos := searchStart + pos 137 | key := fmt.Sprintf("%d:%d", patternIdx, actualPos) 138 | if !seen[key] { 139 | seen[key] = true 140 | allMatches = append(allMatches, matchInfo{ 141 | PatternIndex: patternIdx, 142 | Start: actualPos, 143 | End: actualPos + patternLen, 144 | }) 145 | } 146 | 147 | searchStart = actualPos + 1 148 | } 149 | } 150 | 151 | return allMatches 152 | } 153 | 154 | // HasMatch returns true if any pattern matches the text 155 | func (km *KeywordMatcher) HasMatch(text string) bool { 156 | km.mu.RLock() 157 | defer km.mu.RUnlock() 158 | 159 | if km.matcher == nil { 160 | return false 161 | } 162 | 163 | lowerText := strings.ToLower(text) 164 | hits := km.matcher.Match([]byte(lowerText)) 165 | return len(hits) > 0 166 | } 167 | 168 | // GetPatterns returns a copy of the current patterns 169 | func (km *KeywordMatcher) GetPatterns() []string { 170 | km.mu.RLock() 171 | defer km.mu.RUnlock() 172 | 173 | patterns := make([]string, len(km.patterns)) 174 | copy(patterns, km.patterns) 175 | return patterns 176 | } 177 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: "Tag to release (e.g., 1.2.3)" 11 | required: true 12 | type: string 13 | default: "1.0.0" 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | release-ci-checks: 20 | name: Release CI Checks 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | security-events: write 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: Set up Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version: stable 35 | cache: true 36 | 37 | - name: Run Gosec Security Scanner 38 | uses: securego/gosec@master 39 | with: 40 | args: -no-fail -fmt sarif -out gosec.sarif ./... 41 | 42 | - name: Upload Gosec SARIF 43 | if: hashFiles('gosec.sarif') != '' 44 | continue-on-error: true 45 | uses: github/codeql-action/upload-sarif@v3 46 | with: 47 | sarif_file: gosec.sarif 48 | 49 | - name: Run vulnerability check 50 | continue-on-error: true 51 | uses: golang/govulncheck-action@v1 52 | 53 | - name: Test release build 54 | run: | 55 | CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o alita_robot . 56 | if [ ! -f alita_robot ]; then 57 | echo "Build failed" 58 | exit 1 59 | fi 60 | echo "Build successful" 61 | 62 | goreleaser: 63 | name: Release with GoReleaser 64 | runs-on: ubuntu-latest 65 | needs: [release-ci-checks] 66 | permissions: 67 | contents: write 68 | packages: write 69 | id-token: write 70 | attestations: write 71 | steps: 72 | - name: Checkout code 73 | uses: actions/checkout@v4 74 | with: 75 | fetch-depth: 0 76 | 77 | - name: Set up Go 78 | uses: actions/setup-go@v5 79 | with: 80 | go-version: stable 81 | cache: true 82 | 83 | - name: Create and push tag 84 | if: github.event_name == 'workflow_dispatch' 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 87 | run: | 88 | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" 89 | git config --global user.name "github-actions[bot]" 90 | tag="${{ github.event.inputs.tag }}" 91 | 92 | if git rev-parse "$tag" >/dev/null 2>&1; then 93 | echo "Tag $tag already exists, skipping creation" 94 | else 95 | echo "Creating tag $tag" 96 | git tag -a "$tag" -m "Release $tag" 97 | git push origin "$tag" 98 | fi 99 | 100 | - name: Login to GitHub Container Registry 101 | uses: docker/login-action@v3 102 | with: 103 | registry: ghcr.io 104 | username: ${{ github.actor }} 105 | password: ${{ secrets.GITHUB_TOKEN }} 106 | 107 | - name: Run GoReleaser 108 | uses: goreleaser/goreleaser-action@v6 109 | with: 110 | version: latest 111 | args: release --clean 112 | env: 113 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 114 | 115 | - name: Upload release artifacts 116 | uses: actions/upload-artifact@v4 117 | with: 118 | name: release-artifacts 119 | path: dist/* 120 | retention-days: 1 121 | 122 | attest-artifacts: 123 | name: Attest Release Artifacts 124 | runs-on: ubuntu-latest 125 | needs: [goreleaser] 126 | permissions: 127 | id-token: write 128 | attestations: write 129 | contents: read 130 | steps: 131 | - name: Download release artifacts 132 | uses: actions/download-artifact@v4 133 | with: 134 | name: release-artifacts 135 | path: ./artifacts 136 | 137 | - name: Attest artifacts 138 | uses: actions/attest-build-provenance@v2 139 | with: 140 | subject-path: "./artifacts/*" 141 | 142 | post-release-scan: 143 | name: Post-Release Security Scan 144 | runs-on: ubuntu-latest 145 | needs: [goreleaser] 146 | if: always() && needs.goreleaser.result == 'success' 147 | permissions: 148 | contents: read 149 | security-events: write 150 | steps: 151 | - name: Scan Docker image with Trivy 152 | uses: aquasecurity/trivy-action@master 153 | with: 154 | image-ref: ghcr.io/divkix/alita_robot:latest 155 | format: table 156 | exit-code: 0 157 | severity: CRITICAL,HIGH 158 | 159 | - name: Release notification 160 | run: | 161 | echo "Release completed successfully!" 162 | echo "Artifacts published to GitHub Releases" 163 | echo "Docker images available at ghcr.io/divkix/alita_robot" 164 | -------------------------------------------------------------------------------- /alita/modules/users.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" 8 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/PaulSonOfLars/gotgbot/v2" 12 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 13 | 14 | "github.com/divkix/Alita_Robot/alita/db" 15 | "github.com/divkix/Alita_Robot/alita/utils/chat_status" 16 | "github.com/divkix/Alita_Robot/alita/utils/constants" 17 | "github.com/divkix/Alita_Robot/alita/utils/helpers" 18 | ) 19 | 20 | var ( 21 | usersModule = moduleStruct{ 22 | moduleName: "Users", 23 | handlerGroup: -1, 24 | } 25 | 26 | // Rate limiting for database updates 27 | // Maps user/chat ID to last update timestamp 28 | userUpdateCache = &sync.Map{} 29 | chatUpdateCache = &sync.Map{} 30 | channelUpdateCache = &sync.Map{} 31 | 32 | // Update intervals 33 | userUpdateInterval = constants.UserUpdateInterval 34 | chatUpdateInterval = constants.ChatUpdateInterval 35 | channelUpdateInterval = constants.ChannelUpdateInterval 36 | ) 37 | 38 | // shouldUpdate checks if enough time has passed since the last update 39 | // for rate limiting database operations to prevent excessive writes. 40 | func shouldUpdate(cache *sync.Map, id int64, interval time.Duration) bool { 41 | if lastUpdate, ok := cache.Load(id); ok { 42 | if time.Since(lastUpdate.(time.Time)) < interval { 43 | return false 44 | } 45 | } 46 | cache.Store(id, time.Now()) 47 | return true 48 | } 49 | 50 | // logUsers handles automatic user and chat tracking by updating 51 | // database records with rate limiting for all message events. 52 | func (moduleStruct) logUsers(bot *gotgbot.Bot, ctx *ext.Context) error { 53 | msg := ctx.EffectiveMessage 54 | chat := ctx.EffectiveChat 55 | user := ctx.EffectiveSender 56 | repliedMsg := msg.ReplyToMessage 57 | 58 | if user.IsAnonymousChannel() { 59 | // Only update if enough time has passed 60 | if shouldUpdate(channelUpdateCache, user.Id(), channelUpdateInterval) { 61 | log.Debugf("Updating channel %d in db", user.Id()) 62 | // update when users send a message 63 | go db.UpdateChannel( 64 | user.Id(), 65 | user.Name(), 66 | user.Username(), 67 | ) 68 | } 69 | } else { 70 | // Don't add user to chat entry 71 | if chat_status.RequireGroup(bot, ctx, chat, true) { 72 | // Update user in chat collection with rate limiting 73 | if shouldUpdate(chatUpdateCache, chat.Id, chatUpdateInterval) { 74 | go db.UpdateChat( 75 | chat.Id, 76 | chat.Title, 77 | user.Id(), 78 | ) 79 | } 80 | } 81 | 82 | // Only update user if enough time has passed 83 | if shouldUpdate(userUpdateCache, user.Id(), userUpdateInterval) { 84 | log.Debugf("Updating user %d in db", user.Id()) 85 | // update when users send a message 86 | go db.UpdateUser( 87 | user.Id(), 88 | user.Username(), 89 | user.Name(), 90 | ) 91 | } 92 | } 93 | 94 | // update if message is replied 95 | if repliedMsg != nil { 96 | if repliedMsg.GetSender().IsAnonymousChannel() { 97 | if shouldUpdate(channelUpdateCache, repliedMsg.GetSender().Id(), channelUpdateInterval) { 98 | log.Debugf("Updating channel %d in db", repliedMsg.GetSender().Id()) 99 | go db.UpdateChannel( 100 | repliedMsg.GetSender().Id(), 101 | repliedMsg.GetSender().Name(), 102 | repliedMsg.GetSender().Username(), 103 | ) 104 | } 105 | } else { 106 | if shouldUpdate(userUpdateCache, repliedMsg.GetSender().Id(), userUpdateInterval) { 107 | log.Debugf("Updating user %d in db", repliedMsg.GetSender().Id()) 108 | go db.UpdateUser( 109 | repliedMsg.GetSender().Id(), 110 | repliedMsg.GetSender().Username(), 111 | repliedMsg.GetSender().Name(), 112 | ) 113 | } 114 | } 115 | } 116 | 117 | // update if message is forwarded 118 | if msg.ForwardOrigin != nil { 119 | forwarded := msg.ForwardOrigin.MergeMessageOrigin() 120 | if forwarded.Chat != nil && forwarded.Chat.Type != "group" { 121 | if shouldUpdate(channelUpdateCache, forwarded.Chat.Id, channelUpdateInterval) { 122 | go db.UpdateChannel( 123 | forwarded.Chat.Id, 124 | forwarded.Chat.Title, 125 | forwarded.Chat.Username, 126 | ) 127 | } 128 | } else if forwarded.SenderUser != nil { 129 | // if chat type is not group 130 | if shouldUpdate(userUpdateCache, forwarded.SenderUser.Id, userUpdateInterval) { 131 | go db.UpdateUser( 132 | forwarded.SenderUser.Id, 133 | forwarded.SenderUser.Username, 134 | helpers.GetFullName( 135 | forwarded.SenderUser.FirstName, 136 | forwarded.SenderUser.LastName, 137 | ), 138 | ) 139 | } 140 | } 141 | } 142 | 143 | return ext.ContinueGroups 144 | } 145 | 146 | // LoadUsers registers the user logging handler with the dispatcher 147 | // to automatically track users and chats across all messages. 148 | func LoadUsers(dispatcher *ext.Dispatcher) { 149 | dispatcher.AddHandlerToGroup(handlers.NewMessage(message.All, usersModule.logUsers), usersModule.handlerGroup) 150 | } 151 | -------------------------------------------------------------------------------- /alita/db/reports_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | // GetChatReportSettings retrieves or creates default report settings for the specified chat. 11 | // Returns settings with reports enabled by default if no settings exist. 12 | func GetChatReportSettings(chatID int64) (reportsrc *ReportChatSettings) { 13 | reportsrc = &ReportChatSettings{} 14 | err := GetRecord(reportsrc, ReportChatSettings{ChatId: chatID}) 15 | if errors.Is(err, gorm.ErrRecordNotFound) { 16 | // Ensure chat exists in database before creating settings to satisfy foreign key constraint 17 | if err := EnsureChatInDb(chatID, ""); err != nil { 18 | log.Errorf("[Database] GetChatReportSettings: Failed to ensure chat exists for %d: %v", chatID, err) 19 | return &ReportChatSettings{ChatId: chatID, Enabled: true} 20 | } 21 | 22 | // Create default settings 23 | reportsrc = &ReportChatSettings{ChatId: chatID, Enabled: true} 24 | err := CreateRecord(reportsrc) 25 | if err != nil { 26 | log.Error(err) 27 | } 28 | } else if err != nil { 29 | // Return default on error 30 | reportsrc = &ReportChatSettings{ChatId: chatID, Enabled: true} 31 | log.Error(err) 32 | } 33 | return 34 | } 35 | 36 | // SetChatReportStatus updates the report feature status for the specified chat. 37 | // When disabled, users cannot report messages in this chat. 38 | func SetChatReportStatus(chatID int64, pref bool) { 39 | err := UpdateRecordWithZeroValues(&ReportChatSettings{}, ReportChatSettings{ChatId: chatID}, ReportChatSettings{Enabled: pref}) 40 | if err != nil { 41 | log.Error(err) 42 | } 43 | } 44 | 45 | // BlockReportUser adds a user to the chat's report block list. 46 | // Blocked users cannot send reports in the specified chat. 47 | // Does nothing if the user is already blocked. 48 | func BlockReportUser(chatId, userId int64) { 49 | settings := GetChatReportSettings(chatId) 50 | 51 | // Check if user is already blocked 52 | for _, blockedId := range settings.BlockedList { 53 | if blockedId == userId { 54 | return // User already blocked 55 | } 56 | } 57 | 58 | // Add user to blocked list 59 | settings.BlockedList = append(settings.BlockedList, userId) 60 | err := UpdateRecord(&ReportChatSettings{}, ReportChatSettings{ChatId: chatId}, ReportChatSettings{BlockedList: settings.BlockedList}) 61 | if err != nil { 62 | log.Errorf("[Database] BlockReportUser: %v", err) 63 | } 64 | } 65 | 66 | // UnblockReportUser removes a user from the chat's report block list. 67 | // Allows the previously blocked user to send reports again. 68 | func UnblockReportUser(chatId, userId int64) { 69 | settings := GetChatReportSettings(chatId) 70 | 71 | // Remove user from blocked list 72 | var newBlockedList Int64Array 73 | for _, blockedId := range settings.BlockedList { 74 | if blockedId != userId { 75 | newBlockedList = append(newBlockedList, blockedId) 76 | } 77 | } 78 | 79 | err := UpdateRecord(&ReportChatSettings{}, ReportChatSettings{ChatId: chatId}, ReportChatSettings{BlockedList: newBlockedList}) 80 | if err != nil { 81 | log.Errorf("[Database] UnblockReportUser: %v", err) 82 | } 83 | } 84 | 85 | // GetUserReportSettings retrieves or creates default report settings for the specified user. 86 | // Returns settings with reports enabled by default if no settings exist. 87 | func GetUserReportSettings(userId int64) (reportsrc *ReportUserSettings) { 88 | reportsrc = &ReportUserSettings{} 89 | err := GetRecord(reportsrc, ReportUserSettings{UserId: userId}) 90 | if errors.Is(err, gorm.ErrRecordNotFound) { 91 | // Create default settings 92 | reportsrc = &ReportUserSettings{UserId: userId, Enabled: true} 93 | err := CreateRecord(reportsrc) 94 | if err != nil { 95 | log.Error(err) 96 | } 97 | } else if err != nil { 98 | // Return default on error 99 | reportsrc = &ReportUserSettings{UserId: userId, Enabled: true} 100 | log.Error(err) 101 | } 102 | 103 | return 104 | } 105 | 106 | // SetUserReportSettings updates the global report preference for the specified user. 107 | // When disabled, the user won't receive any report notifications. 108 | func SetUserReportSettings(userID int64, pref bool) { 109 | err := UpdateRecordWithZeroValues(&ReportUserSettings{}, ReportUserSettings{UserId: userID}, ReportUserSettings{Enabled: pref}) 110 | if err != nil { 111 | log.Error(userID) 112 | } 113 | } 114 | 115 | // LoadReportStats returns statistics about report features across the system. 116 | // Returns the count of users and chats with reports enabled. 117 | func LoadReportStats() (uRCount, gRCount int64) { 118 | // Count users with reports enabled 119 | err := DB.Model(&ReportUserSettings{}).Where("enabled = ?", true).Count(&uRCount).Error 120 | if err != nil { 121 | log.Errorf("[Database] LoadReportStats (users): %v", err) 122 | } 123 | 124 | // Count chats with reports enabled 125 | err = DB.Model(&ReportChatSettings{}).Where("enabled = ?", true).Count(&gRCount).Error 126 | if err != nil { 127 | log.Errorf("[Database] LoadReportStats (chats): %v", err) 128 | } 129 | 130 | return 131 | } 132 | -------------------------------------------------------------------------------- /alita/main.go: -------------------------------------------------------------------------------- 1 | package alita 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "slices" 7 | "strings" 8 | "time" 9 | 10 | log "github.com/sirupsen/logrus" 11 | 12 | "github.com/divkix/Alita_Robot/alita/db" 13 | "github.com/divkix/Alita_Robot/alita/modules" 14 | 15 | "github.com/PaulSonOfLars/gotgbot/v2" 16 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 17 | 18 | "github.com/divkix/Alita_Robot/alita/utils/cache" 19 | "github.com/divkix/Alita_Robot/alita/utils/string_handling" 20 | ) 21 | 22 | // ResourceMonitor monitors system resources including memory usage and goroutine count. 23 | // It runs every 5 minutes, logging resource statistics and issuing warnings 24 | // when thresholds are exceeded (>1000 goroutines or >500MB memory usage). 25 | func ResourceMonitor() { 26 | ticker := time.NewTicker(5 * time.Minute) 27 | defer ticker.Stop() 28 | 29 | for range ticker.C { 30 | var m runtime.MemStats 31 | runtime.ReadMemStats(&m) 32 | 33 | numGoroutines := runtime.NumGoroutine() 34 | 35 | // Log metrics 36 | log.WithFields(log.Fields{ 37 | "goroutines": numGoroutines, 38 | "memory_mb": m.Alloc / 1024 / 1024, 39 | "sys_mb": m.Sys / 1024 / 1024, 40 | "gc_runs": m.NumGC, 41 | }).Info("Resource usage stats") 42 | 43 | // Warning thresholds 44 | if numGoroutines > 1000 { 45 | log.WithField("goroutines", numGoroutines).Warn("High goroutine count detected") 46 | } 47 | 48 | if m.Alloc/1024/1024 > 500 { // 500MB 49 | log.WithField("memory_mb", m.Alloc/1024/1024).Warn("High memory usage detected") 50 | } 51 | } 52 | } 53 | 54 | // ListModules returns a formatted string containing all loaded bot modules. 55 | // It retrieves the module names from the HelpModule.AbleMap, sorts them alphabetically, 56 | // and returns them as a comma-separated list wrapped in square brackets. 57 | func ListModules() string { 58 | modSlice := modules.HelpModule.AbleMap.LoadModules() 59 | slices.Sort(modSlice) 60 | return fmt.Sprintf("[%s]", strings.Join(modSlice, ", ")) 61 | } 62 | 63 | // InitialChecks performs essential initialization tasks before starting the bot. 64 | // It ensures the bot exists in the database, validates command aliases for duplicates, 65 | // initializes the cache system, and starts resource monitoring. 66 | // Returns an error if cache initialization fails. 67 | func InitialChecks(b *gotgbot.Bot) error { 68 | // Ensure bot exists in database (blocking - required for FK constraints) 69 | // This must complete before LoadModules to prevent race conditions with 70 | // foreign key constraints that reference the bot entry 71 | if err := db.EnsureBotInDb(b); err != nil { 72 | log.WithError(err).Error("Failed to ensure bot in database") 73 | // Continue anyway - non-fatal for basic operations 74 | } 75 | 76 | checkDuplicateAliases() 77 | 78 | // Initialize cache with proper error handling 79 | if err := cache.InitCache(); err != nil { 80 | return fmt.Errorf("failed to initialize cache: %w", err) 81 | } 82 | 83 | // Start resource monitoring 84 | go ResourceMonitor() 85 | return nil 86 | } 87 | 88 | // checkDuplicateAliases validates that no command aliases are duplicated across modules. 89 | // It collects all alternative help options from loaded modules and checks for duplicates. 90 | // The function terminates the program with a fatal error if duplicates are found. 91 | func checkDuplicateAliases() { 92 | var althelp []string 93 | 94 | for _, i := range modules.HelpModule.AltHelpOptions { 95 | althelp = append(althelp, i...) 96 | } 97 | 98 | duplicateAlias, val := string_handling.IsDuplicateInStringSlice(althelp) 99 | if val { 100 | log.Fatalf("Found duplicate alias: %s", duplicateAlias) 101 | } 102 | } 103 | 104 | // LoadModules loads all bot modules in the correct order using the provided dispatcher. 105 | // It initializes the help system, loads core functionality modules (admin, bans, filters, etc.), 106 | // and ensures the help module is loaded last to register all available commands. 107 | func LoadModules(dispatcher *ext.Dispatcher) { 108 | // Initialize Inner Map 109 | modules.HelpModule.AbleMap.Init() 110 | 111 | // Load this at last because it loads all the modules 112 | defer modules.LoadHelp(dispatcher) 113 | 114 | modules.LoadBotUpdates(dispatcher) 115 | modules.LoadAntispam(dispatcher) 116 | modules.LoadLanguage(dispatcher) 117 | modules.LoadAdmin(dispatcher) 118 | modules.LoadPin(dispatcher) 119 | modules.LoadMisc(dispatcher) 120 | modules.LoadBans(dispatcher) 121 | modules.LoadMutes(dispatcher) 122 | modules.LoadPurges(dispatcher) 123 | modules.LoadUsers(dispatcher) 124 | modules.LoadReports(dispatcher) 125 | modules.LoadDev(dispatcher) 126 | modules.LoadLocks(dispatcher) 127 | modules.LoadFilters(dispatcher) 128 | modules.LoadAntiflood(dispatcher) 129 | modules.LoadNotes(dispatcher) 130 | modules.LoadConnections(dispatcher) 131 | modules.LoadDisabling(dispatcher) 132 | modules.LoadRules(dispatcher) 133 | modules.LoadWarns(dispatcher) 134 | modules.LoadGreetings(dispatcher) 135 | modules.LoadCaptcha(dispatcher) 136 | modules.LoadBlacklists(dispatcher) 137 | modules.LoadMkdCmd(dispatcher) 138 | } 139 | -------------------------------------------------------------------------------- /alita/db/disable_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | 6 | "github.com/divkix/Alita_Robot/alita/utils/cache" 7 | "github.com/divkix/Alita_Robot/alita/utils/string_handling" 8 | ) 9 | 10 | // DisableCMD disables a command in a specific chat. 11 | // Creates a new disable setting record with disabled status set to true. 12 | // Invalidates cache to ensure consistency. 13 | func DisableCMD(chatID int64, cmd string) { 14 | // Create a new disable setting 15 | disableSetting := &DisableSettings{ 16 | ChatId: chatID, 17 | Command: cmd, 18 | Disabled: true, 19 | } 20 | 21 | err := CreateRecord(disableSetting) 22 | if err != nil { 23 | log.Errorf("[Database][DisableCMD]: %v", err) 24 | return 25 | } 26 | 27 | // Invalidate cache to ensure fresh data 28 | invalidateDisabledCommandsCache(chatID) 29 | } 30 | 31 | // EnableCMD enables a command in a specific chat. 32 | // Removes the disable setting record for the command. 33 | // Invalidates cache to ensure consistency. 34 | func EnableCMD(chatID int64, cmd string) { 35 | err := DB.Where("chat_id = ? AND command = ?", chatID, cmd).Delete(&DisableSettings{}).Error 36 | if err != nil { 37 | log.Errorf("[Database][EnableCMD]: %v", err) 38 | return 39 | } 40 | 41 | // Invalidate cache to ensure fresh data 42 | invalidateDisabledCommandsCache(chatID) 43 | } 44 | 45 | // GetChatDisabledCMDs retrieves all disabled commands for a chat. 46 | // Returns an empty slice if no disabled commands are found or on error. 47 | func GetChatDisabledCMDs(chatId int64) []string { 48 | var disableSettings []*DisableSettings 49 | err := GetRecords(&disableSettings, DisableSettings{ChatId: chatId, Disabled: true}) 50 | if err != nil { 51 | log.Errorf("[Database] GetChatDisabledCMDs: %v - %d", err, chatId) 52 | return []string{} 53 | } 54 | 55 | commands := make([]string, len(disableSettings)) 56 | for i, setting := range disableSettings { 57 | commands[i] = setting.Command 58 | } 59 | return commands 60 | } 61 | 62 | // GetChatDisabledCMDsCached retrieves all disabled commands for a chat with caching. 63 | // Uses cache with TTL to avoid database queries on every command check. 64 | func GetChatDisabledCMDsCached(chatId int64) []string { 65 | cacheKey := disabledCommandsCacheKey(chatId) 66 | result, err := getFromCacheOrLoad(cacheKey, CacheTTLDisabledCmds, func() ([]string, error) { 67 | return GetChatDisabledCMDs(chatId), nil 68 | }) 69 | if err != nil { 70 | log.Errorf("[Cache] Failed to get disabled commands from cache for chat %d: %v", chatId, err) 71 | return GetChatDisabledCMDs(chatId) // Fallback to direct DB query 72 | } 73 | return result 74 | } 75 | 76 | // IsCommandDisabled checks if a specific command is disabled in a chat. 77 | // Returns true if the command is in the chat's disabled commands list. 78 | // Uses cached version for better performance. 79 | func IsCommandDisabled(chatId int64, cmd string) bool { 80 | return string_handling.FindInStringSlice(GetChatDisabledCMDsCached(chatId), cmd) 81 | } 82 | 83 | // invalidateDisabledCommandsCache invalidates the disabled commands cache for a specific chat. 84 | func invalidateDisabledCommandsCache(chatID int64) { 85 | cacheKey := disabledCommandsCacheKey(chatID) 86 | if err := cache.Marshal.Delete(cache.Context, cacheKey); err != nil { 87 | log.Debugf("[Cache] Failed to invalidate disabled commands cache for chat %d: %v", chatID, err) 88 | } 89 | } 90 | 91 | // ToggleDel toggles the automatic deletion of disabled commands in a chat. 92 | // Updates the DeleteCommands setting for the chat. 93 | func ToggleDel(chatId int64, pref bool) { 94 | err := UpdateRecordWithZeroValues(&DisableChatSettings{}, DisableChatSettings{ChatId: chatId}, DisableChatSettings{DeleteCommands: pref}) 95 | if err != nil { 96 | log.Errorf("[Database] ToggleDel: %v", err) 97 | } 98 | } 99 | 100 | // ShouldDel checks if automatic command deletion is enabled for a chat. 101 | // Returns false if the setting is not found or on error. 102 | func ShouldDel(chatId int64) bool { 103 | var settings DisableChatSettings 104 | err := GetRecord(&settings, DisableChatSettings{ChatId: chatId}) 105 | if err != nil { 106 | log.Errorf("[Database] ShouldDel: %v", err) 107 | return false 108 | } 109 | return settings.DeleteCommands 110 | } 111 | 112 | // LoadDisableStats returns statistics about disabled commands. 113 | // Returns the total number of disabled commands and distinct chats using command disabling. 114 | func LoadDisableStats() (disabledCmds, disableEnabledChats int64) { 115 | // Count total disabled commands 116 | err := DB.Model(&DisableSettings{}).Where("disabled = ?", true).Count(&disabledCmds).Error 117 | if err != nil { 118 | log.Errorf("[Database] LoadDisableStats (commands): %v", err) 119 | return 0, 0 120 | } 121 | 122 | // Count distinct chats with disabled commands 123 | err = DB.Model(&DisableSettings{}).Where("disabled = ?", true).Distinct("chat_id").Count(&disableEnabledChats).Error 124 | if err != nil { 125 | log.Errorf("[Database] LoadDisableStats (chats): %v", err) 126 | return disabledCmds, 0 127 | } 128 | 129 | return 130 | } 131 | -------------------------------------------------------------------------------- /supabase/migrations/20250806094457_restore_foreign_key_indexes.sql: -------------------------------------------------------------------------------- 1 | -- ===================================================== 2 | -- Supabase Migration: Restore Foreign Key Indexes 3 | -- ===================================================== 4 | -- Migration Name: restore_foreign_key_indexes 5 | -- Description: Restores critical indexes for foreign key constraints that were 6 | -- mistakenly dropped in migration 20250806093839_drop_unused_indexes 7 | -- Date: 2025-08-06 8 | -- ===================================================== 9 | 10 | BEGIN; 11 | 12 | -- ===================================================== 13 | -- BACKGROUND: Why These Indexes Are Critical 14 | -- ===================================================== 15 | -- These indexes were incorrectly identified as "unused" because the application 16 | -- doesn't directly query them. However, PostgreSQL REQUIRES these indexes for: 17 | -- 1. Efficient CASCADE DELETE/UPDATE operations on parent tables 18 | -- 2. Foreign key constraint validation during INSERT/UPDATE 19 | -- 3. Preventing full table scans during referential integrity checks 20 | -- 21 | -- Without these indexes, operations like deleting a user or chat will cause 22 | -- PostgreSQL to perform full table scans on child tables, severely impacting performance. 23 | 24 | -- ===================================================== 25 | -- STEP 1: Restore Critical Foreign Key Indexes 26 | -- ===================================================== 27 | 28 | -- chat_users table: Index on user_id for fk_chat_users_user 29 | -- Required when deleting/updating users to efficiently find related chat_users 30 | CREATE INDEX IF NOT EXISTS idx_chat_users_user_id 31 | ON public.chat_users(user_id); 32 | 33 | -- connection table: Index on chat_id for fk_connection_chat 34 | -- Required when deleting/updating chats to efficiently find related connections 35 | CREATE INDEX IF NOT EXISTS idx_connection_chat_id 36 | ON public.connection(chat_id); 37 | 38 | -- ===================================================== 39 | -- STEP 2: Add Comments for Documentation 40 | -- ===================================================== 41 | 42 | COMMENT ON INDEX idx_chat_users_user_id IS 43 | 'Critical index for fk_chat_users_user foreign key - enables efficient CASCADE operations when deleting/updating users'; 44 | 45 | COMMENT ON INDEX idx_connection_chat_id IS 46 | 'Critical index for fk_connection_chat foreign key - enables efficient CASCADE operations when deleting/updating chats'; 47 | 48 | -- ===================================================== 49 | -- STEP 3: Update Table Statistics 50 | -- ===================================================== 51 | -- Ensure query planner has accurate statistics after index creation 52 | 53 | ANALYZE chat_users; 54 | ANALYZE connection; 55 | 56 | COMMIT; 57 | 58 | -- ===================================================== 59 | -- PERFORMANCE IMPACT 60 | -- ===================================================== 61 | -- These indexes will: 62 | -- 1. Dramatically improve DELETE/UPDATE performance on users and chats tables 63 | -- 2. Eliminate full table scans during foreign key constraint checks 64 | -- 3. Speed up CASCADE operations from O(n) to O(log n) 65 | -- 66 | -- Expected improvements: 67 | -- - CASCADE DELETE on users/chats: 10-1000x faster depending on table size 68 | -- - Foreign key validation: Near instant vs full table scan 69 | -- - Reduced database CPU usage during maintenance operations 70 | 71 | -- ===================================================== 72 | -- VERIFICATION QUERIES 73 | -- ===================================================== 74 | /* 75 | -- Verify indexes have been created 76 | SELECT 77 | schemaname, 78 | tablename, 79 | indexname, 80 | indexdef 81 | FROM pg_indexes 82 | WHERE schemaname = 'public' 83 | AND indexname IN ('idx_chat_users_user_id', 'idx_connection_chat_id') 84 | ORDER BY tablename, indexname; 85 | 86 | -- Check that foreign keys now have covering indexes 87 | SELECT 88 | conname AS constraint_name, 89 | conrelid::regclass AS table_name, 90 | a.attname AS column_name, 91 | confrelid::regclass AS foreign_table_name, 92 | af.attname AS foreign_column_name, 93 | EXISTS ( 94 | SELECT 1 95 | FROM pg_index i 96 | WHERE i.indrelid = c.conrelid 97 | AND conkey[1] = ANY(i.indkey) 98 | ) AS has_index 99 | FROM pg_constraint c 100 | JOIN pg_attribute a ON a.attnum = c.conkey[1] AND a.attrelid = c.conrelid 101 | JOIN pg_attribute af ON af.attnum = c.confkey[1] AND af.attrelid = c.confrelid 102 | WHERE c.contype = 'f' 103 | AND c.conrelid IN ('chat_users'::regclass, 'connection'::regclass) 104 | ORDER BY table_name, constraint_name; 105 | */ 106 | 107 | -- ===================================================== 108 | -- ROLLBACK INSTRUCTIONS 109 | -- ===================================================== 110 | -- If you need to rollback this migration (NOT RECOMMENDED): 111 | /* 112 | BEGIN; 113 | DROP INDEX IF EXISTS idx_chat_users_user_id; 114 | DROP INDEX IF EXISTS idx_connection_chat_id; 115 | COMMIT; 116 | 117 | WARNING: Removing these indexes will severely degrade performance! 118 | */ -------------------------------------------------------------------------------- /alita/i18n/manager.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/divkix/Alita_Robot/alita/utils/cache" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | var ( 13 | managerInstance *LocaleManager 14 | managerOnce sync.Once 15 | ) 16 | 17 | // GetManager returns the singleton LocaleManager instance. 18 | func GetManager() *LocaleManager { 19 | managerOnce.Do(func() { 20 | managerInstance = &LocaleManager{ 21 | viperCache: make(map[string]*viper.Viper), 22 | localeData: make(map[string][]byte), 23 | defaultLang: "en", 24 | } 25 | }) 26 | return managerInstance 27 | } 28 | 29 | // Initialize initializes the LocaleManager with the provided configuration. 30 | func (lm *LocaleManager) Initialize(fs *embed.FS, localePath string, config ManagerConfig) error { 31 | lm.mu.Lock() 32 | defer lm.mu.Unlock() 33 | 34 | // Prevent re-initialization 35 | if lm.localeFS != nil { 36 | return fmt.Errorf("locale manager already initialized") 37 | } 38 | 39 | lm.localeFS = fs 40 | lm.localePath = localePath 41 | lm.defaultLang = config.Loader.DefaultLanguage 42 | 43 | // Initialize cache if available 44 | if config.Cache.EnableCache && cache.Manager != nil { 45 | lm.cacheClient = cache.Manager 46 | } 47 | 48 | // Load all locale files 49 | if err := lm.loadLocaleFiles(); err != nil { 50 | if config.Loader.StrictMode { 51 | return NewI18nError("initialize", "", "", "failed to load locale files", err) 52 | } 53 | // In non-strict mode, log error but continue 54 | fmt.Printf("Warning: failed to load some locale files: %v\n", err) 55 | } 56 | 57 | // Validate default language exists 58 | if _, exists := lm.localeData[lm.defaultLang]; !exists { 59 | return NewI18nError("initialize", lm.defaultLang, "", "default language not found", ErrLocaleNotFound) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // GetTranslator returns a translator for the specified language. 66 | func (lm *LocaleManager) GetTranslator(langCode string) (*Translator, error) { 67 | lm.mu.RLock() 68 | defer lm.mu.RUnlock() 69 | 70 | if lm.localeFS == nil { 71 | return nil, NewI18nError("get_translator", langCode, "", "manager not initialized", ErrManagerNotInit) 72 | } 73 | 74 | // Check if language exists, fallback to default if not 75 | targetLang := langCode 76 | viperInstance, exists := lm.viperCache[langCode] 77 | if !exists { 78 | // Fallback to default language 79 | targetLang = lm.defaultLang 80 | viperInstance = lm.viperCache[lm.defaultLang] 81 | if viperInstance == nil { 82 | return nil, NewI18nError("get_translator", langCode, "", "default language viper not found", ErrLocaleNotFound) 83 | } 84 | } 85 | 86 | return &Translator{ 87 | langCode: targetLang, 88 | manager: lm, 89 | viper: viperInstance, 90 | cachePrefix: fmt.Sprintf("i18n:%s:", targetLang), 91 | }, nil 92 | } 93 | 94 | // GetAvailableLanguages returns a slice of all available language codes. 95 | func (lm *LocaleManager) GetAvailableLanguages() []string { 96 | lm.mu.RLock() 97 | defer lm.mu.RUnlock() 98 | 99 | languages := make([]string, 0, len(lm.localeData)) 100 | for langCode := range lm.localeData { 101 | languages = append(languages, langCode) 102 | } 103 | return languages 104 | } 105 | 106 | // IsLanguageSupported checks if a language is supported. 107 | func (lm *LocaleManager) IsLanguageSupported(langCode string) bool { 108 | lm.mu.RLock() 109 | defer lm.mu.RUnlock() 110 | 111 | _, exists := lm.localeData[langCode] 112 | return exists 113 | } 114 | 115 | // GetDefaultLanguage returns the default language code. 116 | func (lm *LocaleManager) GetDefaultLanguage() string { 117 | lm.mu.RLock() 118 | defer lm.mu.RUnlock() 119 | 120 | return lm.defaultLang 121 | } 122 | 123 | // ReloadLocales reloads all locale files (useful for development). 124 | func (lm *LocaleManager) ReloadLocales() error { 125 | lm.mu.Lock() 126 | defer lm.mu.Unlock() 127 | 128 | if lm.localeFS == nil { 129 | return NewI18nError("reload", "", "", "manager not initialized", ErrManagerNotInit) 130 | } 131 | 132 | // Clear existing caches 133 | lm.viperCache = make(map[string]*viper.Viper) 134 | lm.localeData = make(map[string][]byte) 135 | 136 | // Clear external cache if available 137 | // Note: This would clear all cache, not just i18n entries 138 | // In production, you might want to implement selective clearing 139 | // if lm.cacheClient != nil { 140 | // // TODO: Implement selective cache clearing 141 | // } 142 | 143 | return lm.loadLocaleFiles() 144 | } 145 | 146 | // GetStats returns statistics about the locale manager. 147 | func (lm *LocaleManager) GetStats() map[string]any { 148 | lm.mu.RLock() 149 | defer lm.mu.RUnlock() 150 | 151 | stats := map[string]any{ 152 | "total_languages": len(lm.localeData), 153 | "default_language": lm.defaultLang, 154 | "cache_enabled": lm.cacheClient != nil, 155 | "languages": lm.GetAvailableLanguages(), 156 | } 157 | 158 | // Add memory usage stats if needed 159 | totalSize := 0 160 | for _, data := range lm.localeData { 161 | totalSize += len(data) 162 | } 163 | stats["total_locale_data_size"] = totalSize 164 | 165 | return stats 166 | } 167 | -------------------------------------------------------------------------------- /alita/db/lang_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 5 | log "github.com/sirupsen/logrus" 6 | ) 7 | 8 | // GetLanguage determines the appropriate language for the current context. 9 | // Returns the user's language preference for private chats, or the group's language for group chats. 10 | // Defaults to "en" (English) if no preference is found. 11 | func GetLanguage(ctx *ext.Context) string { 12 | chat := ctx.EffectiveChat 13 | if chat == nil { 14 | // Fallback to default language if we can't determine chat context 15 | log.Warn("[GetLanguage] Unable to determine chat context, using default language") 16 | return "en" 17 | } 18 | 19 | if chat.Type == "private" { 20 | user := ctx.EffectiveSender.User 21 | if user == nil { 22 | return "en" 23 | } 24 | return getUserLanguage(user.Id) 25 | } 26 | return getGroupLanguage(chat.Id) 27 | } 28 | 29 | // getGroupLanguage retrieves the language preference for a specific group. 30 | // Uses caching to improve performance and defaults to "en" if no preference is set. 31 | func getGroupLanguage(GroupID int64) string { 32 | // Try to get from cache first 33 | cacheKey := chatLanguageCacheKey(GroupID) 34 | lang, err := getFromCacheOrLoad(cacheKey, CacheTTLLanguage, func() (string, error) { 35 | groupc := GetChatSettings(GroupID) 36 | if groupc.Language == "" { 37 | return "en", nil 38 | } 39 | return groupc.Language, nil 40 | }) 41 | if err != nil { 42 | return "en" 43 | } 44 | return lang 45 | } 46 | 47 | // getUserLanguage retrieves the language preference for a specific user. 48 | // Uses caching to improve performance and defaults to "en" if no preference is set. 49 | func getUserLanguage(UserID int64) string { 50 | // Try to get from cache first 51 | cacheKey := userLanguageCacheKey(UserID) 52 | lang, err := getFromCacheOrLoad(cacheKey, CacheTTLLanguage, func() (string, error) { 53 | userc := checkUserInfo(UserID) 54 | if userc == nil { 55 | return "en", nil 56 | } else if userc.Language == "" { 57 | return "en", nil 58 | } 59 | return userc.Language, nil 60 | }) 61 | if err != nil { 62 | return "en" 63 | } 64 | return lang 65 | } 66 | 67 | // ChangeUserLanguage updates the language preference for a specific user. 68 | // Creates the user with the specified language if they don't exist. 69 | // Does nothing if the language is already set to the specified value. 70 | // Invalidates the user language cache after successful update. 71 | func ChangeUserLanguage(UserID int64, lang string) { 72 | userc := checkUserInfo(UserID) 73 | if userc == nil { 74 | // Create new user with the specified language 75 | newUser := &User{ 76 | UserId: UserID, 77 | Language: lang, 78 | } 79 | err := DB.Create(newUser).Error 80 | if err != nil { 81 | log.Errorf("[Database] ChangeUserLanguage (create): %v - %d", err, UserID) 82 | return 83 | } 84 | // Invalidate both language cache and optimized query cache after create 85 | deleteCache(userLanguageCacheKey(UserID)) 86 | deleteCache(userCacheKey(UserID)) 87 | log.Infof("[Database] ChangeUserLanguage: created new user %d with language %s", UserID, lang) 88 | return 89 | } else if userc.Language == lang { 90 | return 91 | } 92 | 93 | err := UpdateRecord(&User{}, User{UserId: UserID}, User{Language: lang}) 94 | if err != nil { 95 | log.Errorf("[Database] ChangeUserLanguage: %v - %d", err, UserID) 96 | return 97 | } 98 | // Invalidate both language cache and optimized query cache after update 99 | deleteCache(userLanguageCacheKey(UserID)) 100 | deleteCache(userCacheKey(UserID)) 101 | log.Infof("[Database] ChangeUserLanguage: %d", UserID) 102 | } 103 | 104 | // ChangeGroupLanguage updates the language preference for a specific group. 105 | // Creates the chat with the specified language if it doesn't exist. 106 | // Does nothing if the language is already set to the specified value. 107 | // Invalidates both the group language and chat settings caches after successful update. 108 | func ChangeGroupLanguage(GroupID int64, lang string) { 109 | groupc := GetChatSettings(GroupID) 110 | 111 | // Check if chat exists (GetChatSettings returns empty struct if not found) 112 | if groupc.ChatId == 0 { 113 | // Create new chat with the specified language 114 | newChat := &Chat{ 115 | ChatId: GroupID, 116 | Language: lang, 117 | } 118 | err := DB.Create(newChat).Error 119 | if err != nil { 120 | log.Errorf("[Database] ChangeGroupLanguage (create): %v - %d", err, GroupID) 121 | return 122 | } 123 | // Invalidate all cache layers after create 124 | deleteCache(chatLanguageCacheKey(GroupID)) 125 | deleteCache(chatSettingsCacheKey(GroupID)) 126 | deleteCache(chatCacheKey(GroupID)) 127 | log.Infof("[Database] ChangeGroupLanguage: created new chat %d with language %s", GroupID, lang) 128 | return 129 | } else if groupc.Language == lang { 130 | return 131 | } 132 | 133 | err := UpdateRecord(&Chat{}, Chat{ChatId: GroupID}, Chat{Language: lang}) 134 | if err != nil { 135 | log.Errorf("[Database] ChangeGroupLanguage: %v - %d", err, GroupID) 136 | return 137 | } 138 | // Invalidate all cache layers after update 139 | deleteCache(chatLanguageCacheKey(GroupID)) 140 | deleteCache(chatSettingsCacheKey(GroupID)) // Also invalidate chat settings cache since language is part of it 141 | deleteCache(chatCacheKey(GroupID)) 142 | log.Infof("[Database] ChangeGroupLanguage: %d", GroupID) 143 | } 144 | -------------------------------------------------------------------------------- /supabase/migrations/20250814100001_drop_unused_chat_users_table.sql: -------------------------------------------------------------------------------- 1 | -- ===================================================== 2 | -- CHAT_USERS TABLE CLEANUP MIGRATION 3 | -- ===================================================== 4 | -- This migration removes the unused chat_users join table. 5 | -- 6 | -- Background: The Chat model has both: 7 | -- 1. A JSONB 'users' field (actively used by the application) 8 | -- 2. A many2many relationship via chat_users table (never used) 9 | -- 10 | -- The JSONB approach is preferred and actively maintained, 11 | -- while the join table remains empty and unused, creating 12 | -- maintenance overhead with no benefit. 13 | 14 | -- ===================================================== 15 | -- Step 1: Log current state for audit purposes 16 | -- ===================================================== 17 | DO $$ 18 | DECLARE 19 | table_exists BOOLEAN; 20 | row_count INTEGER; 21 | index_count INTEGER; 22 | BEGIN 23 | -- Check if table exists 24 | SELECT EXISTS ( 25 | SELECT 1 FROM information_schema.tables 26 | WHERE table_schema = 'public' 27 | AND table_name = 'chat_users' 28 | ) INTO table_exists; 29 | 30 | IF table_exists THEN 31 | -- Count rows in chat_users table 32 | EXECUTE 'SELECT COUNT(*) FROM chat_users' INTO row_count; 33 | 34 | -- Count indexes on chat_users table 35 | SELECT COUNT(*) INTO index_count 36 | FROM pg_indexes 37 | WHERE tablename = 'chat_users'; 38 | 39 | RAISE NOTICE 'chat_users table cleanup starting:'; 40 | RAISE NOTICE '- Table exists: %', table_exists; 41 | RAISE NOTICE '- Row count: %', row_count; 42 | RAISE NOTICE '- Index count: %', index_count; 43 | ELSE 44 | RAISE NOTICE 'chat_users table does not exist, migration not needed'; 45 | END IF; 46 | END $$; 47 | 48 | -- ===================================================== 49 | -- Step 2: Drop foreign key constraints first 50 | -- ===================================================== 51 | -- Drop any foreign key constraints pointing to or from chat_users 52 | DO $$ 53 | BEGIN 54 | -- Drop foreign key constraints if they exist 55 | IF EXISTS ( 56 | SELECT 1 FROM information_schema.table_constraints 57 | WHERE constraint_type = 'FOREIGN KEY' 58 | AND table_name = 'chat_users' 59 | ) THEN 60 | -- Drop constraints dynamically 61 | DECLARE 62 | constraint_record RECORD; 63 | BEGIN 64 | FOR constraint_record IN 65 | SELECT constraint_name 66 | FROM information_schema.table_constraints 67 | WHERE constraint_type = 'FOREIGN KEY' 68 | AND table_name = 'chat_users' 69 | LOOP 70 | EXECUTE 'ALTER TABLE chat_users DROP CONSTRAINT IF EXISTS ' || constraint_record.constraint_name; 71 | RAISE NOTICE 'Dropped constraint: %', constraint_record.constraint_name; 72 | END LOOP; 73 | END; 74 | END IF; 75 | END $$; 76 | 77 | -- ===================================================== 78 | -- Step 3: Drop all indexes on chat_users table 79 | -- ===================================================== 80 | DO $$ 81 | DECLARE 82 | index_record RECORD; 83 | BEGIN 84 | -- Drop all indexes on chat_users table 85 | FOR index_record IN 86 | SELECT indexname 87 | FROM pg_indexes 88 | WHERE tablename = 'chat_users' 89 | AND schemaname = 'public' 90 | LOOP 91 | EXECUTE 'DROP INDEX IF EXISTS ' || index_record.indexname; 92 | RAISE NOTICE 'Dropped index: %', index_record.indexname; 93 | END LOOP; 94 | END $$; 95 | 96 | -- ===================================================== 97 | -- Step 4: Drop the chat_users table 98 | -- ===================================================== 99 | DROP TABLE IF EXISTS chat_users CASCADE; 100 | 101 | -- ===================================================== 102 | -- Step 5: Clean up any remaining references in migrations table 103 | -- ===================================================== 104 | -- Note: We don't need to clean up migrations.go index mappings here 105 | -- as that will be done in a separate commit to the Go code 106 | 107 | -- ===================================================== 108 | -- Validation and logging 109 | -- ===================================================== 110 | DO $$ 111 | DECLARE 112 | table_exists BOOLEAN; 113 | remaining_indexes INTEGER; 114 | BEGIN 115 | -- Verify table was dropped 116 | SELECT EXISTS ( 117 | SELECT 1 FROM information_schema.tables 118 | WHERE table_schema = 'public' 119 | AND table_name = 'chat_users' 120 | ) INTO table_exists; 121 | 122 | -- Count any remaining indexes that reference chat_users 123 | SELECT COUNT(*) INTO remaining_indexes 124 | FROM pg_indexes 125 | WHERE tablename = 'chat_users'; 126 | 127 | RAISE NOTICE 'chat_users cleanup completed:'; 128 | RAISE NOTICE '- Table exists: %', table_exists; 129 | RAISE NOTICE '- Remaining indexes: %', remaining_indexes; 130 | 131 | IF NOT table_exists AND remaining_indexes = 0 THEN 132 | RAISE NOTICE 'Migration completed successfully!'; 133 | RAISE NOTICE 'Chat membership is now managed exclusively via JSONB users field'; 134 | ELSE 135 | RAISE WARNING 'Migration may not have completed as expected'; 136 | END IF; 137 | END $$; 138 | -------------------------------------------------------------------------------- /alita/db/cache_helpers.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/divkix/Alita_Robot/alita/utils/cache" 9 | "github.com/divkix/Alita_Robot/alita/utils/error_handling" 10 | "github.com/eko/gocache/lib/v4/store" 11 | log "github.com/sirupsen/logrus" 12 | "golang.org/x/sync/singleflight" 13 | ) 14 | 15 | const ( 16 | // Cache expiration times 17 | CacheTTLChatSettings = 30 * time.Minute 18 | CacheTTLLanguage = 1 * time.Hour 19 | CacheTTLFilterList = 30 * time.Minute 20 | CacheTTLBlacklist = 30 * time.Minute 21 | CacheTTLGreetings = 30 * time.Minute 22 | CacheTTLNotesList = 30 * time.Minute 23 | CacheTTLWarnSettings = 30 * time.Minute 24 | CacheTTLAntiflood = 30 * time.Minute 25 | CacheTTLDisabledCmds = 30 * time.Minute 26 | ) 27 | 28 | // Singleflight group for preventing cache stampede 29 | var ( 30 | cacheGroup singleflight.Group 31 | ) 32 | 33 | // Cache key generators with "alita:" prefix for better organization 34 | // chatSettingsCacheKey generates a cache key for chat settings. 35 | func chatSettingsCacheKey(chatID int64) string { 36 | return fmt.Sprintf("alita:chat_settings:%d", chatID) 37 | } 38 | 39 | // userLanguageCacheKey generates a cache key for user language settings. 40 | func userLanguageCacheKey(userID int64) string { 41 | return fmt.Sprintf("alita:user_lang:%d", userID) 42 | } 43 | 44 | // chatLanguageCacheKey generates a cache key for chat language settings. 45 | func chatLanguageCacheKey(chatID int64) string { 46 | return fmt.Sprintf("alita:chat_lang:%d", chatID) 47 | } 48 | 49 | // filterListCacheKey generates a cache key for chat filter lists. 50 | func filterListCacheKey(chatID int64) string { 51 | return fmt.Sprintf("alita:filter_list:%d", chatID) 52 | } 53 | 54 | // blacklistCacheKey generates a cache key for chat blacklist settings. 55 | func blacklistCacheKey(chatID int64) string { 56 | return fmt.Sprintf("alita:blacklist:%d", chatID) 57 | } 58 | 59 | // warnSettingsCacheKey generates a cache key for chat warning settings. 60 | func warnSettingsCacheKey(chatID int64) string { 61 | return fmt.Sprintf("alita:warn_settings:%d", chatID) 62 | } 63 | 64 | // disabledCommandsCacheKey generates a cache key for chat disabled commands. 65 | func disabledCommandsCacheKey(chatID int64) string { 66 | return fmt.Sprintf("alita:disabled_cmds:%d", chatID) 67 | } 68 | 69 | // getFromCacheOrLoad is a generic helper to get from cache or load from database with stampede protection. 70 | // Uses singleflight pattern with timeout to prevent cache stampede and goroutine accumulation. 71 | func getFromCacheOrLoad[T any](key string, ttl time.Duration, loader func() (T, error)) (T, error) { 72 | var result T 73 | 74 | if cache.Marshal == nil { 75 | // Cache not initialized, load directly 76 | return loader() 77 | } 78 | 79 | // Try to get from cache 80 | _, err := cache.Marshal.Get(cache.Context, key, &result) 81 | if err == nil { 82 | // Cache hit 83 | return result, nil 84 | } 85 | 86 | // Cache miss, use singleflight with timeout to prevent stampede and goroutine accumulation 87 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 88 | defer cancel() 89 | 90 | // Create a channel to handle timeout and singleflight result 91 | type sfResult struct { 92 | value any 93 | err error 94 | } 95 | resultChan := make(chan sfResult, 1) 96 | 97 | go func() { 98 | defer error_handling.RecoverFromPanic("getFromCacheOrLoad", "cache_helpers") 99 | v, err, _ := cacheGroup.Do(key, func() (any, error) { 100 | // Load from database 101 | data, loadErr := loader() 102 | if loadErr != nil { 103 | return data, loadErr 104 | } 105 | 106 | // Store in cache 107 | cacheErr := cache.Marshal.Set(cache.Context, key, data, store.WithExpiration(ttl)) 108 | if cacheErr != nil { 109 | log.Debugf("[Cache] Failed to set cache for key %s: %v", key, cacheErr) 110 | } 111 | 112 | return data, nil 113 | }) 114 | resultChan <- sfResult{value: v, err: err} 115 | }() 116 | 117 | select { 118 | case res := <-resultChan: 119 | if res.err != nil { 120 | return result, res.err 121 | } 122 | 123 | // Type assert the result with panic recovery 124 | func() { 125 | defer func() { 126 | if r := recover(); r != nil { 127 | log.Errorf("[Cache] Panic during type assertion for key %s: %v", key, r) 128 | // Set result to zero value on panic 129 | var zero T 130 | result = zero 131 | } 132 | }() 133 | 134 | if typedResult, ok := res.value.(T); ok { 135 | result = typedResult 136 | } else { 137 | // Type assertion failed 138 | log.Errorf("[Cache] Type assertion failed for key %s: expected %T, got %T", key, result, res.value) 139 | var zero T 140 | result = zero 141 | } 142 | }() 143 | 144 | return result, nil 145 | 146 | case <-ctx.Done(): 147 | // Timeout occurred, cleanup singleflight and return timeout error 148 | cacheGroup.Forget(key) 149 | return result, fmt.Errorf("cache load timeout for key %s: %w", key, ctx.Err()) 150 | } 151 | } 152 | 153 | // deleteCache is a helper to delete a value from cache. 154 | // Logs debug information if deletion fails but does not return errors. 155 | func deleteCache(key string) { 156 | if cache.Marshal == nil { 157 | return 158 | } 159 | 160 | err := cache.Marshal.Delete(cache.Context, key) 161 | if err != nil { 162 | log.Debugf("[Cache] Failed to delete cache for key %s: %v", key, err) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /alita/db/filters_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | // GetFiltersList retrieves a list of all filter keywords for a specific chat ID. 9 | // Uses caching to improve performance for frequently accessed data. 10 | // Returns an empty slice if no filters are found or an error occurs. 11 | func GetFiltersList(chatID int64) (allFilterWords []string) { 12 | // Try to get from cache first 13 | cacheKey := filterListCacheKey(chatID) 14 | result, err := getFromCacheOrLoad(cacheKey, CacheTTLFilterList, func() ([]string, error) { 15 | var results []*ChatFilters 16 | var filterWords []string 17 | err := GetRecords(&results, map[string]any{"chat_id": chatID}) 18 | if err != nil { 19 | log.Errorf("[Database] GetFiltersList: %v - %d", err, chatID) 20 | return []string{}, err 21 | } 22 | 23 | for _, j := range results { 24 | filterWords = append(filterWords, j.KeyWord) 25 | } 26 | return filterWords, nil 27 | }) 28 | if err != nil { 29 | return []string{} 30 | } 31 | return result 32 | } 33 | 34 | // DoesFilterExists checks whether a filter with the given keyword exists in the specified chat. 35 | // Performs a case-insensitive comparison of the keyword. 36 | // Returns false if the filter doesn't exist or an error occurs. 37 | // Uses LIMIT 1 optimization for better performance than COUNT. 38 | func DoesFilterExists(chatId int64, keyword string) bool { 39 | var filter ChatFilters 40 | err := DB.Where("chat_id = ? AND LOWER(keyword) = LOWER(?)", chatId, keyword).Take(&filter).Error 41 | if err != nil { 42 | if err == gorm.ErrRecordNotFound { 43 | return false 44 | } 45 | log.Errorf("[Database] DoesFilterExists: %v - %d", err, chatId) 46 | return false 47 | } 48 | return true 49 | } 50 | 51 | // AddFilter creates a new filter in the database for the specified chat. 52 | // Does nothing if a filter with the same keyword already exists. 53 | // Invalidates the filter list cache after successful addition. 54 | func AddFilter(chatID int64, keyWord, replyText, fileID string, buttons []Button, filtType int) { 55 | // Check if filter already exists using optimized query 56 | var existingFilter ChatFilters 57 | err := DB.Where("chat_id = ? AND keyword = ?", chatID, keyWord).Take(&existingFilter).Error 58 | if err != nil { 59 | if err != gorm.ErrRecordNotFound { 60 | log.Errorf("[Database][AddFilter] checking existence: %d - %v", chatID, err) 61 | return 62 | } 63 | // Filter doesn't exist, continue with creation 64 | } else { 65 | return // Filter already exists 66 | } 67 | 68 | // add the filter 69 | newFilter := ChatFilters{ 70 | ChatId: chatID, 71 | KeyWord: keyWord, 72 | FilterReply: replyText, 73 | MsgType: filtType, 74 | FileID: fileID, 75 | Buttons: ButtonArray(buttons), 76 | } 77 | 78 | err = CreateRecord(&newFilter) 79 | if err != nil { 80 | log.Errorf("[Database][AddFilter]: %d - %v", chatID, err) 81 | return 82 | } 83 | 84 | // Invalidate cache after adding filter 85 | deleteCache(filterListCacheKey(chatID)) 86 | } 87 | 88 | // RemoveFilter deletes a filter with the specified keyword from the chat. 89 | // Invalidates the filter list cache if a filter was successfully removed. 90 | func RemoveFilter(chatID int64, keyWord string) { 91 | // Directly attempt to delete the filter without checking existence first 92 | result := DB.Where("chat_id = ? AND keyword = ?", chatID, keyWord).Delete(&ChatFilters{}) 93 | if result.Error != nil { 94 | log.Errorf("[Database][RemoveFilter]: %d - %v", chatID, result.Error) 95 | return 96 | } 97 | // result.RowsAffected will be 0 if no filter was found, which is fine 98 | 99 | // Invalidate cache after removing filter 100 | if result.RowsAffected > 0 { 101 | deleteCache(filterListCacheKey(chatID)) 102 | } 103 | } 104 | 105 | // RemoveAllFilters deletes all filters for the specified chat ID from the database. 106 | // Invalidates the filter list cache after removal. 107 | func RemoveAllFilters(chatID int64) { 108 | err := DB.Where("chat_id = ?", chatID).Delete(&ChatFilters{}).Error 109 | if err != nil { 110 | log.Errorf("[Database][RemoveAllFilters]: %d - %v", chatID, err) 111 | } 112 | 113 | // Invalidate cache after removing all filters 114 | deleteCache(filterListCacheKey(chatID)) 115 | } 116 | 117 | // CountFilters returns the total number of filters configured for the specified chat ID. 118 | // Returns 0 if an error occurs during the count operation. 119 | func CountFilters(chatID int64) (filtersNum int64) { 120 | err := DB.Model(&ChatFilters{}).Where("chat_id = ?", chatID).Count(&filtersNum).Error 121 | if err != nil { 122 | log.Errorf("[Database][CountFilters]: %d - %v", chatID, err) 123 | } 124 | return 125 | } 126 | 127 | // LoadFilterStats returns statistics about filters across the entire system. 128 | // Returns the total number of filters and the number of distinct chats using filters. 129 | func LoadFilterStats() (filtersNum, filtersUsingChats int64) { 130 | // Count total number of filters 131 | err := DB.Model(&ChatFilters{}).Count(&filtersNum).Error 132 | if err != nil { 133 | log.Errorf("[Database][LoadFilterStats] counting filters: %v", err) 134 | return 135 | } 136 | 137 | // Count distinct chats using filters 138 | err = DB.Model(&ChatFilters{}).Select("COUNT(DISTINCT chat_id)").Scan(&filtersUsingChats).Error 139 | if err != nil { 140 | log.Errorf("[Database][LoadFilterStats] counting chats: %v", err) 141 | return 142 | } 143 | 144 | return 145 | } 146 | -------------------------------------------------------------------------------- /scripts/migrate/README.md: -------------------------------------------------------------------------------- 1 | # MongoDB to PostgreSQL Migration Tool 2 | 3 | This tool migrates data from MongoDB to PostgreSQL for the Alita Robot project. 4 | 5 | ## Features 6 | 7 | - Batch processing for efficient migration of large datasets 8 | - Progress tracking and statistics 9 | - Dry-run mode for testing without writing data 10 | - Upsert support to handle existing data 11 | - Comprehensive error handling and logging 12 | - Support for all Alita Robot collections 13 | 14 | ## Prerequisites 15 | 16 | 1. MongoDB instance with existing data 17 | 2. PostgreSQL database with schema already created (run the migration SQL first) 18 | 3. Go 1.21 or higher 19 | 20 | ## Installation 21 | 22 | ```bash 23 | cd cmd/migrate 24 | go build -o migrate 25 | ``` 26 | 27 | ## Configuration 28 | 29 | Create a `.env` file based on `.env.example`: 30 | 31 | ```env 32 | # MongoDB Configuration 33 | MONGO_URI=mongodb://localhost:27017 34 | MONGO_DATABASE=alita 35 | 36 | # PostgreSQL Configuration 37 | DATABASE_URL=postgres://user:password@localhost:5432/alita_db?sslmode=disable 38 | ``` 39 | 40 | ## Usage 41 | 42 | ### Basic Migration 43 | 44 | ```bash 45 | ./migrate 46 | ``` 47 | 48 | ### With Command-Line Flags 49 | 50 | ```bash 51 | ./migrate \ 52 | -mongo-uri="mongodb://localhost:27017" \ 53 | -mongo-db="alita" \ 54 | -postgres-dsn="postgres://user:pass@localhost/alita_db" \ 55 | -batch-size=500 \ 56 | -verbose 57 | ``` 58 | 59 | ### Dry Run (Test Mode) 60 | 61 | ```bash 62 | ./migrate -dry-run 63 | ``` 64 | 65 | This will simulate the migration without writing any data to PostgreSQL. 66 | 67 | ## Command-Line Options 68 | 69 | - `-mongo-uri`: MongoDB connection URI 70 | - `-mongo-db`: MongoDB database name (default: "alita") 71 | - `-postgres-dsn`: PostgreSQL connection DSN 72 | - `-batch-size`: Number of records to process in each batch (default: 1000) 73 | - `-dry-run`: Perform a dry run without writing to PostgreSQL 74 | - `-verbose`: Enable verbose logging 75 | 76 | ## Migration Process 77 | 78 | The tool migrates the following collections: 79 | 80 | 1. **users** - User information and preferences 81 | 2. **chats** - Chat groups and their metadata 82 | 3. **admin** - Admin settings per chat 83 | 4. **notes_settings** - Note configuration per chat 84 | 5. **notes** - Saved notes and messages 85 | 6. **filters** - Keyword filters and auto-responses 86 | 7. **greetings** - Welcome and goodbye messages 87 | 8. **locks** - Permission locks and restrictions 88 | 9. **pins** - Pin settings per chat 89 | 10. **rules** - Chat rules 90 | 11. **warns_settings** - Warning system configuration 91 | 12. **warns_users** - User warnings 92 | 13. **antiflood_settings** - Anti-flood configuration 93 | 14. **blacklists** - Blacklisted words 94 | 15. **channels** - Linked channels 95 | 16. **connection** - User-chat connections 96 | 17. **connection_settings** - Connection configuration 97 | 18. **disable** - Disabled commands per chat 98 | 19. **report_user_settings** - User report settings 99 | 20. **report_chat_settings** - Chat report settings 100 | 101 | ## Data Transformations 102 | 103 | The migration handles several data transformations: 104 | 105 | - **Nested documents** are flattened (e.g., greetings.welcome_settings) 106 | - **Arrays** are converted to JSONB (e.g., warns, chat users) or expanded to individual rows (e.g., blacklist triggers) 107 | - **MongoDB Long types** are converted to PostgreSQL bigint 108 | - **Missing fields** are handled with appropriate defaults 109 | - **Permissions/Restrictions** in locks are expanded to individual rows 110 | - **Blacklist triggers** are expanded from a single array to individual word entries 111 | 112 | ## Special Considerations 113 | 114 | 1. **Chat Users**: The `chats.users` array is migrated to both a JSONB column and a separate `chat_users` junction table 115 | 2. **Locks**: Permissions and restrictions are expanded from nested objects to individual lock_type rows 116 | 3. **Disable**: Commands array is expanded to individual command rows 117 | 4. **Blacklists**: The `triggers` array is expanded so each blacklisted word becomes a separate row in PostgreSQL 118 | 119 | ## Error Handling 120 | 121 | - Failed records are logged but don't stop the migration 122 | - Each collection is migrated independently 123 | - Statistics show success/failure counts 124 | - Detailed error messages are provided for debugging 125 | 126 | ## Post-Migration 127 | 128 | After migration: 129 | 130 | 1. Verify row counts match between MongoDB and PostgreSQL 131 | 2. Test the application with the migrated data 132 | 3. Update PostgreSQL sequences if needed 133 | 4. Consider creating additional indexes for performance 134 | 135 | ## Troubleshooting 136 | 137 | ### Connection Issues 138 | 139 | - Ensure MongoDB is accessible and running 140 | - Verify PostgreSQL credentials and database exists 141 | - Check network connectivity between the tool and databases 142 | 143 | ### Data Issues 144 | 145 | - Run with `-verbose` flag for detailed logging 146 | - Use `-dry-run` to test without writing data 147 | - Check the error logs for specific record failures 148 | 149 | ### Performance 150 | 151 | - Adjust `-batch-size` based on your system resources 152 | - Smaller batches use less memory but take longer 153 | - Larger batches are faster but require more memory 154 | 155 | ## Development 156 | 157 | To modify the migration: 158 | 159 | 1. Update models in `models.go` for data structures 160 | 2. Modify migration functions in `migrations.go` for transformation logic 161 | 3. Adjust batch processing in `main.go` for performance tuning -------------------------------------------------------------------------------- /alita/db/chats_db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | log "github.com/sirupsen/logrus" 9 | "gorm.io/gorm" 10 | 11 | "github.com/divkix/Alita_Robot/alita/utils/string_handling" 12 | ) 13 | 14 | // GetChatSettings retrieves chat settings using optimized cached queries. 15 | // Returns an empty Chat struct if not found or on error. 16 | func GetChatSettings(chatId int64) (chatSrc *Chat) { 17 | // Use optimized cached query instead of SELECT * 18 | chat, err := GetOptimizedQueries().GetChatBasicInfoCached(chatId) 19 | if err != nil { 20 | if errors.Is(err, gorm.ErrRecordNotFound) { 21 | return &Chat{} 22 | } 23 | log.Errorf("[Database] GetChatSettings: %v - %d", err, chatId) 24 | return &Chat{} 25 | } 26 | return chat 27 | } 28 | 29 | // EnsureChatInDb ensures that a chat exists in the database. 30 | // Creates the chat record if it doesn't exist, or updates it if it does. 31 | // This is essential for foreign key constraints that reference the chats table. 32 | func EnsureChatInDb(chatId int64, chatName string) error { 33 | chatUpdate := &Chat{ 34 | ChatId: chatId, 35 | ChatName: chatName, 36 | } 37 | err := DB.Where("chat_id = ?", chatId).Assign(chatUpdate).FirstOrCreate(&Chat{}).Error 38 | if err != nil { 39 | log.Errorf("[Database] EnsureChatInDb: %v", err) 40 | return fmt.Errorf("failed to ensure chat %d in database: %w", chatId, err) 41 | } 42 | return nil 43 | } 44 | 45 | // UpdateChat updates or creates a chat record with the given information. 46 | // Adds user to the chat's user list if not already present, marks chat as active, 47 | // and updates the last activity timestamp to track when messages are received. 48 | func UpdateChat(chatId int64, chatname string, userid int64) { 49 | chatr := GetChatSettings(chatId) 50 | foundUser := string_handling.FindInInt64Slice(chatr.Users, userid) 51 | now := time.Now() 52 | 53 | // Always update last_activity to track message activity 54 | if chatr.ChatName == chatname && foundUser { 55 | // Only update last_activity and is_inactive 56 | err := DB.Model(&Chat{}).Where("chat_id = ?", chatId).Updates(map[string]any{ 57 | "last_activity": now, 58 | "is_inactive": false, 59 | }).Error 60 | if err != nil { 61 | log.Errorf("[Database] UpdateChat (activity only): %d - %v", chatId, err) 62 | } 63 | // Invalidate cache after update 64 | deleteCache(chatCacheKey(chatId)) 65 | return 66 | } 67 | 68 | // Prepare updates for all fields 69 | updates := make(map[string]any) 70 | if chatr.ChatName != chatname { 71 | updates["chat_name"] = chatname 72 | } 73 | if !foundUser { 74 | newUsers := chatr.Users 75 | newUsers = append(newUsers, userid) 76 | updates["users"] = newUsers 77 | } 78 | updates["is_inactive"] = false 79 | updates["last_activity"] = now 80 | 81 | if chatr.ChatId == 0 { 82 | // Create new chat 83 | newChat := &Chat{ 84 | ChatId: chatId, 85 | ChatName: chatname, 86 | Users: Int64Array{userid}, 87 | IsInactive: false, 88 | LastActivity: now, 89 | } 90 | err := DB.Create(newChat).Error 91 | if err != nil { 92 | log.Errorf("[Database] UpdateChat: %v - %d (%d)", err, chatId, userid) 93 | return 94 | } 95 | } else if len(updates) > 0 { 96 | // Update existing chat with all changes 97 | err := DB.Model(&Chat{}).Where("chat_id = ?", chatId).Updates(updates).Error 98 | if err != nil { 99 | log.Errorf("[Database] UpdateChat: %v - %d (%d)", err, chatId, userid) 100 | return 101 | } 102 | } 103 | 104 | // Invalidate cache after update 105 | deleteCache(chatCacheKey(chatId)) 106 | log.Debugf("[Database] UpdateChat: %d", chatId) 107 | } 108 | 109 | // GetAllChats retrieves all chat records and returns them as a map indexed by chat ID. 110 | // Returns an empty map if an error occurs. 111 | func GetAllChats() map[int64]Chat { 112 | var ( 113 | chatArray []Chat 114 | chatMap = make(map[int64]Chat) 115 | ) 116 | err := DB.Find(&chatArray).Error 117 | if err != nil { 118 | log.Errorf("[Database] GetAllChats: %v", err) 119 | return chatMap 120 | } 121 | 122 | for _, i := range chatArray { 123 | chatMap[i.ChatId] = i 124 | } 125 | 126 | return chatMap 127 | } 128 | 129 | // LoadChatStats returns the count of active and inactive chats. 130 | // Active chats have is_inactive = false, inactive chats have is_inactive = true. 131 | func LoadChatStats() (activeChats, inactiveChats int) { 132 | var activeCount, inactiveCount int64 133 | 134 | // Count active chats 135 | err := DB.Model(&Chat{}).Where("is_inactive = ?", false).Count(&activeCount).Error 136 | if err != nil { 137 | log.Errorf("[Database][LoadChatStats] counting active chats: %v", err) 138 | } 139 | 140 | // Count inactive chats 141 | err = DB.Model(&Chat{}).Where("is_inactive = ?", true).Count(&inactiveCount).Error 142 | if err != nil { 143 | log.Errorf("[Database][LoadChatStats] counting inactive chats: %v", err) 144 | } 145 | 146 | activeChats = int(activeCount) 147 | inactiveChats = int(inactiveCount) 148 | return 149 | } 150 | 151 | // LoadActivityStats returns Daily Active Groups, Weekly Active Groups, and Monthly Active Groups. 152 | // These metrics are based on last_activity timestamps within the respective time periods. 153 | func LoadActivityStats() (dag, wag, mag int64) { 154 | now := time.Now() 155 | dayAgo := now.Add(-24 * time.Hour) 156 | weekAgo := now.Add(-7 * 24 * time.Hour) 157 | monthAgo := now.Add(-30 * 24 * time.Hour) 158 | 159 | // Count daily active groups 160 | err := DB.Model(&Chat{}). 161 | Where("is_inactive = ? AND last_activity >= ?", false, dayAgo). 162 | Count(&dag).Error 163 | if err != nil { 164 | log.Errorf("[Database][LoadActivityStats] counting daily active groups: %v", err) 165 | } 166 | 167 | // Count weekly active groups 168 | err = DB.Model(&Chat{}). 169 | Where("is_inactive = ? AND last_activity >= ?", false, weekAgo). 170 | Count(&wag).Error 171 | if err != nil { 172 | log.Errorf("[Database][LoadActivityStats] counting weekly active groups: %v", err) 173 | } 174 | 175 | // Count monthly active groups 176 | err = DB.Model(&Chat{}). 177 | Where("is_inactive = ? AND last_activity >= ?", false, monthAgo). 178 | Count(&mag).Error 179 | if err != nil { 180 | log.Errorf("[Database][LoadActivityStats] counting monthly active groups: %v", err) 181 | } 182 | 183 | return dag, wag, mag 184 | } 185 | -------------------------------------------------------------------------------- /alita/utils/cache/adminCache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/PaulSonOfLars/gotgbot/v2" 11 | "github.com/eko/gocache/lib/v4/store" 12 | 13 | "github.com/divkix/Alita_Robot/alita/utils/constants" 14 | "github.com/divkix/Alita_Robot/alita/utils/error_handling" 15 | ) 16 | 17 | // LoadAdminCache retrieves and caches the list of administrators for a given chat. 18 | // It fetches the current administrators from Telegram API and stores them in cache 19 | // with a 30-minute expiration. Returns an AdminCache struct containing the admin list. 20 | func LoadAdminCache(b *gotgbot.Bot, chatId int64) AdminCache { 21 | if b == nil { 22 | log.Error("LoadAdminCache: bot is nil") 23 | return AdminCache{} 24 | } 25 | 26 | // Create context with timeout to prevent indefinite blocking 27 | ctx, cancel := context.WithTimeout(context.Background(), constants.DefaultTimeout) 28 | defer cancel() 29 | 30 | // First, check if bot itself is admin to diagnose permission issues 31 | botMember, botErr := b.GetChatMemberWithContext(ctx, chatId, b.Id, nil) 32 | if botErr != nil { 33 | log.WithFields(log.Fields{ 34 | "chatId": chatId, 35 | "botId": b.Id, 36 | "error": botErr, 37 | }).Warning("LoadAdminCache: Could not verify bot admin status") 38 | // If we can't even check bot status, likely not admin - return empty cache 39 | return AdminCache{ 40 | ChatId: chatId, 41 | UserInfo: []gotgbot.MergedChatMember{}, 42 | Cached: true, 43 | } 44 | } 45 | 46 | botStatus := botMember.GetStatus() 47 | if botStatus != "administrator" && botStatus != "creator" { 48 | return AdminCache{ 49 | ChatId: chatId, 50 | UserInfo: []gotgbot.MergedChatMember{}, 51 | Cached: true, 52 | } 53 | } 54 | 55 | log.WithFields(log.Fields{ 56 | "chatId": chatId, 57 | "botId": b.Id, 58 | "botStatus": botStatus, 59 | }).Debug("LoadAdminCache: Bot has admin privileges") 60 | 61 | // Retry logic for API call 62 | maxRetries := 3 63 | var adminList []gotgbot.ChatMember 64 | var err error 65 | 66 | for attempt := 0; attempt < maxRetries; attempt++ { 67 | adminList, err = b.GetChatAdministratorsWithContext(ctx, chatId, nil) 68 | if err != nil { 69 | log.WithFields(log.Fields{ 70 | "chatId": chatId, 71 | "error": err, 72 | "attempt": attempt + 1, 73 | "errorType": fmt.Sprintf("%T", err), 74 | }).Warning("LoadAdminCache: Failed to get chat administrators, retrying...") 75 | 76 | if attempt < maxRetries-1 { 77 | time.Sleep(time.Duration(attempt+1) * time.Second) // Exponential backoff 78 | continue 79 | } 80 | 81 | log.WithFields(log.Fields{ 82 | "chatId": chatId, 83 | "error": err, 84 | }).Error("LoadAdminCache: Failed to get chat administrators after all retries") 85 | return AdminCache{} 86 | } 87 | break // Success 88 | } 89 | 90 | if len(adminList) == 0 { 91 | log.WithFields(log.Fields{ 92 | "chatId": chatId, 93 | }).Warning("LoadAdminCache: No administrators found - this is unusual for a valid group") 94 | // Empty admin list is unusual but not necessarily an error 95 | // Return empty cache but mark it as cached to avoid infinite retries 96 | return AdminCache{ 97 | ChatId: chatId, 98 | UserInfo: []gotgbot.MergedChatMember{}, 99 | Cached: true, 100 | } 101 | } 102 | 103 | // Convert ChatMember to MergedChatMember 104 | var userList []gotgbot.MergedChatMember 105 | for _, admin := range adminList { 106 | userList = append(userList, admin.MergeChatMember()) 107 | } 108 | 109 | adminCache := AdminCache{ 110 | ChatId: chatId, 111 | UserInfo: userList, 112 | Cached: true, 113 | } 114 | 115 | // Cache the admin list with retry on failure in background 116 | go func() { 117 | defer error_handling.RecoverFromPanic("LoadAdminCache.cacheRoutine", "adminCache") 118 | maxRetries := 3 119 | for i := range maxRetries { 120 | if err := Marshal.Set(Context, fmt.Sprintf("alita:adminCache:%d", chatId), adminCache, store.WithExpiration(constants.AdminCacheTTL)); err != nil { 121 | log.WithFields(log.Fields{ 122 | "chatId": chatId, 123 | "error": err, 124 | "retry": i + 1, 125 | }).Error("LoadAdminCache: Failed to cache admin list") 126 | 127 | if i < maxRetries-1 { 128 | time.Sleep(time.Second * 2) // Wait before retry 129 | continue 130 | } 131 | } else { 132 | log.WithFields(log.Fields{ 133 | "chatId": chatId, 134 | }).Debug("LoadAdminCache: Successfully cached admin list") 135 | break 136 | } 137 | } 138 | }() 139 | 140 | return adminCache 141 | } 142 | 143 | // GetAdminCacheList retrieves the cached administrator list for a specific chat. 144 | // Returns true and the AdminCache if found in cache, false and empty AdminCache if cache miss. 145 | func GetAdminCacheList(chatId int64) (bool, AdminCache) { 146 | gotAdminlist, err := Marshal.Get( 147 | Context, 148 | fmt.Sprintf("alita:adminCache:%d", chatId), 149 | new(AdminCache), 150 | ) 151 | if err != nil { 152 | log.WithFields(log.Fields{ 153 | "chatId": chatId, 154 | "error": err, 155 | }).Debug("GetAdminCacheList: Cache miss, will attempt fallback") 156 | return false, AdminCache{} 157 | } 158 | if gotAdminlist == nil { 159 | log.WithFields(log.Fields{ 160 | "chatId": chatId, 161 | }).Debug("GetAdminCacheList: Cache empty, will attempt fallback") 162 | return false, AdminCache{} 163 | } 164 | return true, *gotAdminlist.(*AdminCache) 165 | } 166 | 167 | // GetAdminCacheUser searches for a specific user in the cached administrator list of a chat. 168 | // Returns true and the MergedChatMember if the user is found as an admin, 169 | // false and empty MergedChatMember if not found or cache miss. 170 | func GetAdminCacheUser(chatId, userId int64) (bool, gotgbot.MergedChatMember) { 171 | adminList, err := Marshal.Get(Context, AdminCache{ChatId: chatId}, new(AdminCache)) 172 | if err != nil || adminList == nil { 173 | return false, gotgbot.MergedChatMember{} 174 | } 175 | 176 | // Type assert with check to prevent panic 177 | adminCache, ok := adminList.(*AdminCache) 178 | if !ok || adminCache == nil { 179 | return false, gotgbot.MergedChatMember{} 180 | } 181 | 182 | for i := range adminCache.UserInfo { 183 | admin := &adminCache.UserInfo[i] 184 | if admin.User.Id == userId { 185 | return true, *admin 186 | } 187 | } 188 | return false, gotgbot.MergedChatMember{} 189 | } 190 | -------------------------------------------------------------------------------- /supabase/migrations/20250806093839_drop_unused_indexes.sql: -------------------------------------------------------------------------------- 1 | -- ===================================================== 2 | -- Supabase Migration: Drop Unused Indexes 3 | -- ===================================================== 4 | -- Migration Name: drop_unused_indexes 5 | -- Description: Removes indexes that have never been used by the application 6 | -- Date: 2025-08-06 7 | -- ===================================================== 8 | 9 | BEGIN; 10 | 11 | -- ===================================================== 12 | -- ANALYSIS: Why These Indexes Are Unused 13 | -- ===================================================== 14 | -- The application loads all records for a chat and processes them in memory 15 | -- rather than querying for specific keywords/words/names. 16 | -- This is actually more efficient for the bot's use case since: 17 | -- 1. It needs to check multiple patterns at once 18 | -- 2. Results are cached after loading 19 | -- 3. Avoids multiple round trips to the database 20 | 21 | -- ===================================================== 22 | -- STEP 1: Drop Unused Composite Indexes 23 | -- ===================================================== 24 | 25 | -- Blacklists: App uses GetRecords(&blacklists, {ChatId: chatId}) 26 | -- Never queries: WHERE chat_id = ? AND word = ? 27 | DROP INDEX IF EXISTS idx_blacklist_chat_word; 28 | 29 | -- Connection: Covered by uk_connection_user_chat unique constraint 30 | -- App queries use WHERE user_id = ? AND chat_id = ? which uses the unique index 31 | DROP INDEX IF EXISTS idx_connection_user_chat; 32 | 33 | -- Disable: App loads all disabled commands for a chat 34 | -- Never queries: WHERE chat_id = ? AND command = ? 35 | DROP INDEX IF EXISTS idx_disable_chat_command; 36 | 37 | -- Filters: App uses GetRecords(&filters, {ChatId: chatId}) 38 | -- Never queries: WHERE chat_id = ? AND keyword = ? 39 | DROP INDEX IF EXISTS idx_filters_chat_keyword; 40 | 41 | -- Locks: App loads all locks for a chat at once 42 | -- Never queries: WHERE chat_id = ? AND lock_type = ? 43 | DROP INDEX IF EXISTS idx_lock_chat_type; 44 | 45 | -- Notes: App loads all notes for a chat 46 | -- Never queries: WHERE chat_id = ? AND note_name = ? 47 | DROP INDEX IF EXISTS idx_notes_chat_name; 48 | 49 | -- Warns: Covered by uk_warns_users_user_chat unique constraint 50 | -- Duplicate of the unique constraint index 51 | DROP INDEX IF EXISTS idx_warns_user_chat; 52 | 53 | -- ===================================================== 54 | -- STEP 2: Drop Redundant Single-Column Indexes 55 | -- ===================================================== 56 | 57 | -- Connection table: uk_connection_user_chat(user_id, chat_id) already exists 58 | -- PostgreSQL can use leftmost column of composite index for single-column queries 59 | DROP INDEX IF EXISTS idx_connection_user_id; 60 | DROP INDEX IF EXISTS idx_connection_chat_id; 61 | 62 | -- Chat_users table: Primary key (chat_id, user_id) already provides indexing 63 | -- Additional single-column indexes are redundant 64 | DROP INDEX IF EXISTS idx_chat_users_user_id; 65 | DROP INDEX IF EXISTS idx_chat_users_chat_id; 66 | 67 | -- ===================================================== 68 | -- STEP 3: Update Table Statistics 69 | -- ===================================================== 70 | -- Ensure query planner has accurate statistics after index removal 71 | 72 | ANALYZE blacklists; 73 | ANALYZE connection; 74 | ANALYZE disable; 75 | ANALYZE filters; 76 | ANALYZE locks; 77 | ANALYZE notes; 78 | ANALYZE warns_users; 79 | ANALYZE chat_users; 80 | 81 | COMMIT; 82 | 83 | -- ===================================================== 84 | -- PERFORMANCE BENEFITS 85 | -- ===================================================== 86 | -- 1. Storage: Frees up space from 11 unused indexes 87 | -- 2. Write Performance: INSERT/UPDATE/DELETE operations will be faster 88 | -- 3. Maintenance: Reduced VACUUM and ANALYZE overhead 89 | -- 4. Memory: Less index metadata in buffer cache 90 | -- 91 | -- Estimated improvements: 92 | -- - INSERT operations: 5-10% faster 93 | -- - UPDATE operations: 10-15% faster 94 | -- - Storage saved: Several MB to GB depending on data size 95 | 96 | -- ===================================================== 97 | -- ROLLBACK INSTRUCTIONS 98 | -- ===================================================== 99 | -- If you need to restore these indexes (not recommended): 100 | /* 101 | BEGIN; 102 | 103 | -- Recreate composite indexes 104 | CREATE INDEX idx_blacklist_chat_word ON public.blacklists(chat_id, word); 105 | CREATE INDEX idx_connection_user_chat ON public.connection(user_id, chat_id); 106 | CREATE INDEX idx_disable_chat_command ON public.disable(chat_id, command); 107 | CREATE INDEX idx_filters_chat_keyword ON public.filters(chat_id, keyword); 108 | CREATE INDEX idx_lock_chat_type ON public.locks(chat_id, lock_type); 109 | CREATE INDEX idx_notes_chat_name ON public.notes(chat_id, note_name); 110 | CREATE INDEX idx_warns_user_chat ON public.warns_users(user_id, chat_id); 111 | 112 | -- Recreate single-column indexes 113 | CREATE INDEX idx_connection_user_id ON public.connection(user_id); 114 | CREATE INDEX idx_connection_chat_id ON public.connection(chat_id); 115 | CREATE INDEX idx_chat_users_user_id ON public.chat_users(user_id); 116 | CREATE INDEX idx_chat_users_chat_id ON public.chat_users(chat_id); 117 | 118 | COMMIT; 119 | */ 120 | 121 | -- ===================================================== 122 | -- VERIFICATION QUERIES 123 | -- ===================================================== 124 | /* 125 | -- Verify indexes have been dropped 126 | SELECT COUNT(*) as dropped_index_count 127 | FROM pg_indexes 128 | WHERE indexname IN ( 129 | 'idx_blacklist_chat_word', 130 | 'idx_connection_user_chat', 131 | 'idx_disable_chat_command', 132 | 'idx_filters_chat_keyword', 133 | 'idx_lock_chat_type', 134 | 'idx_notes_chat_name', 135 | 'idx_warns_user_chat', 136 | 'idx_connection_user_id', 137 | 'idx_connection_chat_id', 138 | 'idx_chat_users_user_id', 139 | 'idx_chat_users_chat_id' 140 | ); 141 | -- Should return 0 142 | 143 | -- Check remaining indexes on affected tables 144 | SELECT 145 | tablename, 146 | indexname, 147 | indexdef 148 | FROM pg_indexes 149 | WHERE tablename IN ('blacklists', 'connection', 'disable', 'filters', 150 | 'locks', 'notes', 'warns_users', 'chat_users') 151 | AND schemaname = 'public' 152 | ORDER BY tablename, indexname; 153 | 154 | -- Monitor index usage after migration (run after a few days) 155 | SELECT 156 | schemaname, 157 | tablename, 158 | indexname, 159 | idx_scan as times_used, 160 | idx_tup_read as rows_read, 161 | idx_tup_fetch as rows_fetched 162 | FROM pg_stat_user_indexes 163 | WHERE schemaname = 'public' 164 | AND idx_scan > 0 165 | ORDER BY idx_scan DESC; 166 | */ --------------------------------------------------------------------------------