├── .gitattributes ├── resources ├── embed.go ├── migrations │ ├── 1637265145-add-chats-settings.sql │ ├── 0-init.sql │ ├── 1677008076-add-settings.sql │ ├── 1-add_charade_scores.sql │ ├── 20250113000000-add-kv-store.sql │ ├── 1697008076-add-spam-tracking.sql │ ├── 2-add_users.sql │ ├── 1697008078-add-spam-votes.sql │ ├── 20241115000000-add-recent-joiners-and-banlist.sql │ ├── 1697008077-add-spam-cases.sql │ ├── 1687008076-tidy-settings-up.sql │ └── 20241112163044-update-channel-username-column.sql └── gatekeeper │ └── challenges │ ├── zh.yml │ ├── ko.yml │ ├── ja.yml │ ├── et.yml │ ├── hu.yml │ ├── nb.yml │ ├── tr.yml │ ├── cs.yml │ ├── da.yml │ ├── bg.yml │ ├── lt.yml │ ├── sk.yml │ ├── fi.yml │ ├── ro.yml │ ├── sl.yml │ ├── sv.yml │ ├── en.yml │ ├── id.yml │ ├── nl.yml │ ├── de.yml │ ├── fr.yml │ ├── pl.yml │ ├── es.yml │ ├── pt.yml │ ├── lv.yml │ ├── it.yml │ ├── be.yml │ ├── uk.yml │ ├── el.yml │ └── ru.yml ├── .gitignore ├── .dockerignore ├── .editorconfig ├── internal ├── errors │ └── errors.go ├── handlers │ ├── dependencies.go │ ├── base │ │ └── handler.go │ ├── admin │ │ └── admin.go │ └── moderation │ │ ├── spam_detector.go │ │ ├── ban_service.go │ │ └── spam_control.go ├── db │ ├── spam_tracking.go │ ├── dependencies.go │ ├── entities.go │ └── sqlite │ │ └── client.go ├── infra │ ├── health_check.go │ └── filesystem.go ├── adapters │ ├── llm │ │ ├── entities.go │ │ ├── openai │ │ │ └── openai.go │ │ └── gemini │ │ │ └── gemini.go │ └── llm.go ├── utils │ └── text │ │ └── cyrillics.go ├── event │ ├── bus.go │ └── worker.go ├── bot │ ├── dependencies.go │ ├── service.go │ └── update_processor.go ├── i18n │ └── i18n.go ├── config │ ├── log_formatter.go │ └── config.go ├── observability │ └── setup.go └── infrastructure │ └── telegram │ └── operations.go ├── Dockerfile ├── compose.yaml.dist ├── .env.example ├── project.md ├── go.mod ├── README.md ├── cmd └── ngbot │ └── main.go ├── AGENTS.md └── go.sum /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf encoding=utf-8 2 | 3 | *.gif binary 4 | *.jpg binary 5 | *.png binary -------------------------------------------------------------------------------- /resources/embed.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | //go:embed * 8 | var FS embed.FS 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ngbot 2 | !cmd/ngbot 3 | .*/ 4 | !**/*.go 5 | /.* 6 | !.env.example 7 | dbconfig.yml 8 | .env 9 | compose.yaml 10 | __debug* 11 | .qodo 12 | -------------------------------------------------------------------------------- /resources/migrations/1637265145-add-chats-settings.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | ALTER TABLE "chats" 3 | ADD COLUMN "settings" TEXT; 4 | 5 | -- +migrate Down 6 | -- nothing to do 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | compose.yaml 3 | Dockerfile 4 | ngbot 5 | .*/ 6 | 7 | 8 | .DS_Store* 9 | ._* 10 | .Spotlight-V100 11 | .Trashes 12 | ehthumbs.db 13 | Thumbs.db 14 | *.md 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | tab_width = 4 4 | trim_trailing_whitespace = false 5 | charset = utf-8 6 | end_of_line = lf 7 | 8 | [*.{yml, yaml}] 9 | tab_width = 2 10 | 11 | [*.go] 12 | indent_style = tab 13 | 14 | [*.{go, sql, html, tpl}] 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /resources/migrations/0-init.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS chats; 3 | CREATE TABLE IF NOT EXISTS "chats" 4 | ( 5 | "id" BIGINT NOT NULL, 6 | "title" TEXT NOT NULL, 7 | "language" TEXT, 8 | "type" TEXT, 9 | PRIMARY KEY ("id") 10 | ); 11 | 12 | -- +migrate Down 13 | DROP TABLE chats; 14 | -------------------------------------------------------------------------------- /resources/migrations/1677008076-add-settings.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | create table if not exists "meta" ( 3 | "id" bigint not null, 4 | "lang" text not null default 'en', 5 | primary key ("id") 6 | ); 7 | -- no data migration is meant to be done 8 | 9 | -- +migrate Down 10 | drop table if exists "meta"; 11 | -- nothing to do 12 | -------------------------------------------------------------------------------- /resources/migrations/1-add_charade_scores.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS charade_scores; 3 | CREATE TABLE IF NOT EXISTS "charade_scores" 4 | ( 5 | "user_id" INT NOT NULL, 6 | "chat_id" BIGINT NOT NULL, 7 | "score" INT NOT NULL, 8 | PRIMARY KEY ("user_id", "chat_id") 9 | ); 10 | 11 | -- +migrate Down 12 | DROP TABLE charade_scores; 13 | -------------------------------------------------------------------------------- /internal/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | // Common error types 8 | var ( 9 | ErrInvalidInput = errors.New("invalid input") 10 | ErrDatabaseError = errors.New("database error") 11 | ErrNotFound = errors.New("not found") 12 | ErrUnauthorized = errors.New("unauthorized") 13 | ErrInternal = errors.New("internal error") 14 | ) 15 | -------------------------------------------------------------------------------- /resources/migrations/20250113000000-add-kv-store.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE IF NOT EXISTS kv_store ( 3 | key TEXT PRIMARY KEY, 4 | value TEXT NOT NULL, 5 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 6 | ); 7 | 8 | CREATE INDEX IF NOT EXISTS idx_kv_store_updated_at ON kv_store(updated_at); 9 | 10 | -- +migrate Down 11 | DROP TABLE IF EXISTS kv_store; 12 | -------------------------------------------------------------------------------- /resources/migrations/1697008076-add-spam-tracking.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE IF NOT EXISTS "user_restrictions" ( 3 | "user_id" BIGINT NOT NULL, 4 | "chat_id" BIGINT NOT NULL, 5 | "restricted_at" DATETIME NOT NULL, 6 | "expires_at" DATETIME NOT NULL, 7 | "reason" TEXT NOT NULL, 8 | PRIMARY KEY ("chat_id", "user_id") 9 | ); 10 | 11 | -- +migrate Down 12 | DROP TABLE IF EXISTS "user_restrictions"; 13 | -------------------------------------------------------------------------------- /resources/migrations/2-add_users.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS users; 3 | CREATE TABLE IF NOT EXISTS "users" 4 | ( 5 | "id" BIGINT NOT NULL, 6 | "first_name" TEXT NOT NULL, 7 | "last_name" TEXT NOT NULL, 8 | "username" TEXT NOT NULL, 9 | "language_code" TEXT NOT NULL, 10 | "is_bot" TINYINT NOT NULL, 11 | 12 | PRIMARY KEY ("id") 13 | ); 14 | 15 | -- +migrate Down 16 | DROP TABLE users; 17 | -------------------------------------------------------------------------------- /internal/handlers/dependencies.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/iamwavecut/ngbot/internal/bot" 5 | adminhandler "github.com/iamwavecut/ngbot/internal/handlers/admin" 6 | chathandler "github.com/iamwavecut/ngbot/internal/handlers/chat" 7 | ) 8 | 9 | // Ensure all handlers implement the Handler interface 10 | var ( 11 | _ bot.Handler = (*adminhandler.Admin)(nil) 12 | _ bot.Handler = (*chathandler.Gatekeeper)(nil) 13 | _ bot.Handler = (*chathandler.Reactor)(nil) 14 | ) 15 | -------------------------------------------------------------------------------- /resources/migrations/1697008078-add-spam-votes.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE IF NOT EXISTS "spam_votes" ( 3 | "case_id" BIGINT NOT NULL, 4 | "voter_id" BIGINT NOT NULL, 5 | "vote" BOOLEAN NOT NULL, 6 | "voted_at" DATETIME NOT NULL, 7 | PRIMARY KEY ("case_id", "voter_id"), 8 | FOREIGN KEY ("case_id") REFERENCES "spam_cases" ("id") ON DELETE CASCADE 9 | ); 10 | 11 | CREATE INDEX IF NOT EXISTS "idx_spam_votes_case" ON "spam_votes" ("case_id"); 12 | 13 | -- +migrate Down 14 | DROP TABLE IF EXISTS "spam_votes"; 15 | -------------------------------------------------------------------------------- /internal/db/spam_tracking.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "time" 4 | 5 | type SpamReport struct { 6 | UserID int64 `db:"user_id"` 7 | ChatID int64 `db:"chat_id"` 8 | MessageID int `db:"message_id"` 9 | SpamScore float64 `db:"spam_score"` 10 | ReportedAt time.Time `db:"reported_at"` 11 | } 12 | 13 | type UserRestriction struct { 14 | UserID int64 `db:"user_id"` 15 | ChatID int64 `db:"chat_id"` 16 | RestrictedAt time.Time `db:"restricted_at"` 17 | ExpiresAt time.Time `db:"expires_at"` 18 | Reason string `db:"reason"` 19 | } 20 | -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/zh.yml: -------------------------------------------------------------------------------- 1 | "🐩": "狮子狗" 2 | "🐿️": "松鼠" 3 | "🐓": "公鸡" 4 | "🐷": "猪" 5 | "🎂": "饼" 6 | "🍔": "汉堡包" 7 | "🔪": "刀" 8 | "📱": "IPhone" 9 | "🎁": "礼物" 10 | "🖥️": "PC" 11 | "💡": "灯泡" 12 | "🥁": "鼓" 13 | "🎸": "吉他" 14 | "❤️": "喜欢" 15 | "🧦": "袜子" 16 | "🌭": "热狗" 17 | "🍌": "香蕉" 18 | "🍎": "苹果" 19 | "🐐": "山羊" 20 | "🍉": "西瓜" 21 | "🦀": "螃蟹" 22 | "🍭": "棒棒糖" 23 | "🌍": "行星" 24 | "⚓": "锚" 25 | "🚀": "火箭" 26 | "💎": "宝石" 27 | "🔥": "火" 28 | "🎈": "气球" 29 | "🕹": "操纵杆" 30 | "🔋": "电池" 31 | "⚡": "闪电" 32 | "📢": "扩音器" 33 | "🎤": "麦克风" 34 | "🎵": "音符" 35 | "🥝": "奇异果" 36 | "🍕": "比萨饼片" 37 | "🍋": "柠檬" 38 | "🍓": "草莓" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/ko.yml: -------------------------------------------------------------------------------- 1 | "🐩": "푸들" 2 | "🐿️": "다람쥐" 3 | "🐓": "수탉" 4 | "🐷": "돼지" 5 | "🎂": "케이크" 6 | "🍔": "버거" 7 | "🔪": "칼" 8 | "📱": "아이폰" 9 | "🎁": "선물" 10 | "🖥️": "PC" 11 | "💡": "bulb" 12 | "🥁": "드럼" 13 | "🎸": "guitar" 14 | "❤️": "좋아요" 15 | "🧦": "양말" 16 | "🌭": "핫도그" 17 | "🍌": "바나나" 18 | "🍎": "사과" 19 | "🐐": "염소" 20 | "🍉": "수박" 21 | "🦀": "게" 22 | "🍭": "롤리팝" 23 | "🌍": "행성" 24 | "⚓": "앵커" 25 | "🚀": "로켓" 26 | "💎": "보석" 27 | "🔥": "불" 28 | "🎈": "풍선" 29 | "🕹": "조이스틱" 30 | "🔋": "배터리" 31 | "⚡": "번개" 32 | "📢": "확성기" 33 | "🎤": "마이크" 34 | "🎵": "음표" 35 | "🥝": "키위 과일" 36 | "🍕": "피자 슬라이스" 37 | "🍋": "레몬" 38 | "🍓": "딸기" -------------------------------------------------------------------------------- /internal/infra/health_check.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | const ( 11 | checkExecInterval = 5 * time.Second 12 | ) 13 | 14 | func MonitorExecutable() chan struct{} { 15 | ch := make(chan struct{}) 16 | go func() { 17 | exeFilename, _ := os.Executable() 18 | log.Debug(exeFilename) 19 | stat, _ := os.Stat(exeFilename) 20 | originalTime := stat.ModTime() 21 | for { 22 | time.Sleep(checkExecInterval) 23 | stat, _ := os.Stat(exeFilename) 24 | if !originalTime.Equal(stat.ModTime()) { 25 | ch <- struct{}{} 26 | return 27 | } 28 | } 29 | }() 30 | return ch 31 | } 32 | -------------------------------------------------------------------------------- /resources/migrations/20241115000000-add-recent-joiners-and-banlist.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE IF NOT EXISTS recent_joiners ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | join_message_id INTEGER NOT NULL, 5 | chat_id INTEGER NOT NULL, 6 | user_id INTEGER NOT NULL, 7 | username TEXT, 8 | joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | processed BOOLEAN NOT NULL DEFAULT FALSE, 10 | is_spammer BOOLEAN NULL, 11 | UNIQUE(chat_id, user_id) 12 | ); 13 | 14 | CREATE TABLE IF NOT EXISTS banlist ( 15 | user_id INTEGER PRIMARY KEY 16 | ); 17 | 18 | -- +migrate Down 19 | DROP TABLE recent_joiners; 20 | DROP TABLE banlist; 21 | -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/ja.yml: -------------------------------------------------------------------------------- 1 | "🐩": "プードル" 2 | "🐿️": "リス" 3 | "🐓": "コック" 4 | "🐷": "ブタ" 5 | "🎂": "ケーキ" 6 | "🍔": "ハンバーガー" 7 | "🔪": "ナイフ" 8 | "📱": "アイフォン" 9 | "🎁": "ギフト" 10 | "🖥️": "パソコン" 11 | "💡": "電球" 12 | "🥁": "ドラム" 13 | "🎸": "ギター" 14 | "❤️": "好き" 15 | "🧦": "靴下" 16 | "🌭": "ホットドッグ" 17 | "🍌": "バナナ" 18 | "🍎": "リンゴ" 19 | "🐐": "ヤギ" 20 | "🍉": "スイカ" 21 | "🦀": "カニ" 22 | "🍭": "ロリポップ" 23 | "🌍": "惑星" 24 | "⚓": "アンカー" 25 | "🚀": "ロケット" 26 | "💎": "ジェム" 27 | "🔥": "火" 28 | "🎈": "風船" 29 | "🕹": "ジョイスティック" 30 | "🔋": "バッテリー" 31 | "⚡": "稲妻" 32 | "📢": "ラウドスピーカー" 33 | "🎤": "マイク" 34 | "🎵": "音符" 35 | "🥝": "キウイフルーツ" 36 | "🍕": "ピザの切り身" 37 | "🍋": "レモン" 38 | "🍓": "イチゴ" -------------------------------------------------------------------------------- /internal/adapters/llm/entities.go: -------------------------------------------------------------------------------- 1 | package llm 2 | 3 | type ChatCompletionMessage struct { 4 | Role string `json:"role"` 5 | Content string `json:"content"` 6 | } 7 | 8 | type ChatCompletionResponse struct { 9 | Choices []ChatCompletionChoice `json:"choices"` 10 | } 11 | 12 | type ChatCompletionChoice struct { 13 | Message ChatCompletionMessage `json:"message"` 14 | } 15 | 16 | type GenerationParameters struct { 17 | Temperature float32 `json:"temperature"` 18 | TopK int32 `json:"top_k"` 19 | TopP float32 `json:"top_p"` 20 | MaxOutputTokens int `json:"max_output_tokens"` 21 | ResponseMIMEType string `json:"response_mime_type"` 22 | } 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.7 2 | FROM golang:alpine AS build 3 | HEALTHCHECK NONE 4 | 5 | WORKDIR /build 6 | COPY go.mod go.sum ./ 7 | RUN --mount=type=cache,target=/go/pkg/mod \ 8 | --mount=type=cache,target=/root/.cache/go-build \ 9 | go mod download 10 | COPY . . 11 | RUN CGO_ENABLED=0 \ 12 | --mount=type=cache,target=/go/pkg/mod \ 13 | --mount=type=cache,target=/root/.cache/go-build \ 14 | go build -ldflags='-w -s -extldflags "-static"' -o ngbot cmd/ngbot/main.go && chmod +x ngbot 15 | 16 | FROM gcr.io/distroless/static-debian12 17 | HEALTHCHECK NONE 18 | WORKDIR /app 19 | ENV HOME=/root 20 | COPY --from=build /build/ngbot ./ 21 | ENTRYPOINT ["./ngbot"] 22 | -------------------------------------------------------------------------------- /internal/infra/filesystem.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/mitchellh/go-homedir" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func GetWorkDir(path ...string) string { 13 | parts := []string{ 14 | "~", 15 | ".ngbot", 16 | } 17 | parts = append(parts, path...) 18 | workDir, err := homedir.Expand(filepath.Join(parts...)) 19 | if err != nil { 20 | log.Fatalln(err) 21 | } 22 | if err = os.MkdirAll(workDir, os.ModePerm); err != nil { 23 | log.Fatalln(err) 24 | } 25 | log.Println(workDir) 26 | return workDir 27 | } 28 | 29 | func GetResourcesPath(path ...string) string { 30 | return strings.Join(path, "/") 31 | } 32 | -------------------------------------------------------------------------------- /resources/migrations/1697008077-add-spam-cases.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE IF NOT EXISTS "spam_cases" ( 3 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, 4 | "chat_id" BIGINT NOT NULL, 5 | "user_id" BIGINT NOT NULL, 6 | "message_text" TEXT NOT NULL, 7 | "created_at" DATETIME NOT NULL, 8 | "channel_id" BIGINT, 9 | "channel_post_id" INTEGER, 10 | "notification_message_id" INTEGER, 11 | "status" TEXT NOT NULL DEFAULT 'pending', 12 | "resolved_at" DATETIME 13 | ); 14 | 15 | CREATE INDEX IF NOT EXISTS "idx_spam_cases_chat_user" ON "spam_cases" ("chat_id", "user_id"); 16 | CREATE INDEX IF NOT EXISTS "idx_spam_cases_status" ON "spam_cases" ("status"); 17 | 18 | -- +migrate Down 19 | DROP TABLE IF EXISTS "spam_cases"; 20 | -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/et.yml: -------------------------------------------------------------------------------- 1 | "🐩": "Puudel" 2 | "🐿️": "orav" 3 | "🐓": "kukk" 4 | "🐷": "pig" 5 | "🎂": "kook" 6 | "🍔": "burger" 7 | "🔪": "nuga" 8 | "📱": "IPhone" 9 | "🎁": "kingitus" 10 | "🖥️": "PC" 11 | "💡": "lamb" 12 | "🥁": "drum" 13 | "🎸": "kitarr" 14 | "❤️": "like" 15 | "🧦": "sokid" 16 | "🌭": "hot dog" 17 | "🍌": "banaan" 18 | "🍎": "apple" 19 | "🐐": "goat" 20 | "🍉": "arbuus" 21 | "🦀": "krabid" 22 | "🍭": "lollipop" 23 | "🌍": "planeet" 24 | "⚓": "anchor" 25 | "🚀": "rocket" 26 | "💎": "gem" 27 | "🔥": "fire" 28 | "🎈": "balloon" 29 | "🕹": "joystick" 30 | "🔋": "aku" 31 | "⚡": "välk" 32 | "📢": "valjuhääldi" 33 | "🎤": "mikrofon" 34 | "🎵": "muusikaline noot" 35 | "🥝": "kiivi" 36 | "🍕": "pizzaviil" 37 | "🍋": "sidrun" 38 | "🍓": "maasika" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/hu.yml: -------------------------------------------------------------------------------- 1 | "🐩": "poodle" 2 | "🐿️": "mókus" 3 | "🐓": "cock" 4 | "🐷": "pig" 5 | "🎂": "cake" 6 | "🍔": "burger" 7 | "🔪": "kés" 8 | "📱": "IPhone" 9 | "🎁": "ajándék" 10 | "🖥️": "PC" 11 | "💡": "bulb" 12 | "🥁": "drum" 13 | "🎸": "guitar" 14 | "❤️": "like" 15 | "🧦": "zokni" 16 | "🌭": "hot dog" 17 | "🍌": "banán" 18 | "🍎": "alma" 19 | "🐐": "kecske" 20 | "🍉": "görögdinnye" 21 | "🦀": "rák" 22 | "🍭": "nyalóka" 23 | "🌍": "planet" 24 | "⚓": "anchor" 25 | "🚀": "rakéta" 26 | "💎": "gem" 27 | "🔥": "fire" 28 | "🎈": "ballon" 29 | "🕹": "joystick" 30 | "🔋": "battery" 31 | "⚡": "villámcsapás" 32 | "📢": "hangszóró" 33 | "🎤": "mikrofon" 34 | "🎵": "hangjegy" 35 | "🥝": "kiwi" 36 | "🍕": "pizza szelet" 37 | "🍋": "citrom" 38 | "🍓": "eper" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/nb.yml: -------------------------------------------------------------------------------- 1 | "🐩": "puddel" 2 | "🐿️": "ekorn" 3 | "🐓": "kuk" 4 | "🐷": "gris" 5 | "🎂": "kake" 6 | "🍔": "burger" 7 | "🔪": "kniv" 8 | "📱": "IPhone" 9 | "🎁": "gave" 10 | "🖥️": "PC" 11 | "💡": "pære" 12 | "🥁": "trommel" 13 | "🎸": "gitar" 14 | "❤️": "like" 15 | "🧦": "sokker" 16 | "🌭": "pølse" 17 | "🍌": "banan" 18 | "🍎": "eple" 19 | "🐐": "geit" 20 | "🍉": "vannmelon" 21 | "🦀": "krabbe" 22 | "🍭": "kjærlighet på pinne" 23 | "🌍": "planet" 24 | "⚓": "anker" 25 | "🚀": "rakett" 26 | "💎": "perle" 27 | "🔥": "brann" 28 | "🎈": "ballong" 29 | "🕹": "joystick" 30 | "🔋": "batteri" 31 | "⚡": "lyn" 32 | "📢": "høyttaler" 33 | "🎤": "mikrofon" 34 | "🎵": "note" 35 | "🥝": "kiwi" 36 | "🍕": "pizzastykke" 37 | "🍋": "sitron" 38 | "🍓": "jordbær" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/tr.yml: -------------------------------------------------------------------------------- 1 | "🐩": "kaniş" 2 | "🐿️": "squirrel" 3 | "🐓": "horoz" 4 | "🐷": "domuz" 5 | "🎂": "kek" 6 | "🍔": "burger" 7 | "🔪": "bıçak" 8 | "📱": "IPhone" 9 | "🎁": "hediye" 10 | "🖥️": "PC" 11 | "💡": "ampul" 12 | "🥁": "drum" 13 | "🎸": "gitar" 14 | "❤️": "gibi" 15 | "🧦": "çorap" 16 | "🌭": "hot dog" 17 | "🍌": "muz" 18 | "🍎": "elma" 19 | "🐐": "keçi" 20 | "🍉": "karpuz" 21 | "🦀": "yengeç" 22 | "🍭": "lolipop" 23 | "🌍": "gezegen" 24 | "⚓": "çapa" 25 | "🚀": "roket" 26 | "💎": "mücevher" 27 | "🔥": "ateş" 28 | "🎈": "balon" 29 | "🕹": "joystick" 30 | "🔋": "pil" 31 | "⚡": "şimşek" 32 | "📢": "hoparlör" 33 | "🎤": "mikrofon" 34 | "🎵": "müzik notası" 35 | "🥝": "kivi meyvesi" 36 | "🍕": "pizza dilimi" 37 | "🍋": "limon" 38 | "🍓": "çilek" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/cs.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pudl" 2 | "🐿️": "veverka" 3 | "🐓": "kohout" 4 | "🐷": "prase" 5 | "🎂": "dort" 6 | "🍔": "hamburger" 7 | "🔪": "nůž" 8 | "📱": "IPhone" 9 | "🎁": "dárek" 10 | "🖥️": "PC" 11 | "💡": "žárovka" 12 | "🥁": "buben" 13 | "🎸": "kytara" 14 | "❤️": "like" 15 | "🧦": "ponožky" 16 | "🌭": "hot dog" 17 | "🍌": "banán" 18 | "🍎": "jablko" 19 | "🐐": "koza" 20 | "🍉": "meloun" 21 | "🦀": "krab" 22 | "🍭": "lízátko" 23 | "🌍": "planeta" 24 | "⚓": "kotva" 25 | "🚀": "raketa" 26 | "💎": "gem" 27 | "🔥": "fire" 28 | "🎈": "balon" 29 | "🕹": "joystick" 30 | "🔋": "baterie" 31 | "⚡": "blesk" 32 | "📢": "reproduktor" 33 | "🎤": "mikrofon" 34 | "🎵": "hudební nota" 35 | "🥝": "kiwi" 36 | "🍕": "kousek pizzy" 37 | "🍋": "citron" 38 | "🍓": "jahoda" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/da.yml: -------------------------------------------------------------------------------- 1 | "🐩": "puddel" 2 | "🐿️": "egern" 3 | "🐓": "hane" 4 | "🐷": "svin" 5 | "🎂": "kage" 6 | "🍔": "burger" 7 | "🔪": "kniv" 8 | "📱": "IPhone" 9 | "🎁": "gave" 10 | "🖥️": "PC" 11 | "💡": "pære" 12 | "🥁": "tromle" 13 | "🎸": "guitar" 14 | "❤️": "like" 15 | "🧦": "sokker" 16 | "🌭": "hotdog" 17 | "🍌": "banan" 18 | "🍎": "æble" 19 | "🐐": "ged" 20 | "🍉": "vandmelon" 21 | "🦀": "krabbe" 22 | "🍭": "slikkepind" 23 | "🌍": "planet" 24 | "⚓": "anker" 25 | "🚀": "rocket" 26 | "💎": "gem" 27 | "🔥": "fire" 28 | "🎈": "balloon" 29 | "🕹": "joystick" 30 | "🔋": "batteri" 31 | "⚡": "lynnedslag" 32 | "📢": "højtaler" 33 | "🎤": "mikrofon" 34 | "🎵": "musikalsk tone" 35 | "🥝": "kiwifrugt" 36 | "🍕": "pizzaslice" 37 | "🍋": "lemon" 38 | "🍓": "jordbær" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/bg.yml: -------------------------------------------------------------------------------- 1 | "🐩": "пудел" 2 | "🐿️": "катерица" 3 | "🐓": "петел" 4 | "🐷": "прасе" 5 | "🎂": "торта" 6 | "🍔": "бургер" 7 | "🔪": "нож" 8 | "📱": "IPhone" 9 | "🎁": "подарък" 10 | "🖥️": "PC" 11 | "💡": "крушка" 12 | "🥁": "барабан" 13 | "🎸": "китара" 14 | "❤️": "like" 15 | "🧦": "чорапи" 16 | "🌭": "хот-дог" 17 | "🍌": "банан" 18 | "🍎": "ябълка" 19 | "🐐": "коза" 20 | "🍉": "диня" 21 | "🦀": "раци" 22 | "🍭": "близалка" 23 | "🌍": "планета" 24 | "⚓": "котва" 25 | "🚀": "ракета" 26 | "💎": "gem" 27 | "🔥": "огън" 28 | "🎈": "балон" 29 | "🕹": "джойстик" 30 | "🔋": "батерия" 31 | "⚡": "мълния" 32 | "📢": "високоговорител" 33 | "🎤": "микрофон" 34 | "🎵": "музикална нота" 35 | "🥝": "плод киви" 36 | "🍕": "парче пица" 37 | "🍋": "лимон" 38 | "🍓": "ягода" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/lt.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pūdeļi" 2 | "🐿️": "vāvere" 3 | "🐓": "gailis" 4 | "🐷": "cūka" 5 | "🎂": "kūka" 6 | "🍔": "burgers" 7 | "🔪": "nazis" 8 | "📱": "IPhone" 9 | "🎁": "dāvana" 10 | "🖥️": "PC" 11 | "💡": "spuldze" 12 | "🥁": "bungas" 13 | "🎸": "ģitāra" 14 | "❤️": "like" 15 | "🧦": "zeķes" 16 | "🌭": "hotdogs" 17 | "🍌": "banāns" 18 | "🍎": "ābols" 19 | "🐐": "kaza" 20 | "🍉": "arbūzs" 21 | "🦀": "krab" 22 | "🍭": "lollipop" 23 | "🌍": "planēta" 24 | "⚓": "enkurs" 25 | "🚀": "raķete" 26 | "💎": "gem" 27 | "🔥": "uguns" 28 | "🎈": "balons" 29 | "🕹": "joystick" 30 | "🔋": "baterija" 31 | "⚡": "zibens" 32 | "📢": "skaļrunis" 33 | "🎤": "mikrofons" 34 | "🎵": "nots" 35 | "🥝": "kivi auglis" 36 | "🍕": "picas gabaliņš" 37 | "🍋": "citrons" 38 | "🍓": "zemeņu" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/sk.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pudel" 2 | "🐿️": "veverička" 3 | "🐓": "kohút" 4 | "🐷": "prasa" 5 | "🎂": "koláč" 6 | "🍔": "hamburger" 7 | "🔪": "nôž" 8 | "📱": "IPhone" 9 | "🎁": "darček" 10 | "🖥️": "PC" 11 | "💡": "žiarovka" 12 | "🥁": "bubon" 13 | "🎸": "gitara" 14 | "❤️": "like" 15 | "🧦": "ponožky" 16 | "🌭": "hot dog" 17 | "🍌": "banán" 18 | "🍎": "jablko" 19 | "🐐": "koza" 20 | "🍉": "melón" 21 | "🦀": "krab" 22 | "🍭": "lollipop" 23 | "🌍": "planéta" 24 | "⚓": "kotva" 25 | "🚀": "raketový" 26 | "💎": "gem" 27 | "🔥": "fire" 28 | "🎈": "balón" 29 | "🕹": "joystick" 30 | "🔋": "batéria" 31 | "⚡": "blesk" 32 | "📢": "reproduktor" 33 | "🎤": "mikrofón" 34 | "🎵": "hudobná nota" 35 | "🥝": "kiwi" 36 | "🍕": "kúsok pizze" 37 | "🍋": "citrón" 38 | "🍓": "jahoda" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/fi.yml: -------------------------------------------------------------------------------- 1 | "🐩": "villakoira" 2 | "🐿️": "orava" 3 | "🐓": "kukko" 4 | "🐷": "pig" 5 | "🎂": "cake" 6 | "🍔": "burger" 7 | "🔪": "veitsi" 8 | "📱": "IPhone" 9 | "🎁": "lahja" 10 | "🖥️": "PC" 11 | "💡": "bulb" 12 | "🥁": "rumpu" 13 | "🎸": "guitar" 14 | "❤️": "like" 15 | "🧦": "sukat" 16 | "🌭": "hot dog" 17 | "🍌": "banaani" 18 | "🍎": "omena" 19 | "🐐": "vuohi" 20 | "🍉": "vesimeloni" 21 | "🦀": "rapu" 22 | "🍭": "tikkari" 23 | "🌍": "planeetta" 24 | "⚓": "ankkuri" 25 | "🚀": "raketti" 26 | "💎": "jalokivi" 27 | "🔥": "fire" 28 | "🎈": "balloon" 29 | "🕹": "joystick" 30 | "🔋": "battery" 31 | "⚡": "salama" 32 | "📢": "kaiutin" 33 | "🎤": "mikrofoni" 34 | "🎵": "nuotti" 35 | "🥝": "kiivi" 36 | "🍕": "pizzaviipale" 37 | "🍋": "sitruuna" 38 | "🍓": "mansikka" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/ro.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pudel" 2 | "🐿️": "veveriță" 3 | "🐓": "cocoș" 4 | "🐷": "porc" 5 | "🎂": "tort" 6 | "🍔": "burger" 7 | "🔪": "cuțit" 8 | "📱": "IPhone" 9 | "🎁": "cadou" 10 | "🖥️": "PC" 11 | "💡": "bec" 12 | "🥁": "tambur" 13 | "🎸": "chitară" 14 | "❤️": "like" 15 | "🧦": "șosete" 16 | "🌭": "hot dog" 17 | "🍌": "banană" 18 | "🍎": "măr" 19 | "🐐": "capră" 20 | "🍉": "pepene verde" 21 | "🦀": "crab" 22 | "🍭": "acadea" 23 | "🌍": "planetă" 24 | "⚓": "ancoră" 25 | "🚀": "rachetă" 26 | "💎": "piatră prețioasă" 27 | "🔥": "foc" 28 | "🎈": "balon" 29 | "🕹": "joystick" 30 | "🔋": "baterie" 31 | "⚡": "fulger" 32 | "📢": "difuzor" 33 | "🎤": "microfon" 34 | "🎵": "notă muzicală" 35 | "🥝": "kiwi" 36 | "🍕": "felie de pizza" 37 | "🍋": "lămâie" 38 | "🍓": "căpșuni" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/sl.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pudl" 2 | "🐿️": "veverica" 3 | "🐓": "petelin" 4 | "🐷": "prašič" 5 | "🎂": "torta" 6 | "🍔": "burger" 7 | "🔪": "nož" 8 | "📱": "IPhone" 9 | "🎁": "darilo" 10 | "🖥️": "PC" 11 | "💡": "žarnica" 12 | "🥁": "boben" 13 | "🎸": "kitara" 14 | "❤️": "like" 15 | "🧦": "nogavice" 16 | "🌭": "hot dog" 17 | "🍌": "banana" 18 | "🍎": "jabolko" 19 | "🐐": "koza" 20 | "🍉": "lubenica" 21 | "🦀": "rakovica" 22 | "🍭": "lizika" 23 | "🌍": "planet" 24 | "⚓": "sidro" 25 | "🚀": "raketa" 26 | "💎": "dragulj" 27 | "🔥": "ogenj" 28 | "🎈": "balon" 29 | "🕹": "joystick" 30 | "🔋": "baterija" 31 | "⚡": "strela" 32 | "📢": "zvočnik" 33 | "🎤": "mikrofon" 34 | "🎵": "glasbena nota" 35 | "🥝": "kivi" 36 | "🍕": "košček pice" 37 | "🍋": "limona" 38 | "🍓": "jagoda" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/sv.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pudel" 2 | "🐿️": "ekorre" 3 | "🐓": "cock" 4 | "🐷": "gris" 5 | "🎂": "kaka" 6 | "🍔": "hamburgare" 7 | "🔪": "kniv" 8 | "📱": "IPhone" 9 | "🎁": "present" 10 | "🖥️": "PC" 11 | "💡": "glödlampa" 12 | "🥁": "trumma" 13 | "🎸": "gitarr" 14 | "❤️": "like" 15 | "🧦": "socks" 16 | "🌭": "korv" 17 | "🍌": "banan" 18 | "🍎": "äpple" 19 | "🐐": "get" 20 | "🍉": "vattenmelon" 21 | "🦀": "krabba" 22 | "🍭": "Lollipop" 23 | "🌍": "planet" 24 | "⚓": "ankare" 25 | "🚀": "raket" 26 | "💎": "gem" 27 | "🔥": "eld" 28 | "🎈": "ballong" 29 | "🕹": "joystick" 30 | "🔋": "batteri" 31 | "⚡": "blixtnedslag" 32 | "📢": "högtalare" 33 | "🎤": "mikrofon" 34 | "🎵": "musikalisk not" 35 | "🥝": "kiwi" 36 | "🍕": "pizzabit" 37 | "🍋": "citron" 38 | "🍓": "jordgubbe" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/en.yml: -------------------------------------------------------------------------------- 1 | "🐩": "poodle" 2 | "🐿️": "squirrel" 3 | "🐓": "cock" 4 | "🐷": "pig" 5 | "🎂": "cake" 6 | "🍔": "burger" 7 | "🔪": "knife" 8 | "📱": "IPhone" 9 | "🎁": "gift" 10 | "🖥️": "PC" 11 | "💡": "bulb" 12 | "🥁": "drum" 13 | "🎸": "guitar" 14 | "❤️": "like" 15 | "🧦": "socks" 16 | "🌭": "hot dog" 17 | "🍌": "banana" 18 | "🍎": "apple" 19 | "🐐": "goat" 20 | "🍉": "watermelon" 21 | "🦀": "crab" 22 | "🍭": "lollipop" 23 | "🌍": "planet" 24 | "⚓": "anchor" 25 | "🚀": "rocket" 26 | "💎": "gem" 27 | "🔥": "fire" 28 | "🎈": "balloon" 29 | "🕹": "joystick" 30 | "🔋": "battery" 31 | "⚡": "lightning bolt" 32 | "📢": "loudspeaker" 33 | "🎤": "microphone" 34 | "🎵": "musical note" 35 | "🥝": "kiwi fruit" 36 | "🍕": "pizza slice" 37 | "🍋": "lemon" 38 | "🍓": "strawberry" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/id.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pudel" 2 | "🐿️": "tupai" 3 | "🐓": "ayam" 4 | "🐷": "babi" 5 | "🎂": "kue" 6 | "🍔": "burger" 7 | "🔪": "pisau" 8 | "📱": "iPhone" 9 | "🎁": "hadiah" 10 | "🖥️": "PC" 11 | "💡": "bohlam" 12 | "🥁": "drum" 13 | "🎸": "gitar" 14 | "❤️": "suka" 15 | "🧦": "kaus kaki" 16 | "🌭": "hot dog" 17 | "🍌": "pisang" 18 | "🍎": "apel" 19 | "🐐": "kambing" 20 | "🍉": "semangka" 21 | "🦀": "kepiting" 22 | "🍭": "lolipop" 23 | "🌍": "planet" 24 | "⚓": "jangkar" 25 | "🚀": "roket" 26 | "💎": "permata" 27 | "🔥": "api" 28 | "🎈": "balon" 29 | "🕹": "joystick" 30 | "🔋": "baterai" 31 | "⚡": "petir" 32 | "📢": "pengeras suara" 33 | "🎤": "mikrofon" 34 | "🎵": "catatan musik" 35 | "🥝": "buah kiwi" 36 | "🍕": "potongan pizza" 37 | "🍋": "lemon" 38 | "🍓": "stroberi" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/nl.yml: -------------------------------------------------------------------------------- 1 | "🐩": "poedel" 2 | "🐿️": "eekhoorn" 3 | "🐓": "haan" 4 | "🐷": "varken" 5 | "🎂": "taart" 6 | "🍔": "hamburger" 7 | "🔪": "mes" 8 | "📱": "IPhone" 9 | "🎁": "gift" 10 | "🖥️": "PC" 11 | "💡": "lamp" 12 | "🥁": "drum" 13 | "🎸": "gitaar" 14 | "❤️": "like" 15 | "🧦": "sokken" 16 | "🌭": "hot dog" 17 | "🍌": "banaan" 18 | "🍎": "appel" 19 | "🐐": "geit" 20 | "🍉": "watermeloen" 21 | "🦀": "krab" 22 | "🍭": "lollie" 23 | "🌍": "planeet" 24 | "⚓": "anker" 25 | "🚀": "raket" 26 | "💎": "gem" 27 | "🔥": "vuur" 28 | "🎈": "ballon" 29 | "🕹": "joystick" 30 | "🔋": "batterij" 31 | "⚡": "bliksemschicht" 32 | "📢": "luidspreker" 33 | "🎤": "microfoon" 34 | "🎵": "muzieknoot" 35 | "🥝": "kiwi fruit" 36 | "🍕": "pizza slice" 37 | "🍋": "citroen" 38 | "🍓": "aardbei" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/de.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pudel" 2 | "🐿️": "eichhörnchen" 3 | "🐓": "hahn" 4 | "🐷": "schwein" 5 | "🎂": "kuchen" 6 | "🍔": "burger" 7 | "🔪": "messer" 8 | "📱": "iPhone" 9 | "🎁": "geschenk" 10 | "🖥️": "pc" 11 | "💡": "glühbirne" 12 | "🥁": "trommel" 13 | "🎸": "gitarre" 14 | "❤️": "wie" 15 | "🧦": "socken" 16 | "🌭": "hot dog" 17 | "🍌": "banane" 18 | "🍎": "apfel" 19 | "🐐": "ziege" 20 | "🍉": "watermelon" 21 | "🦀": "krabbe" 22 | "🍭": "lollipop" 23 | "🌍": "planet" 24 | "⚓": "anker" 25 | "🚀": "rakete" 26 | "💎": "gem" 27 | "🔥": "feuer" 28 | "🎈": "ballon" 29 | "🕹": "joystick" 30 | "🔋": "batterie" 31 | "⚡": "blitz" 32 | "📢": "lautsprecher" 33 | "🎤": "mikrofon" 34 | "🎵": "musiknote" 35 | "🥝": "kiwi fruit" 36 | "🍕": "pizzastück" 37 | "🍋": "zitrone" 38 | "🍓": "erdbeere" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/fr.yml: -------------------------------------------------------------------------------- 1 | "🐩": "caniche" 2 | "🐿️": "écureuil" 3 | "🐓": "coq" 4 | "🐷": "cochon" 5 | "🎂": "gâteau" 6 | "🍔": "burger" 7 | "🔪": "couteau" 8 | "📱": "IPhone" 9 | "🎁": "cadeau" 10 | "🖥️": "PC" 11 | "💡": "ampoule" 12 | "🥁": "tambour" 13 | "🎸": "guitare" 14 | "❤️": "like" 15 | "🧦": "chaussettes" 16 | "🌭": "hot dog" 17 | "🍌": "banane" 18 | "🍎": "pomme" 19 | "🐐": "chèvre" 20 | "🍉": "pastèque" 21 | "🦀": "crabe" 22 | "🍭": "sucette" 23 | "🌍": "planète" 24 | "⚓": "ancre" 25 | "🚀": "fusée" 26 | "💎": "gemme" 27 | "🔥": "feu" 28 | "🎈": "ballon" 29 | "🕹": "joystick" 30 | "🔋": "batterie" 31 | "⚡": "foudre" 32 | "📢": "haut-parleur" 33 | "🎤": "microphone" 34 | "🎵": "note de musique" 35 | "🥝": "kiwi" 36 | "🍕": "part de pizza" 37 | "🍋": "citron" 38 | "🍓": "fraise" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/pl.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pudel" 2 | "🐿️": "wiewiórka" 3 | "🐓": "kogut" 4 | "🐷": "świnia" 5 | "🎂": "ciasto" 6 | "🍔": "burger" 7 | "🔪": "nóż" 8 | "📱": "IPhone" 9 | "🎁": "prezent" 10 | "🖥️": "PC" 11 | "💡": "żarówka" 12 | "🥁": "bęben" 13 | "🎸": "gitara" 14 | "❤️": "like" 15 | "🧦": "skarpetki" 16 | "🌭": "hot dog" 17 | "🍌": "banan" 18 | "🍎": "jabłko" 19 | "🐐": "koza" 20 | "🍉": "arbuz" 21 | "🦀": "krab" 22 | "🍭": "lizak" 23 | "🌍": "planeta" 24 | "⚓": "kotwica" 25 | "🚀": "rakieta" 26 | "💎": "diament" 27 | "🔥": "ogień" 28 | "🎈": "balon" 29 | "🕹": "joystick" 30 | "🔋": "bateria" 31 | "⚡": "piorunochron" 32 | "📢": "głośnik" 33 | "🎤": "mikrofon" 34 | "🎵": "nuta muzyczna" 35 | "🥝": "owoc kiwi" 36 | "🍕": "plasterek pizzy" 37 | "🍋": "cytryna" 38 | "🍓": "truskawka" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/es.yml: -------------------------------------------------------------------------------- 1 | "🐩": "caniche" 2 | "🐿️": "ardilla" 3 | "🐓": "gallo" 4 | "🐷": "cerdo" 5 | "🎂": "pastel" 6 | "🍔": "hamburguesa" 7 | "🔪": "cuchillo" 8 | "📱": "IPhone" 9 | "🎁": "regalo" 10 | "🖥️": "PC" 11 | "💡": "bombilla" 12 | "🥁": "tambor" 13 | "🎸": "guitarra" 14 | "❤️": "like" 15 | "🧦": "calcetines" 16 | "🌭": "perrito caliente" 17 | "🍌": "banana" 18 | "🍎": "manzana" 19 | "🐐": "cabra" 20 | "🍉": "sandía" 21 | "🦀": "cangrejo" 22 | "🍭": "piruleta" 23 | "🌍": "planeta" 24 | "⚓": "ancla" 25 | "🚀": "cohete" 26 | "💎": "gema" 27 | "🔥": "fuego" 28 | "🎈": "globo" 29 | "🕹": "joystick" 30 | "🔋": "batería" 31 | "⚡": "rayo" 32 | "📢": "altavoz" 33 | "🎤": "micrófono" 34 | "🎵": "nota musical" 35 | "🥝": "kiwi" 36 | "🍕": "trozo de pizza" 37 | "🍋": "limón" 38 | "🍓": "fresa" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/pt.yml: -------------------------------------------------------------------------------- 1 | "🐩": "poodle" 2 | "🐿️": "esquilo" 3 | "🐓": "galo" 4 | "🐷": "porco" 5 | "🎂": "bolo" 6 | "🍔": "hambúrguer" 7 | "🔪": "faca" 8 | "📱": "IPhone" 9 | "🎁": "presente" 10 | "🖥️": "PC" 11 | "💡": "lâmpada" 12 | "🥁": "tambor" 13 | "🎸": "guitarra" 14 | "❤️": "como" 15 | "🧦": "meias" 16 | "🌭": "cachorro-quente" 17 | "🍌": "banana" 18 | "🍎": "maçã" 19 | "🐐": "bode" 20 | "🍉": "melancia" 21 | "🦀": "caranguejo" 22 | "🍭": "chupa-chupa" 23 | "🌍": "planeta" 24 | "⚓": "âncora" 25 | "🚀": "foguetão" 26 | "💎": "gema" 27 | "🔥": "fogo" 28 | "🎈": "balão" 29 | "🕹": "joystick" 30 | "🔋": "bateria" 31 | "⚡": "relâmpago" 32 | "📢": "alto-falante" 33 | "🎤": "microfone" 34 | "🎵": "nota musical" 35 | "🥝": "kiwi fruit" 36 | "🍕": "pizza" 37 | "🍋": "limão" 38 | "🍓": "morango" -------------------------------------------------------------------------------- /internal/adapters/llm.go: -------------------------------------------------------------------------------- 1 | package adapters 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/iamwavecut/ngbot/internal/adapters/llm" 7 | ) 8 | 9 | // LLM defines the interface for language model operations 10 | type LLM interface { 11 | // Detect checks if a message is spam 12 | Detect(ctx context.Context, message string) (*bool, error) 13 | // WithModel sets the model to use 14 | WithModel(modelName string) LLM 15 | // WithParameters sets the generation parameters 16 | WithParameters(parameters *llm.GenerationParameters) LLM 17 | // WithSystemPrompt sets the system prompt 18 | WithSystemPrompt(prompt string) LLM 19 | // ChatCompletion performs a chat completion request 20 | ChatCompletion(ctx context.Context, messages []llm.ChatCompletionMessage) (llm.ChatCompletionResponse, error) 21 | } 22 | -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/lv.yml: -------------------------------------------------------------------------------- 1 | "🐩": "pudelis" 2 | "🐿️": "voverė" 3 | "🐓": "gaidys" 4 | "🐷": "kiaulė" 5 | "🎂": "pyragas" 6 | "🍔": "mėsainis" 7 | "🔪": "peilis" 8 | "📱": "IPhone" 9 | "🎁": "dovana" 10 | "🖥️": "PC" 11 | "💡": "lemputė" 12 | "🥁": "būgnas" 13 | "🎸": "gitara" 14 | "❤️": "like" 15 | "🧦": "kojinės" 16 | "🌭": "hot dog" 17 | "🍌": "bananas" 18 | "🍎": "obuolys" 19 | "🐐": "ožka" 20 | "🍉": "arbūzas" 21 | "🦀": "krabas" 22 | "🍭": "saldainis" 23 | "🌍": "planeta" 24 | "⚓": "inkaras" 25 | "🚀": "raketa" 26 | "💎": "brangakmenis" 27 | "🔥": "ugnis" 28 | "🎈": "balionas" 29 | "🕹": "joystick" 30 | "🔋": "baterija" 31 | "⚡": "žaibas" 32 | "📢": "garsiakalbis" 33 | "🎤": "mikrofonas" 34 | "🎵": "muzikinė nata" 35 | "🥝": "kivis" 36 | "🍕": "picos gabalėlis" 37 | "🍋": "citrina" 38 | "🍓": "braškių" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/it.yml: -------------------------------------------------------------------------------- 1 | "🐩": "barboncino" 2 | "🐿️": "scoiattolo" 3 | "🐓": "gallo" 4 | "🐷": "maiale" 5 | "🎂": "torta" 6 | "🍔": "hamburger" 7 | "🔪": "coltello" 8 | "📱": "IPhone" 9 | "🎁": "regalo" 10 | "🖥️": "PC" 11 | "💡": "lampadina" 12 | "🥁": "tamburo" 13 | "🎸": "chitarra" 14 | "❤️": "like" 15 | "🧦": "calzini" 16 | "🌭": "hot dog" 17 | "🍌": "banana" 18 | "🍎": "mela" 19 | "🐐": "capra" 20 | "🍉": "anguria" 21 | "🦀": "granchio" 22 | "🍭": "lecca-lecca" 23 | "🌍": "pianeta" 24 | "⚓": "ancora" 25 | "🚀": "razzo" 26 | "💎": "gemma" 27 | "🔥": "fuoco" 28 | "🎈": "palloncino" 29 | "🕹": "joystick" 30 | "🔋": "batteria" 31 | "⚡": "fulmine" 32 | "📢": "altoparlante" 33 | "🎤": "microfono" 34 | "🎵": "nota musicale" 35 | "🥝": "kiwi" 36 | "🍕": "trancio di pizza" 37 | "🍋": "limone" 38 | "🍓": "fragola" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/be.yml: -------------------------------------------------------------------------------- 1 | "🐩": "пудзеля" 2 | "🐿️": "вавёрку" 3 | "🐓": "пеўня" 4 | "🐷": "пятачка" 5 | "🎂": "торт" 6 | "🍔": "бургер" 7 | "🔪": "нож" 8 | "📱": "айфон" 9 | "🎁": "сектар прыз на барабане" 10 | "🖥️": "камплюктар" 11 | "💡": "лямпачку" 12 | "🥁": "барабан" 13 | "🎸": "гітару" 14 | "❤️": "лайк" 15 | "🌻": "сланечнік" 16 | "🧦": "шкарпэткі" 17 | "🌭": "хот-дог" 18 | "🍌": "банан" 19 | "🍎": "яблык" 20 | "🐐": "казла" 21 | "🍉": "кавун" 22 | "🦀": "краба" 23 | "🍭": "лядзяш" 24 | "🌍": "планету" 25 | "⚓": "якар" 26 | "🚀": "ракету" 27 | "💎": "алмаз" 28 | "🔥": "агонь" 29 | "🎈": "паветраны шар" 30 | "🕹": "джойсцік" 31 | "🔋": "батарэйку" 32 | "⚡": "маланку" 33 | "📢": "гучнагаварыцель" 34 | "🎤": "мікрафон" 35 | "🎵": "музычную нотку" 36 | "🥝": "ківі" 37 | "🍕": "піцу" 38 | "🍋": "цытрус" 39 | "🍓": "трускаўку" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/uk.yml: -------------------------------------------------------------------------------- 1 | "🐩": "пуделя" 2 | "🐿️": "білку" 3 | "🐓": "півня" 4 | "🐷": "п'ятачка" 5 | "🎂": "торт" 6 | "🍔": "бургер" 7 | "🔪": "ніж" 8 | "📱": "айфон" 9 | "🎁": "сектор приз на барабані" 10 | "🖥️": "комплюктер" 11 | "💡": "лампочку" 12 | "🥁": "барабан" 13 | "🎸": "гітару" 14 | "❤️": "лайк" 15 | "🌻": "соняшник" 16 | "🧦": "шкарпетки" 17 | "🌭": "хот-дог" 18 | "🍌": "банан" 19 | "🍎": "яблуко" 20 | "🐐": "козла" 21 | "🍉": "кавунчик" 22 | "🦀": "краба" 23 | "🍭": "льодяник" 24 | "🌍": "планету" 25 | "⚓": "якір" 26 | "🚀": "ракету" 27 | "💎": "алмаз" 28 | "🔥": "вогонь" 29 | "🎈": "повітряна куля" 30 | "🕹": "джойстик" 31 | "🔋": "батарейку" 32 | "⚡": "блискавку" 33 | "📢": "гучномовець" 34 | "🎤": "мікрофон" 35 | "🎵": "музичну нотку" 36 | "🥝": "ківі" 37 | "🍕": "піцу" 38 | "🍋": "цитрус" 39 | "🍓": "полуничку" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/el.yml: -------------------------------------------------------------------------------- 1 | "🐩": "σγουρόμαλλο σκυλάκι" 2 | "🐿️": "σκίουρος" 3 | "🐓": "κόκορας" 4 | "🐷": "χοίρος" 5 | "🎂": "κέικ" 6 | "🍔": "μπιφτέκι" 7 | "🔪": "μαχαίρι" 8 | "📱": "iPhone" 9 | "🎁": "δώρο" 10 | "🖥️": "προσωπικός υπολογιστής" 11 | "💡": "λαμπτήρας" 12 | "🥁": "τύμπανο" 13 | "🎸": "κιθάρα" 14 | "❤️": "όπως" 15 | "🧦": "κάλτσες" 16 | "🌭": "λουκάνικοg" 17 | "🍌": "μπανάνα" 18 | "🍎": "μήλο" 19 | "🐐": "κατσίκα" 20 | "🍉": "καρπούζι" 21 | "🦀": "καβούρι" 22 | "🍭": "γλειφιτζούρι" 23 | "🌍": "πλανήτης" 24 | "⚓": "άγκυρα" 25 | "🚀": "ρόκετ" 26 | "💎": "κόσμημα" 27 | "🔥": "φωτιά" 28 | "🎈": "μπαλόνι" 29 | "🕹": "χειριστήριο" 30 | "🔋": "μπαταρία" 31 | "⚡": "αστραπή" 32 | "📢": "ηχείο" 33 | "🎤": "μικρόφωνο" 34 | "🎵": "μουσική σημείωση" 35 | "🥝": "ακτινίδια" 36 | "🍕": "πίτσα" 37 | "🍋": "λεμόνι" 38 | "🍓": "φράουλα" -------------------------------------------------------------------------------- /resources/gatekeeper/challenges/ru.yml: -------------------------------------------------------------------------------- 1 | "🐩": "пуделя" 2 | "🐿️": "белку" 3 | "🐓": "петуха" 4 | "🐷": "пятачка" 5 | "🎂": "торт" 6 | "🍔": "бургер" 7 | "🔪": "нож" 8 | "📱": "айфон" 9 | "🎁": "сектор приз на барабане" 10 | "🖥️": "комплюктер" 11 | "💡": "лампочку" 12 | "🥁": "барабан" 13 | "🎸": "гитару" 14 | "❤️": "лайк" 15 | "🌻": "подсолнух" 16 | "🧦": "носки" 17 | "🌭": "хот-дог" 18 | "🍌": "банан" 19 | "🍎": "яблоко" 20 | "🐐": "козла" 21 | "🍉": "арбузик" 22 | "🦀": "краба" 23 | "🍭": "леденец" 24 | "🌍": "планету" 25 | "⚓": "якорь" 26 | "🚀": "ракету" 27 | "💎": "алмаз" 28 | "🔥": "огонь" 29 | "🎈": "воздушный шар" 30 | "🕹": "джойстик" 31 | "🔋": "батарейку" 32 | "⚡": "молнию" 33 | "📢": "громкоговоритель" 34 | "🎤": "микрофон" 35 | "🎵": "музыкальную нотку" 36 | "🥝": "киви" 37 | "🍕": "пиццу" 38 | "🍋": "цитрус" 39 | "🍓": "клубничку" 40 | -------------------------------------------------------------------------------- /internal/utils/text/cyrillics.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | // HasCyrillics checks if the given string contains any Cyrillic characters 4 | func HasCyrillics(content string) bool { 5 | for _, r := range content { 6 | if r >= 0x0400 && r <= 0x04FF { 7 | return true 8 | } 9 | } 10 | return false 11 | } 12 | 13 | // NormalizeCyrillics normalizes Cyrillic text by removing special characters and extra spaces 14 | func NormalizeCyrillics(content string) string { 15 | var result []rune 16 | var lastWasSpace bool 17 | for _, r := range content { 18 | if r >= 0x0400 && r <= 0x04FF || r == ' ' { 19 | if r == ' ' { 20 | if !lastWasSpace { 21 | result = append(result, r) 22 | lastWasSpace = true 23 | } 24 | } else { 25 | result = append(result, r) 26 | lastWasSpace = false 27 | } 28 | } 29 | } 30 | return string(result) 31 | } 32 | -------------------------------------------------------------------------------- /compose.yaml.dist: -------------------------------------------------------------------------------- 1 | services: 2 | ngbot: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | environment: 7 | - NG_TOKEN 8 | - NG_LANG 9 | - NG_HANDLERS 10 | - NG_LOG_LEVEL 11 | - NG_DOT_PATH 12 | - NG_LLM_API_KEY 13 | - NG_LLM_API_URL 14 | - NG_LLM_API_MODEL 15 | - NG_FLAGGED_EMOJIS 16 | - NG_SPAM_LOG_CHANNEL_USERNAME 17 | - NG_SPAM_VERBOSE 18 | - NG_SPAM_VOTING_TIMEOUT 19 | - NG_SPAM_MIN_VOTERS 20 | - NG_SPAM_MAX_VOTERS 21 | - NG_SPAM_MIN_VOTERS_PERCENTAGE 22 | - NG_SPAM_SUSPECT_NOTIFICATION_TIMEOUT 23 | volumes: 24 | # Set the path to your ngbot directory 25 | - dotpath:/root/.ngbot:delegated 26 | deploy: 27 | restart_policy: 28 | condition: on-failure 29 | delay: 5s 30 | 31 | volumes: 32 | dotpath: 33 | driver: local 34 | driver_opts: 35 | type: none 36 | # Set the path to your ngbot directory 37 | device: /home/username/.ngbot 38 | o: bind -------------------------------------------------------------------------------- /resources/migrations/1687008076-tidy-settings-up.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS "charade_scores"; 3 | DROP TABLE IF EXISTS "meta"; 4 | DROP TABLE IF EXISTS "chat_members"; 5 | 6 | ALTER TABLE "chats" RENAME TO "chats_old"; 7 | 8 | CREATE TABLE "chats" ( 9 | "id" INTEGER PRIMARY KEY, 10 | "enabled" BOOLEAN NOT NULL DEFAULT 1, 11 | "challenge_timeout" INTEGER NOT NULL DEFAULT 180, 12 | "reject_timeout" INTEGER NOT NULL DEFAULT 600, 13 | "language" TEXT NOT NULL DEFAULT 'en' 14 | ); 15 | INSERT INTO "chats" ("id", "enabled", "challenge_timeout", "reject_timeout", "language") 16 | SELECT co.id, true, 180, 600, co.language FROM "chats_old" co; 17 | DROP TABLE IF EXISTS "chats_old"; 18 | 19 | CREATE TABLE IF NOT EXISTS "chat_members" ( 20 | "chat_id" INTEGER NOT NULL, 21 | "user_id" INTEGER NOT NULL, 22 | PRIMARY KEY ("chat_id", "user_id"), 23 | FOREIGN KEY ("chat_id") REFERENCES "chats" ("id") ON DELETE CASCADE 24 | ); 25 | 26 | -- +migrate Down 27 | DROP TABLE IF EXISTS "chat_members"; 28 | DROP TABLE IF EXISTS "chats"; -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ## Required variables 2 | NG_TOKEN= # Telegram BOT API token 3 | NG_LLM_API_KEY= # LLM API key for content analysis 4 | 5 | ## Optional variables with defaults 6 | NG_LANG=en # Default language for new chats 7 | NG_HANDLERS=admin,gatekeeper,reactor # Enabled bot handlers 8 | NG_LOG_LEVEL=2 # Log level (0=Panic to 6=Trace) 9 | NG_DOT_PATH=~/.ngbot # Bot data storage path 10 | 11 | ## OpenAI settings 12 | NG_LLM_API_MODEL=gpt-4o-mini # LLM model 13 | NG_LLM_API_URL=https://api.openai.com/v1 # LLM API base URL 14 | NG_LLM_API_TYPE=openai # LLM API type {openai,gemini} 15 | 16 | ## Reactor settings 17 | NG_FLAGGED_EMOJIS=👎,💩 # Emojis for content flagging 18 | 19 | ## Spam control settings 20 | NG_SPAM_LOG_CHANNEL_USERNAME= # Channel for spam logging 21 | NG_SPAM_VERBOSE=false # Verbose in-chat notifications 22 | NG_SPAM_VOTING_TIMEOUT=5m # Voting time limit 23 | NG_SPAM_MIN_VOTERS=2 # Minimum required voters 24 | NG_SPAM_MAX_VOTERS=10 # Maximum voters cap 25 | NG_SPAM_MIN_VOTERS_PERCENTAGE=5 # Minimum voter percentage 26 | NG_SPAM_SUSPECT_NOTIFICATION_TIMEOUT=2m # Suspect notification timeout -------------------------------------------------------------------------------- /internal/event/bus.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ( 8 | bus struct { 9 | q chan Queueable 10 | } 11 | 12 | Queueable interface { 13 | Process() 14 | IsProcessed() bool 15 | Drop() 16 | IsDropped() bool 17 | Expired() bool 18 | Type() string 19 | } 20 | 21 | Base struct { 22 | processed bool 23 | dropped bool 24 | expireAt time.Time 25 | eventType string 26 | } 27 | ) 28 | 29 | func CreateBase(eventType string, expiresAt time.Time) *Base { 30 | return &Base{ 31 | expireAt: expiresAt, 32 | eventType: eventType, 33 | } 34 | } 35 | 36 | func (b *Base) Process() { 37 | b.processed = true 38 | } 39 | 40 | func (b *Base) IsProcessed() bool { 41 | return b.processed 42 | } 43 | 44 | func (b *Base) Drop() { 45 | b.dropped = true 46 | } 47 | 48 | func (b *Base) IsDropped() bool { 49 | return b.dropped 50 | } 51 | 52 | func (b *Base) Expired() bool { 53 | return time.Until(b.expireAt) < 0 54 | } 55 | 56 | func (b *Base) Type() string { 57 | return b.eventType 58 | } 59 | 60 | var Bus = &bus{q: make(chan Queueable, 100000)} 61 | 62 | // NQ adds an event to the queue 63 | func (b *bus) NQ(event Queueable) { 64 | go func() { b.q <- event }() 65 | } 66 | 67 | // DQ returns the next event from the queue or nil if the queue is empty 68 | func (b *bus) DQ() Queueable { 69 | select { 70 | case q := <-b.q: 71 | return q 72 | default: 73 | return nil 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /resources/migrations/20241112163044-update-channel-username-column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | ALTER TABLE spam_cases 3 | RENAME COLUMN channel_id TO channel_username; 4 | 5 | CREATE TABLE spam_cases_new ( 6 | id INTEGER PRIMARY KEY, 7 | chat_id INTEGER, 8 | user_id INTEGER, 9 | message_text TEXT, 10 | created_at DATETIME, 11 | channel_username TEXT, 12 | channel_post_id INTEGER, 13 | notification_message_id INTEGER, 14 | status TEXT, 15 | resolved_at DATETIME 16 | ); 17 | 18 | INSERT INTO spam_cases_new (id, chat_id, user_id, message_text, created_at, channel_username, channel_post_id, notification_message_id, status, resolved_at) 19 | SELECT id, chat_id, user_id, message_text, created_at, channel_username, channel_post_id, notification_message_id, status, resolved_at 20 | FROM spam_cases; 21 | 22 | DROP TABLE spam_cases; 23 | 24 | ALTER TABLE spam_cases_new RENAME TO spam_cases; 25 | 26 | -- +migrate Down 27 | ALTER TABLE spam_cases 28 | RENAME COLUMN channel_username TO channel_id; 29 | 30 | CREATE TABLE spam_cases_new ( 31 | id INTEGER PRIMARY KEY, 32 | chat_id INTEGER, 33 | user_id INTEGER, 34 | message_text TEXT, 35 | created_at DATETIME, 36 | channel_id INTEGER, 37 | channel_post_id INTEGER, 38 | notification_message_id INTEGER, 39 | status TEXT, 40 | resolved_at DATETIME 41 | ); 42 | 43 | INSERT INTO spam_cases_new (id, chat_id, user_id, message_text, created_at, channel_id, channel_post_id, notification_message_id, status, resolved_at) 44 | SELECT id, chat_id, user_id, message_text, created_at, channel_id, channel_post_id, notification_message_id, status, resolved_at 45 | FROM spam_cases; 46 | 47 | DROP TABLE spam_cases; 48 | 49 | ALTER TABLE spam_cases_new RENAME TO spam_cases; 50 | -------------------------------------------------------------------------------- /internal/bot/dependencies.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | 6 | api "github.com/OvyFlash/telegram-bot-api" 7 | "github.com/iamwavecut/ngbot/internal/db" 8 | ) 9 | 10 | // ServiceBot defines bot-specific operations 11 | type ServiceBot interface { 12 | GetBot() *api.BotAPI 13 | } 14 | 15 | // ServiceDB defines database-specific operations 16 | type ServiceDB interface { 17 | GetDB() db.Client 18 | } 19 | 20 | // Service defines the core bot service interface 21 | type Service interface { 22 | ServiceBot 23 | ServiceDB 24 | IsMember(ctx context.Context, chatID, userID int64) (bool, error) 25 | InsertMember(ctx context.Context, chatID, userID int64) error 26 | DeleteMember(ctx context.Context, chatID, userID int64) error 27 | GetSettings(ctx context.Context, chatID int64) (*db.Settings, error) 28 | SetSettings(ctx context.Context, settings *db.Settings) error 29 | GetLanguage(ctx context.Context, chatID int64, user *api.User) string 30 | } 31 | 32 | // Handler defines the interface for all update handlers in the system 33 | type Handler interface { 34 | Handle(ctx context.Context, u *api.Update, chat *api.Chat, user *api.User) (proceed bool, err error) 35 | } 36 | 37 | // Client defines the database interface 38 | type Client interface { 39 | GetSettings(ctx context.Context, chatID int64) (*db.Settings, error) 40 | SetSettings(ctx context.Context, settings *db.Settings) error 41 | 42 | // Spam control methods 43 | CreateSpamCase(ctx context.Context, sc *db.SpamCase) (*db.SpamCase, error) 44 | UpdateSpamCase(ctx context.Context, sc *db.SpamCase) error 45 | GetSpamCase(ctx context.Context, id int64) (*db.SpamCase, error) 46 | GetSpamVotes(ctx context.Context, caseID int64) ([]*db.SpamVote, error) 47 | AddSpamVote(ctx context.Context, vote *db.SpamVote) error 48 | } 49 | -------------------------------------------------------------------------------- /internal/i18n/i18n.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/iamwavecut/ngbot/internal/config" 8 | "github.com/iamwavecut/ngbot/internal/infra" 9 | "github.com/iamwavecut/ngbot/resources" 10 | 11 | log "github.com/sirupsen/logrus" 12 | "gopkg.in/yaml.v2" 13 | ) 14 | 15 | var state = struct { 16 | translations map[string]map[string]string // [key][lang][translation] 17 | resourcesPath string 18 | defaultLanguage string 19 | availableLanguages []string 20 | }{ 21 | translations: map[string]map[string]string{}, 22 | defaultLanguage: config.Get().DefaultLanguage, 23 | resourcesPath: infra.GetResourcesPath("i18n"), 24 | availableLanguages: []string{"en"}, 25 | } 26 | 27 | func Init() { 28 | if len(state.translations) > 0 { 29 | return 30 | } 31 | i18n, err := resources.FS.ReadFile(state.resourcesPath + "/translations.yml") 32 | if err != nil { 33 | log.WithField("error", err.Error()).Errorln("cant load translations") 34 | return 35 | } 36 | if err := yaml.Unmarshal(i18n, &(state.translations)); err != nil { 37 | log.WithField("error", err.Error()).Errorln("cant unmarshal translations") 38 | return 39 | } 40 | languages := map[string]struct{}{} 41 | for _, langs := range state.translations { 42 | for lang := range langs { 43 | languages[strings.ToLower(lang)] = struct{}{} 44 | } 45 | } 46 | for lang := range languages { 47 | state.availableLanguages = append(state.availableLanguages, lang) 48 | } 49 | sort.Strings(state.availableLanguages) 50 | log.Traceln("languages count:", len(state.availableLanguages)) 51 | } 52 | 53 | func GetLanguagesList() []string { 54 | return state.availableLanguages[:] 55 | } 56 | 57 | func Get(key, lang string) string { 58 | if "en" == lang { 59 | return key 60 | } 61 | if res, ok := state.translations[key][strings.ToUpper(lang)]; ok { 62 | return res 63 | } 64 | log.Traceln(`no "` + lang + `" translation for key "` + key + `"`) 65 | return key 66 | } 67 | -------------------------------------------------------------------------------- /internal/event/worker.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | type worker struct { 11 | subscriptions map[string][]func(event Queueable) 12 | logger *log.Entry 13 | } 14 | 15 | var instance = &worker{ 16 | subscriptions: map[string][]func(event Queueable){}, 17 | logger: log.WithField("context", "event_worker"), 18 | } 19 | 20 | func RunWorker() context.CancelFunc { 21 | ctx, cancelFunc := context.WithCancel(context.Background()) 22 | instance.Run(ctx) 23 | return cancelFunc 24 | } 25 | 26 | func (w *worker) Run(ctx context.Context) { 27 | if w.logger == nil { 28 | w.logger = log.WithField("context", "event_worker") 29 | } 30 | 31 | done := ctx.Done() 32 | toProfile := false 33 | profileTicker := time.NewTicker(time.Minute * 5) 34 | 35 | go func() { 36 | for { 37 | select { 38 | case <-done: 39 | return 40 | case <-profileTicker.C: 41 | toProfile = true 42 | } 43 | } 44 | }() 45 | 46 | go func() { 47 | w.logger.Trace("events runner go") 48 | var event Queueable 49 | for { 50 | select { 51 | case <-done: 52 | w.logger.Info("shutting down event worker by cancelled context") 53 | return 54 | default: 55 | time.Sleep(1 * time.Millisecond) 56 | event = Bus.DQ() 57 | if event == nil { 58 | continue 59 | } 60 | 61 | if event.Expired() { 62 | continue 63 | } 64 | 65 | subscribers, ok := w.subscriptions[event.Type()] 66 | if !ok { 67 | Bus.NQ(event) 68 | continue 69 | } 70 | for _, sub := range subscribers { 71 | sub(event) 72 | if event.IsDropped() { 73 | continue 74 | } 75 | } 76 | 77 | if event.IsDropped() { 78 | continue 79 | } 80 | if !event.IsProcessed() { 81 | Bus.NQ(event) 82 | } 83 | 84 | if qLen := len(Bus.q); toProfile && qLen > 0 { 85 | w.logger.WithField("queue_length", qLen).Debug("unprocessed queue length") 86 | } 87 | } 88 | } 89 | }() 90 | } 91 | -------------------------------------------------------------------------------- /internal/config/log_formatter.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "runtime" 7 | "strconv" 8 | "strings" 9 | 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | type NbFormatter struct{} 14 | 15 | func (f *NbFormatter) Format(entry *log.Entry) ([]byte, error) { 16 | const ( 17 | red = 31 18 | yellow = 33 19 | blue = 36 20 | gray = 37 21 | green = 32 22 | cyan = 96 23 | lightYellow = 93 24 | lightGreen = 92 25 | ) 26 | levelColor := blue 27 | switch entry.Level { 28 | case 5, 6: 29 | levelColor = gray 30 | case 3: 31 | levelColor = yellow 32 | case 2, 1, 0: 33 | levelColor = red 34 | case 4: 35 | levelColor = blue 36 | } 37 | level := fmt.Sprintf( 38 | "\x1b[%dm%s\x1b[0m", 39 | levelColor, 40 | strings.ToUpper(entry.Level.String())[:4], 41 | ) 42 | 43 | output := fmt.Sprintf("\x1b[%dm%s\x1b[0m=%s", cyan, "level", level) 44 | output += fmt.Sprintf(" \x1b[%dm%s\x1b[0m=\x1b[%dm%s\x1b[0m", cyan, "ts", lightYellow, entry.Time.Format("2006-01-02 15:04:05.000")) 45 | _, file, line, ok := runtime.Caller(6) 46 | if ok { 47 | shortFile := file[strings.LastIndex(file, "/")+1:] 48 | output += fmt.Sprintf(" \x1b[%dm%s\x1b[0m=\x1b[%dm%s:%d\x1b[0m", cyan, "source", lightYellow, shortFile, line) 49 | } 50 | 51 | for k, val := range entry.Data { 52 | var s string 53 | if m, err := json.Marshal(val); err == nil { 54 | s = string(m) 55 | } 56 | if s == "" { 57 | continue 58 | } 59 | valueColor := cyan 60 | if _, err := strconv.ParseFloat(s, 64); err == nil { 61 | valueColor = green 62 | } else if strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") { 63 | valueColor = lightYellow 64 | } 65 | output += fmt.Sprintf(" \x1b[%dm%s\x1b[0m=\x1b[%dm%s\x1b[0m", cyan, k, valueColor, s) 66 | } 67 | output += fmt.Sprintf(" \x1b[%dm%s\x1b[0m=\x1b[%dm\"%s\"\x1b[0m", cyan, "msg", lightGreen, entry.Message) 68 | output = strings.Replace(output, "\r", "\\r", -1) 69 | output = strings.Replace(output, "\n", "\\n", -1) + "\n" 70 | return []byte(output), nil 71 | } 72 | -------------------------------------------------------------------------------- /internal/observability/setup.go: -------------------------------------------------------------------------------- 1 | package observability 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promhttp" 9 | log "github.com/sirupsen/logrus" 10 | "go.opentelemetry.io/otel" 11 | "go.opentelemetry.io/otel/sdk/trace" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | var ( 16 | // Global logger instance 17 | Logger *zap.Logger 18 | 19 | // Metrics 20 | spamMessagesTotal = prometheus.NewCounterVec( 21 | prometheus.CounterOpts{ 22 | Name: "spam_messages_total", 23 | Help: "Total number of spam messages detected", 24 | }, 25 | []string{"type"}, 26 | ) 27 | 28 | messageProcessingDuration = prometheus.NewHistogramVec( 29 | prometheus.HistogramOpts{ 30 | Name: "message_processing_duration_seconds", 31 | Help: "Time spent processing messages", 32 | Buckets: prometheus.DefBuckets, 33 | }, 34 | []string{"status"}, 35 | ) 36 | ) 37 | 38 | func Init(ctx context.Context) error { 39 | // Initialize logger 40 | var err error 41 | Logger, err = zap.NewProduction() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // Register metrics 47 | prometheus.MustRegister(spamMessagesTotal) 48 | prometheus.MustRegister(messageProcessingDuration) 49 | 50 | // Setup OpenTelemetry (simplified setup) 51 | tp := trace.NewTracerProvider() 52 | otel.SetTracerProvider(tp) 53 | 54 | // Start Prometheus metrics endpoint 55 | go func() { 56 | http.Handle("/metrics", promhttp.Handler()) 57 | if err := http.ListenAndServe(":2112", nil); err != nil { 58 | log.WithError(err).Error("metrics server failed") 59 | } 60 | }() 61 | 62 | return nil 63 | } 64 | 65 | // RecordSpamDetection records a spam message detection 66 | func RecordSpamDetection(spamType string) { 67 | spamMessagesTotal.WithLabelValues(spamType).Inc() 68 | } 69 | 70 | // StartMessageProcessing returns a function to record message processing duration 71 | func StartMessageProcessing() func(status string) { 72 | start := prometheus.NewTimer(messageProcessingDuration.WithLabelValues("processing")) 73 | return func(status string) { 74 | start.ObserveDuration() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/db/dependencies.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Client interface { 8 | Close() error 9 | SetSettings(ctx context.Context, settings *Settings) error 10 | GetSettings(ctx context.Context, chatID int64) (*Settings, error) 11 | GetAllSettings(ctx context.Context) (map[int64]*Settings, error) 12 | InsertMember(ctx context.Context, chatID int64, userID int64) error 13 | InsertMembers(ctx context.Context, chatID int64, userIDs []int64) error 14 | DeleteMember(ctx context.Context, chatID int64, userID int64) error 15 | DeleteMembers(ctx context.Context, chatID int64, userIDs []int64) error 16 | GetMembers(ctx context.Context, chatID int64) ([]int64, error) 17 | GetAllMembers(ctx context.Context) (map[int64][]int64, error) 18 | IsMember(ctx context.Context, chatID int64, userID int64) (bool, error) 19 | 20 | // KV store methods 21 | GetKV(ctx context.Context, key string) (string, error) 22 | SetKV(ctx context.Context, key string, value string) error 23 | 24 | // Spam tracking methods 25 | AddRestriction(ctx context.Context, restriction *UserRestriction) error 26 | GetActiveRestriction(ctx context.Context, chatID, userID int64) (*UserRestriction, error) 27 | RemoveRestriction(ctx context.Context, chatID, userID int64) error 28 | RemoveExpiredRestrictions(ctx context.Context) error 29 | 30 | // Spam control methods 31 | CreateSpamCase(ctx context.Context, sc *SpamCase) (*SpamCase, error) 32 | UpdateSpamCase(ctx context.Context, sc *SpamCase) error 33 | GetSpamCase(ctx context.Context, id int64) (*SpamCase, error) 34 | GetPendingSpamCases(ctx context.Context) ([]*SpamCase, error) 35 | GetActiveSpamCase(ctx context.Context, chatID int64, userID int64) (*SpamCase, error) 36 | AddSpamVote(ctx context.Context, vote *SpamVote) error 37 | GetSpamVotes(ctx context.Context, caseID int64) ([]*SpamVote, error) 38 | AddChatRecentJoiner(ctx context.Context, joiner *RecentJoiner) (*RecentJoiner, error) 39 | GetChatRecentJoiners(ctx context.Context, chatID int64) ([]*RecentJoiner, error) 40 | GetUnprocessedRecentJoiners(ctx context.Context) ([]*RecentJoiner, error) 41 | ProcessRecentJoiner(ctx context.Context, chatID int64, userID int64, isSpammer bool) error 42 | UpsertBanlist(ctx context.Context, userIDs []int64) error 43 | GetBanlist(ctx context.Context) (map[int64]struct{}, error) 44 | } 45 | -------------------------------------------------------------------------------- /internal/handlers/base/handler.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | api "github.com/OvyFlash/telegram-bot-api" 9 | "github.com/iamwavecut/ngbot/internal/bot" 10 | "github.com/iamwavecut/ngbot/internal/db" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | const ( 15 | defaultChallengeTimeout = 5 * time.Minute 16 | defaultRejectTimeout = 10 * time.Minute 17 | ) 18 | 19 | // BaseHandler provides common functionality for all handlers 20 | type BaseHandler struct { 21 | service bot.Service 22 | logger *log.Entry 23 | } 24 | 25 | // NewBaseHandler creates a new base handler 26 | func NewBaseHandler(service bot.Service, handlerName string) *BaseHandler { 27 | return &BaseHandler{ 28 | service: service, 29 | logger: log.WithField("handler", handlerName), 30 | } 31 | } 32 | 33 | // GetService returns the bot service 34 | func (h *BaseHandler) GetService() bot.Service { 35 | return h.service 36 | } 37 | 38 | // GetLogger returns the handler's logger 39 | func (h *BaseHandler) GetLogger() *log.Entry { 40 | return h.logger 41 | } 42 | 43 | // ValidateUpdate performs common update validation 44 | func (h *BaseHandler) ValidateUpdate(u *api.Update, chat *api.Chat, user *api.User) error { 45 | if u == nil { 46 | return ErrNilUpdate 47 | } 48 | if chat == nil || user == nil { 49 | return ErrNilChatOrUser 50 | } 51 | return nil 52 | } 53 | 54 | // GetOrCreateSettings retrieves or creates default settings for a chat 55 | func (h *BaseHandler) GetOrCreateSettings(ctx context.Context, chat *api.Chat) (*db.Settings, error) { 56 | settings, err := h.service.GetSettings(ctx, chat.ID) 57 | if err != nil { 58 | return nil, err 59 | } 60 | if settings == nil { 61 | settings = &db.Settings{ 62 | ID: chat.ID, 63 | Enabled: true, 64 | ChallengeTimeout: defaultChallengeTimeout.Nanoseconds(), 65 | RejectTimeout: defaultRejectTimeout.Nanoseconds(), 66 | Language: "en", 67 | } 68 | if err := h.service.SetSettings(ctx, settings); err != nil { 69 | return nil, err 70 | } 71 | } 72 | return settings, nil 73 | } 74 | 75 | // GetLanguage returns the language for a chat/user 76 | func (h *BaseHandler) GetLanguage(ctx context.Context, chat *api.Chat, user *api.User) string { 77 | return h.service.GetLanguage(ctx, chat.ID, user) 78 | } 79 | 80 | var ( 81 | ErrNilUpdate = errors.New("nil update") 82 | ErrNilChatOrUser = errors.New("nil chat or user") 83 | ) 84 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/sethvargo/go-envconfig" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type ( 15 | Config struct { 16 | TelegramAPIToken string `env:"TOKEN,required"` 17 | DefaultLanguage string `env:"LANG,default=en"` 18 | EnabledHandlers []string `env:"HANDLERS,default=admin,gatekeeper,reactor"` 19 | LogLevel int `env:"LOG_LEVEL,default=2"` 20 | DotPath string `env:"DOT_PATH,default=~/.ngbot"` 21 | LLM LLM 22 | Reactor Reactor 23 | SpamControl SpamControl 24 | } 25 | 26 | LLM struct { 27 | APIKey string `env:"LLM_API_KEY,required"` 28 | Model string `env:"LLM_API_MODEL,default=gpt-4o-mini"` 29 | BaseURL string `env:"LLM_API_URL,default=https://api.openai.com/v1"` 30 | Type string `env:"LLM_API_TYPE,default=openai"` 31 | } 32 | 33 | Reactor struct { 34 | FlaggedEmojis []string `env:"FLAGGED_EMOJIS,default=👎,💩"` 35 | } 36 | 37 | SpamControl struct { 38 | LogChannelUsername string `env:"SPAM_LOG_CHANNEL_USERNAME"` 39 | DebugUserID int64 `env:"SPAM_DEBUG_USER_ID"` 40 | MinVoters int `env:"SPAM_MIN_VOTERS,default=2"` 41 | MaxVoters int `env:"SPAM_MAX_VOTERS,default=10"` 42 | MinVotersPercentage float64 `env:"SPAM_MIN_VOTERS_PERCENTAGE,default=5"` 43 | Verbose bool `env:"SPAM_VERBOSE,default=false"` 44 | 45 | VotingTimeoutMinutes time.Duration `env:"SPAM_VOTING_TIMEOUT,default=5m"` 46 | SuspectNotificationTimeout time.Duration `env:"SPAM_SUSPECT_NOTIFICATION_TIMEOUT,default=2m"` 47 | } 48 | ) 49 | 50 | var ( 51 | once sync.Once 52 | globalConfig = &Config{} 53 | ) 54 | 55 | func Get() Config { 56 | once.Do(func() { 57 | cfg := &Config{} 58 | envcfg := envconfig.Config{ 59 | Lookuper: envconfig.PrefixLookuper("NG_", envconfig.OsLookuper()), 60 | Target: cfg, 61 | } 62 | if err := envconfig.ProcessWith(context.Background(), &envcfg); err != nil { 63 | log.WithField("error", err.Error()).Fatalln("cant load config") 64 | } 65 | home, err := os.UserHomeDir() 66 | if err != nil { 67 | log.WithField("error", err.Error()).Fatalln("failed to get user home directory") 68 | } 69 | cfg.DotPath = strings.Replace(cfg.DotPath, "~", home, 1) 70 | log.Traceln("loaded config") 71 | globalConfig = cfg 72 | }) 73 | return *globalConfig 74 | } 75 | -------------------------------------------------------------------------------- /internal/db/entities.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/iamwavecut/ngbot/internal/config" 7 | ) 8 | 9 | type ( 10 | Settings struct { 11 | ID int64 `db:"id"` 12 | Language string `db:"language"` 13 | Enabled bool `db:"enabled"` 14 | ChallengeTimeout int64 `db:"challenge_timeout"` 15 | RejectTimeout int64 `db:"reject_timeout"` 16 | } 17 | 18 | SpamCase struct { 19 | ID int64 `db:"id"` 20 | ChatID int64 `db:"chat_id"` 21 | UserID int64 `db:"user_id"` 22 | MessageText string `db:"message_text"` 23 | CreatedAt time.Time `db:"created_at"` 24 | ChannelUsername string `db:"channel_username"` 25 | ChannelPostID int `db:"channel_post_id"` 26 | NotificationMessageID int `db:"notification_message_id"` 27 | Status string `db:"status"` // pending, spam, false_positive 28 | ResolvedAt *time.Time `db:"resolved_at"` 29 | } 30 | 31 | SpamVote struct { 32 | CaseID int64 `db:"case_id"` 33 | VoterID int64 `db:"voter_id"` 34 | Vote bool `db:"vote"` // true = not spam, false = spam 35 | VotedAt time.Time `db:"voted_at"` 36 | } 37 | 38 | RecentJoiner struct { 39 | ID int64 `db:"id"` 40 | JoinMessageID int `db:"join_message_id"` 41 | ChatID int64 `db:"chat_id"` 42 | UserID int64 `db:"user_id"` 43 | Username string `db:"username"` 44 | JoinedAt time.Time `db:"joined_at"` 45 | Processed bool `db:"processed"` 46 | IsSpammer bool `db:"is_spammer"` 47 | } 48 | ) 49 | 50 | const ( 51 | defaultChallengeTimeout = 3 * time.Minute 52 | defaultRejectTimeout = 10 * time.Minute 53 | ) 54 | 55 | // GetLanguage Returns chat's set language 56 | func (cm *Settings) GetLanguage() (string, error) { 57 | if cm == nil { 58 | return config.Get().DefaultLanguage, nil 59 | } 60 | if cm.Language == "" { 61 | return config.Get().DefaultLanguage, nil 62 | } 63 | return cm.Language, nil 64 | } 65 | 66 | // GetChallengeTimeout Returns chat entry challenge timeout duration 67 | func (cm *Settings) GetChallengeTimeout() time.Duration { 68 | if cm == nil { 69 | return defaultChallengeTimeout 70 | } 71 | if cm.ChallengeTimeout == 0 { 72 | cm.ChallengeTimeout = defaultChallengeTimeout.Nanoseconds() 73 | } 74 | return time.Duration(cm.ChallengeTimeout) 75 | } 76 | 77 | // GetRejectTimeout Returns chat entry reject timeout duration 78 | func (cm *Settings) GetRejectTimeout() time.Duration { 79 | if cm == nil { 80 | return defaultRejectTimeout 81 | } 82 | if cm.RejectTimeout == 0 { 83 | cm.RejectTimeout = defaultRejectTimeout.Nanoseconds() 84 | } 85 | return time.Duration(cm.RejectTimeout) 86 | } 87 | -------------------------------------------------------------------------------- /project.md: -------------------------------------------------------------------------------- 1 | ```mermaid 2 | graph TD 3 | %% Nodes Definition 4 | A[Start] --> B[Initialize Configuration] 5 | B --> C[Load Environment Variables] 6 | C --> D[Configure Logging] 7 | D --> E[Initialize Database] 8 | E --> F{Is Database Migration Needed?} 9 | F -->|Yes| G[Run Migrations] 10 | F -->|No| H[Proceed to Service Initialization] 11 | G --> H 12 | H --> I[Initialize Bot API] 13 | I --> J[Set Up Handlers] 14 | J --> K[Register Event Handlers] 15 | K --> L[Start Receiving Updates] 16 | 17 | %% Event Handling Flow 18 | L --> M{Received Update?} 19 | M -->|Yes| N[Validate Update] 20 | M -->|No| L 21 | N --> O{Is Update Relevant?} 22 | O -->|Yes| P[Determine Update Type] 23 | O -->|No| L 24 | 25 | %% Update Type Handling 26 | P --> Q{Update Type?} 27 | Q -->|Message| R[Process Message] 28 | Q -->|Callback Query| S[Process Callback Query] 29 | Q -->|Chat Join Request| T[Process Join Request] 30 | Q -->|Other| U[Handle Other Update Types] 31 | 32 | %% Message Processing Flow 33 | R --> V[Check if User is Member] 34 | V --> W{Is Member?} 35 | W -->|Yes| X[Skip Processing] 36 | W -->|No| Y[Check Ban Status] 37 | Y --> Z{Is Banned?} 38 | Z -->|Yes| AA[Process Banned Message] 39 | Z -->|No| AB[Check Content] 40 | AB --> AC{Is Content Empty?} 41 | AC -->|Yes| X 42 | AC -->|No| AD[Check for Spam] 43 | AD --> AE{Is Spam?} 44 | AE -->|Yes| AF[Process Spam Message] 45 | AE -->|No| AG[Add User as Member] 46 | AG --> X 47 | 48 | %% Process Banned Message 49 | AA --> AH[Delete Message] 50 | AH --> AI[Ban User] 51 | AI --> X 52 | 53 | %% Process Spam Message 54 | AF --> AJ[Delete Message] 55 | AJ --> AK[Ban User] 56 | AK --> X 57 | 58 | %% Process Callback Query 59 | S --> AL[Validate Callback Data] 60 | AL --> AM{Is Challenge Callback?} 61 | AM -->|Yes| AN[Process Challenge] 62 | AM -->|No| AO[Ignore Callback] 63 | AN --> AP[Validate Challenge Attempt] 64 | AP --> AQ{Is Attempt Valid?} 65 | AQ -->|Yes| AR[Approve Join] 66 | AQ -->|No| AS[Reject Join] 67 | AR --> AT[Delete Challenge Message] 68 | AR --> AU[Approve Join Request] 69 | AU --> AV[Send Welcome Message] 70 | AV --> X 71 | AS --> AW[Delete Challenge Message] 72 | AS --> AX[Reject Join Request] 73 | AX --> AY[Send Rejection Message] 74 | AY --> X 75 | 76 | %% Process Join Request 77 | T --> AZ[Check if User is Banned] 78 | AZ --> BA{Is Banned?} 79 | BA -->|Yes| BB[Ban User] 80 | BA -->|No| BC[Send Challenge Message] 81 | BC --> BD[Wait for Response] 82 | BD --> BE{Is Response Correct?} 83 | BE -->|Yes| BF[Approve Join] 84 | BE -->|No| BG[Reject Join] 85 | BF --> BH[Delete Challenge Message] 86 | BF --> BI[Send Welcome Message] 87 | BI --> X 88 | BG --> BJ[Delete Challenge Message] 89 | BG --> BK[Send Rejection Message] 90 | BK --> X 91 | 92 | %% Other Update Types 93 | U --> BL[Handle Other Updates] 94 | BL --> X 95 | 96 | %% Styling 97 | classDef handler fill:#f9f,stroke:#333,stroke-width:2px; 98 | class A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,AA,AB,AC,AD,AE,AF,AG,AH,AI,AJ,AK,AL,AM,AN,AO,AP,AQ,AR,AS,AT,AU,AV,AW,AX,AY,AZ,BA,BB,BC,BD,BE,BF,BG,BH,BI,BJ,BK,BL class handler; 99 | ``` -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/iamwavecut/ngbot 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/OvyFlash/telegram-bot-api v0.0.0-20241219171906-3f2ca0c14ada 9 | github.com/google/generative-ai-go v0.19.0 10 | github.com/jmoiron/sqlx v1.4.0 11 | github.com/mitchellh/go-homedir v1.1.0 12 | github.com/pborman/uuid v1.2.1 13 | github.com/pkg/errors v0.9.1 14 | github.com/prometheus/client_golang v1.21.1 15 | github.com/rubenv/sql-migrate v1.7.1 16 | github.com/sirupsen/logrus v1.9.3 17 | go.opentelemetry.io/otel v1.35.0 18 | go.opentelemetry.io/otel/sdk v1.35.0 19 | go.uber.org/zap v1.27.0 20 | golang.org/x/sync v0.12.0 21 | google.golang.org/api v0.224.0 22 | gopkg.in/yaml.v2 v2.4.0 23 | modernc.org/sqlite v1.36.0 24 | ) 25 | 26 | require ( 27 | cloud.google.com/go v0.118.3 // indirect 28 | cloud.google.com/go/ai v0.10.0 // indirect 29 | cloud.google.com/go/auth v0.15.0 // indirect 30 | cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect 31 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 32 | cloud.google.com/go/longrunning v0.6.5 // indirect 33 | github.com/beorn7/perks v1.0.1 // indirect 34 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 35 | github.com/dustin/go-humanize v1.0.1 // indirect 36 | github.com/felixge/httpsnoop v1.0.4 // indirect 37 | github.com/go-logr/logr v1.4.2 // indirect 38 | github.com/go-logr/stdr v1.2.2 // indirect 39 | github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect 40 | github.com/google/s2a-go v0.1.9 // indirect 41 | github.com/google/uuid v1.6.0 // indirect 42 | github.com/googleapis/enterprise-certificate-proxy v0.3.5 // indirect 43 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 44 | github.com/klauspost/compress v1.18.0 // indirect 45 | github.com/mattn/go-isatty v0.0.20 // indirect 46 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 47 | github.com/ncruces/go-strftime v0.1.9 // indirect 48 | github.com/prometheus/client_model v0.6.1 // indirect 49 | github.com/prometheus/common v0.62.0 // indirect 50 | github.com/prometheus/procfs v0.15.1 // indirect 51 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 52 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 53 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 54 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 55 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 56 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 57 | go.uber.org/multierr v1.11.0 // indirect 58 | golang.org/x/crypto v0.36.0 // indirect 59 | golang.org/x/net v0.38.0 // indirect 60 | golang.org/x/oauth2 v0.28.0 // indirect 61 | golang.org/x/text v0.23.0 // indirect 62 | golang.org/x/time v0.11.0 // indirect 63 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect 64 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect 65 | google.golang.org/grpc v1.71.0 // indirect 66 | google.golang.org/protobuf v1.36.5 // indirect 67 | modernc.org/libc v1.61.13 // indirect 68 | modernc.org/mathutil v1.7.1 // indirect 69 | modernc.org/memory v1.8.2 // indirect 70 | ) 71 | 72 | require ( 73 | github.com/go-gorp/gorp/v3 v3.1.0 // indirect 74 | github.com/iamwavecut/tool v1.3.0 75 | github.com/sashabaranov/go-openai v1.38.0 76 | github.com/sethvargo/go-envconfig v1.1.1 77 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect 78 | golang.org/x/sys v0.31.0 // indirect 79 | ) 80 | -------------------------------------------------------------------------------- /internal/infrastructure/telegram/operations.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | api "github.com/OvyFlash/telegram-bot-api" 10 | ) 11 | 12 | // Operations provides common Telegram bot operations 13 | type Operations struct { 14 | bot *api.BotAPI 15 | } 16 | 17 | // NewOperations creates a new Operations instance 18 | func NewOperations(bot *api.BotAPI) *Operations { 19 | return &Operations{bot: bot} 20 | } 21 | 22 | // DeleteMessage deletes a message from a chat 23 | func (o *Operations) DeleteMessage(ctx context.Context, chatID int64, messageID int) error { 24 | _, err := o.bot.Request(api.NewDeleteMessage(chatID, messageID)) 25 | if err != nil { 26 | return fmt.Errorf("failed to delete message: %w", err) 27 | } 28 | return nil 29 | } 30 | 31 | // BanUser bans a user from a chat 32 | func (o *Operations) BanUser(ctx context.Context, userID int64, chatID int64) error { 33 | config := api.BanChatMemberConfig{ 34 | ChatMemberConfig: api.ChatMemberConfig{ 35 | ChatConfig: api.ChatConfig{ 36 | ChatID: chatID, 37 | }, 38 | UserID: userID, 39 | }, 40 | UntilDate: time.Now().Add(10 * time.Minute).Unix(), 41 | RevokeMessages: true, 42 | } 43 | _, err := o.bot.Request(config) 44 | if err != nil { 45 | if strings.Contains(err.Error(), "not enough rights") { 46 | return fmt.Errorf("not enough rights to ban user") 47 | } 48 | return fmt.Errorf("failed to ban user: %w", err) 49 | } 50 | return nil 51 | } 52 | 53 | // RestrictUser restricts a user's ability to chat 54 | func (o *Operations) RestrictUser(ctx context.Context, userID int64, chatID int64) error { 55 | untilDate := time.Now().Add(24 * time.Hour) 56 | config := api.RestrictChatMemberConfig{ 57 | ChatMemberConfig: api.ChatMemberConfig{ 58 | ChatConfig: api.ChatConfig{ 59 | ChatID: chatID, 60 | }, 61 | UserID: userID, 62 | }, 63 | UntilDate: untilDate.Unix(), 64 | Permissions: &api.ChatPermissions{ 65 | CanSendMessages: false, 66 | CanSendOtherMessages: false, 67 | CanAddWebPagePreviews: false, 68 | }, 69 | } 70 | _, err := o.bot.Request(config) 71 | if err != nil { 72 | return fmt.Errorf("failed to restrict user: %w", err) 73 | } 74 | return nil 75 | } 76 | 77 | // UnrestrictUser removes chat restrictions from a user 78 | func (o *Operations) UnrestrictUser(ctx context.Context, userID int64, chatID int64) error { 79 | config := api.RestrictChatMemberConfig{ 80 | ChatMemberConfig: api.ChatMemberConfig{ 81 | ChatConfig: api.ChatConfig{ 82 | ChatID: chatID, 83 | }, 84 | UserID: userID, 85 | }, 86 | Permissions: &api.ChatPermissions{ 87 | CanSendMessages: true, 88 | CanSendOtherMessages: true, 89 | CanAddWebPagePreviews: true, 90 | }, 91 | } 92 | _, err := o.bot.Request(config) 93 | if err != nil { 94 | return fmt.Errorf("failed to unrestrict user: %w", err) 95 | } 96 | return nil 97 | } 98 | 99 | // ApproveJoinRequest approves a chat join request 100 | func (o *Operations) ApproveJoinRequest(ctx context.Context, userID int64, chatID int64) error { 101 | config := api.ApproveChatJoinRequestConfig{ 102 | ChatConfig: api.ChatConfig{ 103 | ChatID: chatID, 104 | }, 105 | UserID: userID, 106 | } 107 | _, err := o.bot.Request(config) 108 | if err != nil { 109 | return fmt.Errorf("failed to approve join request: %w", err) 110 | } 111 | return nil 112 | } 113 | 114 | // DeclineJoinRequest declines a chat join request 115 | func (o *Operations) DeclineJoinRequest(ctx context.Context, userID int64, chatID int64) error { 116 | config := api.DeclineChatJoinRequest{ 117 | ChatConfig: api.ChatConfig{ 118 | ChatID: chatID, 119 | }, 120 | UserID: userID, 121 | } 122 | _, err := o.bot.Request(config) 123 | if err != nil { 124 | return fmt.Errorf("failed to decline join request: %w", err) 125 | } 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /internal/adapters/llm/openai/openai.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/iamwavecut/ngbot/internal/adapters" 9 | "github.com/iamwavecut/ngbot/internal/adapters/llm" 10 | "github.com/sashabaranov/go-openai" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type API struct { 15 | client *openai.Client 16 | systemPrompt string 17 | model string 18 | parameters *llm.GenerationParameters 19 | logger *log.Entry 20 | } 21 | 22 | const DefaultModel = "gpt-4o-mini" 23 | 24 | func NewOpenAI(apiKey, model, baseURL string, logger *log.Entry) adapters.LLM { 25 | config := openai.DefaultConfig(apiKey) 26 | config.BaseURL = baseURL 27 | client := openai.NewClientWithConfig(config) 28 | api := &API{ 29 | client: client, 30 | logger: logger, 31 | } 32 | api.WithModel(model) 33 | api.WithParameters(nil) 34 | return api 35 | } 36 | 37 | func (o *API) WithModel(modelName string) adapters.LLM { 38 | if modelName == "" { 39 | modelName = DefaultModel 40 | } 41 | o.model = modelName 42 | return o 43 | } 44 | 45 | func (o *API) WithParameters(parameters *llm.GenerationParameters) adapters.LLM { 46 | if parameters == nil || parameters == (&llm.GenerationParameters{}) { 47 | parameters = &llm.GenerationParameters{ 48 | Temperature: 0.9, 49 | TopP: 0.9, 50 | TopK: 50, 51 | MaxOutputTokens: 8192, 52 | } 53 | } 54 | o.parameters = parameters 55 | return o 56 | } 57 | 58 | func (o *API) WithSystemPrompt(prompt string) adapters.LLM { 59 | o.systemPrompt = prompt 60 | return o 61 | } 62 | 63 | func (o *API) ChatCompletion(ctx context.Context, messages []llm.ChatCompletionMessage) (llm.ChatCompletionResponse, error) { 64 | var openaiMessages []openai.ChatCompletionMessage 65 | systemPrompt := o.systemPrompt 66 | 67 | for _, msg := range messages { 68 | if msg.Role == openai.ChatMessageRoleSystem { 69 | systemPrompt = msg.Content 70 | continue 71 | } 72 | openaiMessages = append(openaiMessages, openai.ChatCompletionMessage{ 73 | Role: msg.Role, 74 | Content: msg.Content, 75 | }) 76 | } 77 | openaiMessages = append([]openai.ChatCompletionMessage{ 78 | { 79 | Role: openai.ChatMessageRoleSystem, 80 | Content: systemPrompt, 81 | }, 82 | }, openaiMessages...) 83 | 84 | resp, err := o.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ 85 | Model: o.model, 86 | Messages: openaiMessages, 87 | Temperature: float32(o.parameters.Temperature), 88 | TopP: float32(o.parameters.TopP), 89 | MaxTokens: int(o.parameters.MaxOutputTokens), 90 | }) 91 | if err != nil { 92 | return llm.ChatCompletionResponse{}, err 93 | } 94 | 95 | if len(resp.Choices) == 0 { 96 | return llm.ChatCompletionResponse{}, nil 97 | } 98 | 99 | return llm.ChatCompletionResponse{ 100 | Choices: []llm.ChatCompletionChoice{ 101 | { 102 | Message: llm.ChatCompletionMessage{ 103 | Role: resp.Choices[0].Message.Role, 104 | Content: resp.Choices[0].Message.Content, 105 | }, 106 | }, 107 | }, 108 | }, nil 109 | } 110 | 111 | // Detect implements the LLM interface 112 | func (o *API) Detect(ctx context.Context, message string) (*bool, error) { 113 | messages := []llm.ChatCompletionMessage{ 114 | { 115 | Role: "system", 116 | Content: "You are a spam detection system. Analyze the following message and respond with true if it's spam, false if it's not. Consider advertising, scams, and inappropriate content as spam.", 117 | }, 118 | { 119 | Role: "user", 120 | Content: message, 121 | }, 122 | } 123 | 124 | resp, err := o.ChatCompletion(ctx, messages) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | if len(resp.Choices) == 0 { 130 | return nil, fmt.Errorf("no response choices available") 131 | } 132 | 133 | result := strings.ToLower(strings.TrimSpace(resp.Choices[0].Message.Content)) == "true" 134 | return &result, nil 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :shield: Telegram Chat Gatekeeper bot 2 | > Get rid of the unwanted spam joins out of the box 3 | 4 | ![Demo](https://user-images.githubusercontent.com/239034/142725561-5fd80514-dae9-4d29-aa19-a7d2ad41e362.png) 5 | 6 | ## Join protection 7 | 0. Join-time challenge is disabled as for now, due to being buggy, but will be back as option in the future. 8 | > 1. Triggered on the events, which introduces new chat members (invite, join, etc). Also works with **join requests**. 9 | > 2. Restrict newcomer to be read-only. 10 | > 3. Set up a challenge for the newcomer (join request), which is a simple task as shown on the image above, but yet, unsolvable for the vast majority of automated spam robots. 11 | > 4. If the newcomer succeeds in choosing the right answer - restrictions gets fully lifted, challenge ends. 12 | > 5. Otherwise - newcomer gets banned for 10 minutes (There is a "false-positive" chance, rememeber? Most robots aint coming back, anyway). 13 | > 6. If the newcomer struggles to answer in a set period of time (defaults to 3 minutes) - challenge automatically fails the same way, as in p.5. 14 | > 7. After the challenge bot cleans up all related messages, only leaving join notification for the newcomers, that made it. There are no traces of unsuccesful joins left, and that is awesome. 15 | 16 | ## Spam protection 17 | 1. Every chat member first message is being checked for spam using two approaches: 18 | - **Known spammers DB lookup** - checks if the message author is in the known spammers DB. 19 | - **GPT-powered content analysis** - asks GPT to analyze the message for harmful content. 20 | 2. If the message is considered as spam - newcomer gets kick-banned. 21 | 3. If the message is not considered as spam - user becomes a normal trusted chat member. 22 | 23 | ## Installation 24 | 25 | ### Quick Start with Docker Compose 26 | 1. Create a bot via [BotFather](https://t.me/BotFather) and enable group messages access. 27 | 2. Clone the repository: 28 | ```bash 29 | git clone https://github.com/iamwavecut/ngbot.git 30 | cd ngbot 31 | ``` 32 | 3. Copy the example environment file and configure it: 33 | ```bash 34 | cp .env.example .env 35 | # Edit .env with your favorite editor and set required variables 36 | ``` 37 | 4. Start the bot: 38 | ```bash 39 | docker compose up -d 40 | ``` 41 | 5. Add your bot to chat and give it **Ban**, **Delete**, and **Invite** permissions. 42 | 6. Optional: Change bot language with `/lang ` (e.g., `/lang ru`). 43 | 44 | ### Manual Installation 45 | 1. Follow steps 1-3 from Quick Start. 46 | 2. Build and run: 47 | ```bash 48 | go mod download 49 | go run . 50 | ``` 51 | 52 | ## Configuration 53 | All configuration is done through environment variables. You can: 54 | - Set them in your environment 55 | - Use a `.env` file (recommended) 56 | - Pass them directly to docker compose or the binary 57 | 58 | See [.env.example](.env.example) for a quick reference of all available options. 59 | 60 | ### Configuration Options 61 | 62 | | Required | Variable name | Description | Default | Options | 63 | | --- | --- | --- | --- | --- | 64 | | :heavy_check_mark: | `NG_TOKEN` | Telegram BOT API token | | | 65 | | :heavy_check_mark: | `NG_LLM_API_KEY` | OpenAI API key for content analysis | | | 66 | | | `NG_LANG` | Default language to use in new chats | `en` | `be`, `bg`, `cs`, `da`, `de`, `el`, `en`, `es`, `et`, `fi`, `fr`, `hu`, `id`, `it`, `ja`, `ko`, `lt`, `lv`, `nb`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `sl`, `sv`, `tr`, `uk`, `zh` | 67 | | | `NG_HANDLERS` | Enabled bot handlers | `admin,gatekeeper,reactor` | Comma-separated list of handlers | 68 | | | `NG_LOG_LEVEL` | Logging verbosity | `2` | `0`=Panic, `1`=Fatal, `2`=Error, `3`=Warn, `4`=Info, `5`=Debug, `6`=Trace | 69 | | | `NG_DOT_PATH` | Bot data storage path | `~/.ngbot` | Any valid filesystem path | 70 | | | `NG_LLM_API_MODEL` | OpenAI model to use | `gpt-4o-mini` | Any valid OpenAI model | 71 | | | `NG_LLM_API_URL` | OpenAI API base URL | `https://api.openai.com/v1` | Any valid OpenAI API compliant base URL | 72 | | | `NG_LLM_API_TYPE` | API type | `openai` | `openai`, `gemini` | 73 | | | `NG_FLAGGED_EMOJIS` | Emojis used for content flagging | `👎,💩` | Comma-separated list of emojis | 74 | | | `NG_SPAM_LOG_CHANNEL_USERNAME` | Channel for spam logging | | Any valid channel username | 75 | | | `NG_SPAM_VERBOSE` | Verbose in-chat notifications | `false` | `true`, `false` | 76 | | | `NG_SPAM_VOTING_TIMEOUT` | Voting time limit | `5m` | Any valid duration string | 77 | | | `NG_SPAM_MIN_VOTERS` | Minimum required voters | `2` | Any positive integer | 78 | | | `NG_SPAM_MAX_VOTERS` | Maximum voters cap | `10` | Any positive integer | 79 | | | `NG_SPAM_MIN_VOTERS_PERCENTAGE` | Minimum voter percentage | `5` | Any positive float | 80 | | | `NG_SPAM_SUSPECT_NOTIFICATION_TIMEOUT` | Suspect notification timeout | `2m` | Any valid duration string | 81 | 82 | ## Troubleshooting 83 | Don't hesitate to contact me 84 | 85 | [![telegram](https://user-images.githubusercontent.com/239034/142726254-d3378dee-5b73-41b0-858d-b2a6e85dc735.png) 86 | ](https://t.me/WaveCut) [![linkedin](https://user-images.githubusercontent.com/239034/142726236-86c526e0-8fc3-4570-bd2d-fc7723d5dc09.png) 87 | ](https://linkedin.com/in/wavecut) 88 | 89 | ## TODO 90 | 91 | - [ ] Individual chat's settings (behaviours, timeouts, custom welcome messages, etc). 92 | - [ ] Chat-specific spam filters. 93 | - [ ] Settings UI in private and/or web. 94 | 95 | > Feel free to add your requests in issues. 96 | -------------------------------------------------------------------------------- /internal/adapters/llm/gemini/gemini.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/google/generative-ai-go/genai" 9 | "github.com/iamwavecut/ngbot/internal/adapters" 10 | "github.com/iamwavecut/ngbot/internal/adapters/llm" 11 | log "github.com/sirupsen/logrus" 12 | "google.golang.org/api/option" 13 | ) 14 | 15 | type API struct { 16 | client *genai.Client 17 | model *genai.GenerativeModel 18 | logger *log.Entry 19 | } 20 | 21 | const DefaultModel = "gemini-2.5-flash-lite" 22 | 23 | func NewGemini(apiKey, model string, logger *log.Entry) adapters.LLM { 24 | ctx := context.Background() 25 | client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey)) 26 | if err != nil { 27 | logger.Fatalf("Error creating client: %v", err) 28 | } 29 | api := &API{ 30 | client: client, 31 | logger: logger, 32 | model: client.GenerativeModel(model), 33 | } 34 | api.WithSafetySettings(nil) 35 | api.WithParameters(nil) 36 | return api 37 | } 38 | 39 | func (g *API) WithModel(modelName string) adapters.LLM { 40 | if modelName == "" { 41 | modelName = DefaultModel 42 | } 43 | model := g.client.GenerativeModel(modelName) 44 | g.model = model 45 | return g 46 | } 47 | 48 | func (g *API) WithParameters(parameters *llm.GenerationParameters) adapters.LLM { 49 | if parameters == nil || (parameters == &llm.GenerationParameters{}) { 50 | parameters = &llm.GenerationParameters{ 51 | Temperature: 0.9, 52 | TopK: 40, 53 | TopP: 0.95, 54 | MaxOutputTokens: 8192, 55 | ResponseMIMEType: "text/plain", 56 | } 57 | } 58 | 59 | g.model.SetTemperature(parameters.Temperature) 60 | g.model.SetTopK(parameters.TopK) 61 | g.model.SetTopP(parameters.TopP) 62 | g.model.SetMaxOutputTokens(int32(parameters.MaxOutputTokens)) 63 | g.model.ResponseMIMEType = parameters.ResponseMIMEType 64 | 65 | return g 66 | } 67 | 68 | func (g *API) WithSafetySettings(safetySettings []*genai.SafetySetting) *API { 69 | if len(safetySettings) == 0 { 70 | safetySettings = []*genai.SafetySetting{ 71 | { 72 | Category: genai.HarmCategoryDangerous, 73 | Threshold: genai.HarmBlockNone, 74 | }, 75 | { 76 | Category: genai.HarmCategoryDangerousContent, 77 | Threshold: genai.HarmBlockNone, 78 | }, 79 | { 80 | Category: genai.HarmCategoryDerogatory, 81 | Threshold: genai.HarmBlockNone, 82 | }, 83 | { 84 | Category: genai.HarmCategoryHarassment, 85 | Threshold: genai.HarmBlockNone, 86 | }, 87 | { 88 | Category: genai.HarmCategoryHateSpeech, 89 | Threshold: genai.HarmBlockNone, 90 | }, 91 | { 92 | Category: genai.HarmCategoryMedical, 93 | Threshold: genai.HarmBlockNone, 94 | }, 95 | { 96 | Category: genai.HarmCategorySexual, 97 | Threshold: genai.HarmBlockNone, 98 | }, 99 | { 100 | Category: genai.HarmCategorySexuallyExplicit, 101 | Threshold: genai.HarmBlockNone, 102 | }, 103 | { 104 | Category: genai.HarmCategoryToxicity, 105 | Threshold: genai.HarmBlockNone, 106 | }, 107 | { 108 | Category: genai.HarmCategoryViolence, 109 | Threshold: genai.HarmBlockNone, 110 | }, 111 | { 112 | Category: genai.HarmCategoryUnspecified, 113 | Threshold: genai.HarmBlockNone, 114 | }, 115 | } 116 | } 117 | g.model.SafetySettings = safetySettings 118 | return g 119 | } 120 | 121 | func (g *API) WithSystemPrompt(prompt string) adapters.LLM { 122 | g.model.SystemInstruction = &genai.Content{ 123 | Parts: []genai.Part{genai.Text(prompt)}, 124 | } 125 | return g 126 | } 127 | 128 | func (g *API) ChatCompletion(ctx context.Context, messages []llm.ChatCompletionMessage) (llm.ChatCompletionResponse, error) { 129 | session := g.model.StartChat() 130 | session.History = []*genai.Content{} 131 | 132 | lastMessage, messages := messages[len(messages)-1], messages[:len(messages)-1] 133 | 134 | backupGlobalInstruction := g.model.SystemInstruction 135 | for _, message := range messages { 136 | if message.Role == "system" { 137 | g.model.SystemInstruction = &genai.Content{ 138 | Parts: []genai.Part{genai.Text(message.Content)}, 139 | } 140 | continue 141 | } 142 | session.History = append(session.History, &genai.Content{ 143 | Parts: []genai.Part{genai.Text(message.Content)}, 144 | }) 145 | } 146 | 147 | resp, err := session.SendMessage(ctx, genai.Text(lastMessage.Content)) 148 | if err != nil { 149 | return llm.ChatCompletionResponse{}, err 150 | } 151 | g.model.SystemInstruction = backupGlobalInstruction 152 | 153 | response := "" 154 | for _, part := range resp.Candidates[0].Content.Parts { 155 | response += fmt.Sprintf("%v", part) 156 | } 157 | 158 | return llm.ChatCompletionResponse{ 159 | Choices: []llm.ChatCompletionChoice{{Message: llm.ChatCompletionMessage{Content: response}}}, 160 | }, nil 161 | } 162 | 163 | // Detect implements the LLM interface 164 | func (g *API) Detect(ctx context.Context, message string) (*bool, error) { 165 | messages := []llm.ChatCompletionMessage{ 166 | { 167 | Role: "system", 168 | Content: "You are a spam detection system. Analyze the following message and respond with true if it's spam, false if it's not. Consider advertising, scams, and inappropriate content as spam.", 169 | }, 170 | { 171 | Role: "user", 172 | Content: message, 173 | }, 174 | } 175 | 176 | resp, err := g.ChatCompletion(ctx, messages) 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | if len(resp.Choices) == 0 { 182 | return nil, fmt.Errorf("no response choices available") 183 | } 184 | 185 | result := strings.ToLower(strings.TrimSpace(resp.Choices[0].Message.Content)) == "true" 186 | return &result, nil 187 | } 188 | -------------------------------------------------------------------------------- /internal/handlers/admin/admin.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "strings" 8 | 9 | api "github.com/OvyFlash/telegram-bot-api" 10 | "github.com/iamwavecut/tool" 11 | "github.com/pkg/errors" 12 | log "github.com/sirupsen/logrus" 13 | 14 | "github.com/iamwavecut/ngbot/internal/bot" 15 | moderation "github.com/iamwavecut/ngbot/internal/handlers/moderation" 16 | "github.com/iamwavecut/ngbot/internal/i18n" 17 | ) 18 | 19 | type Admin struct { 20 | s bot.Service 21 | languages []string 22 | banService moderation.BanService 23 | spamControl *moderation.SpamControl 24 | } 25 | 26 | func NewAdmin(s bot.Service, banService moderation.BanService, spamControl *moderation.SpamControl) *Admin { 27 | entry := log.WithField("object", "Admin").WithField("method", "NewAdmin") 28 | 29 | a := &Admin{ 30 | s: s, 31 | banService: banService, 32 | spamControl: spamControl, 33 | languages: i18n.GetLanguagesList(), 34 | } 35 | entry.Debug("created new admin handler") 36 | return a 37 | } 38 | 39 | func (a *Admin) Handle(ctx context.Context, u *api.Update, chat *api.Chat, user *api.User) (proceed bool, err error) { 40 | entry := a.getLogEntry().WithField("method", "Handle") 41 | 42 | if chat == nil || user == nil { 43 | entry.Debug("chat or user is nil, proceeding") 44 | return true, nil 45 | } 46 | 47 | switch { 48 | case 49 | u.Message == nil, 50 | user.IsBot, 51 | !u.Message.IsCommand(): 52 | return true, nil 53 | } 54 | m := u.Message 55 | 56 | entry.Debugf("processing command: %s", m.Command()) 57 | 58 | isAdmin, err := a.isAdmin(chat.ID, user.ID) 59 | if err != nil { 60 | entry.WithField("error", err.Error()).Error("can't check admin status") 61 | return true, err 62 | } 63 | entry.Debugf("user is admin: %v", isAdmin) 64 | 65 | language := a.s.GetLanguage(ctx, chat.ID, user) 66 | 67 | switch m.Command() { 68 | case "lang": 69 | return a.handleLangCommand(ctx, m, isAdmin, language) 70 | case "start": 71 | return a.handleStartCommand(m, language) 72 | 73 | // case "ban": 74 | // return a.handleBanCommand(ctx, m, isAdmin, language) 75 | 76 | default: 77 | entry.Debug("unknown command") 78 | return true, nil 79 | } 80 | } 81 | 82 | func (a *Admin) handleLangCommand(ctx context.Context, msg *api.Message, isAdmin bool, language string) (bool, error) { 83 | entry := a.getLogEntry().WithField("command", "lang") 84 | if !isAdmin { 85 | entry.Debug("user is not admin, ignoring command") 86 | return true, nil 87 | } 88 | 89 | argument := msg.CommandArguments() 90 | entry.Debugf("language argument: %s", argument) 91 | 92 | isAllowed := false 93 | for _, allowedLanguage := range a.languages { 94 | if allowedLanguage == argument { 95 | isAllowed = true 96 | break 97 | } 98 | } 99 | if !isAllowed { 100 | entry.Debug("invalid language argument") 101 | msg := api.NewMessage( 102 | msg.Chat.ID, 103 | i18n.Get("You should use one of the following options", language)+": `"+strings.Join(a.languages, "`, `")+"`", 104 | ) 105 | msg.ParseMode = api.ModeMarkdown 106 | msg.DisableNotification = true 107 | _, _ = a.s.GetBot().Send(msg) 108 | return false, nil 109 | } 110 | 111 | settings, err := a.s.GetDB().GetSettings(ctx, msg.Chat.ID) 112 | if tool.Try(err) { 113 | if errors.Cause(err) != sql.ErrNoRows { 114 | entry.WithField("error", err.Error()).Error("can't get chat settings") 115 | return true, errors.WithMessage(err, "cant get chat settings") 116 | } 117 | } 118 | 119 | settings.Language = argument 120 | err = a.s.GetDB().SetSettings(ctx, settings) 121 | if tool.Try(err) { 122 | entry.WithField("error", err.Error()).Error("can't update chat language") 123 | return false, errors.WithMessage(err, "cant update chat language") 124 | } 125 | 126 | entry.Debug("language set successfully") 127 | _, _ = a.s.GetBot().Send(api.NewMessage( 128 | msg.Chat.ID, 129 | i18n.Get("Language set successfully", language), 130 | )) 131 | 132 | return false, nil 133 | } 134 | 135 | func (a *Admin) handleStartCommand(msg *api.Message, language string) (bool, error) { 136 | entry := a.getLogEntry().WithField("method", "handleStartCommand") 137 | entry.Debug("start command received") 138 | _, _ = a.s.GetBot().Send(api.NewMessage( 139 | msg.Chat.ID, 140 | i18n.Get("Bot started successfully", language), 141 | )) 142 | 143 | return false, nil 144 | } 145 | 146 | func (a *Admin) handleBanCommand(ctx context.Context, msg *api.Message, isAdmin bool, language string) (bool, error) { 147 | entry := log.WithField("method", "handleBanCommand") 148 | entry.Debug("handling ban command") 149 | 150 | if msg.Chat.Type == "private" { 151 | entry.Debug("command used in private chat, ignoring") 152 | msg := api.NewMessage(msg.Chat.ID, i18n.Get("This command can only be used in groups", language)) 153 | msg.DisableNotification = true 154 | _, _ = a.s.GetBot().Send(msg) 155 | return false, nil 156 | } 157 | 158 | if msg.ReplyToMessage == nil { 159 | msg := api.NewMessage(msg.Chat.ID, i18n.Get("This command must be used as a reply to a message", language)) 160 | msg.DisableNotification = true 161 | _, _ = a.s.GetBot().Send(msg) 162 | return false, nil 163 | } 164 | 165 | if isAdmin { 166 | return false, a.handleAdminBan(ctx, msg, language) 167 | } else { 168 | return false, a.handleUserBanVote(ctx, msg, language) 169 | } 170 | } 171 | 172 | func (a *Admin) handleAdminBan(ctx context.Context, msg *api.Message, language string) error { 173 | targetMsg := msg.ReplyToMessage 174 | 175 | err := bot.BanUserFromChat(ctx, a.s.GetBot(), targetMsg.From.ID, msg.Chat.ID, 0) 176 | if err != nil { 177 | if errors.Is(err, moderation.ErrNoPrivileges) { 178 | msg := api.NewMessage(msg.Chat.ID, i18n.Get("I don't have enough rights to ban this user", language)) 179 | msg.DisableNotification = true 180 | _, _ = a.s.GetBot().Send(msg) 181 | } 182 | return fmt.Errorf("failed to ban user: %w", err) 183 | } 184 | 185 | err = bot.DeleteChatMessage(ctx, a.s.GetBot(), msg.Chat.ID, targetMsg.MessageID) 186 | if err != nil { 187 | return fmt.Errorf("failed to delete message: %w", err) 188 | } 189 | 190 | err = bot.DeleteChatMessage(ctx, a.s.GetBot(), msg.Chat.ID, msg.MessageID) 191 | if err != nil { 192 | return fmt.Errorf("failed to delete command message: %w", err) 193 | } 194 | 195 | return nil 196 | } 197 | 198 | func (a *Admin) handleUserBanVote(ctx context.Context, msg *api.Message, language string) error { 199 | targetMsg := msg.ReplyToMessage 200 | if targetMsg == nil { 201 | err := bot.DeleteChatMessage(ctx, a.s.GetBot(), msg.Chat.ID, msg.MessageID) 202 | if err != nil { 203 | return fmt.Errorf("failed to delete command message: %w", err) 204 | } 205 | } 206 | return a.spamControl.ProcessSuspectMessage(ctx, targetMsg, language) 207 | } 208 | 209 | func (a *Admin) isAdmin(chatID, userID int64) (bool, error) { 210 | b := a.s.GetBot() 211 | chatMember, err := b.GetChatMember(api.GetChatMemberConfig{ 212 | ChatConfigWithUser: api.ChatConfigWithUser{ 213 | UserID: userID, 214 | ChatConfig: api.ChatConfig{ 215 | ChatID: chatID, 216 | }, 217 | }, 218 | }) 219 | if err != nil { 220 | return false, errors.New("cant get chat member") 221 | } 222 | 223 | return chatMember.IsCreator() || (chatMember.IsAdministrator() && chatMember.CanRestrictMembers), nil 224 | } 225 | 226 | func (a *Admin) getLogEntry() *log.Entry { 227 | return log.WithField("context", "admin") 228 | } 229 | -------------------------------------------------------------------------------- /cmd/ngbot/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "os/signal" 8 | "strings" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/iamwavecut/tool" 13 | 14 | "github.com/iamwavecut/ngbot/internal/adapters" 15 | "github.com/iamwavecut/ngbot/internal/adapters/llm/gemini" 16 | "github.com/iamwavecut/ngbot/internal/adapters/llm/openai" 17 | "github.com/iamwavecut/ngbot/internal/db/sqlite" 18 | "github.com/iamwavecut/ngbot/internal/event" 19 | adminHandlers "github.com/iamwavecut/ngbot/internal/handlers/admin" 20 | chatHandlers "github.com/iamwavecut/ngbot/internal/handlers/chat" 21 | moderationHandlers "github.com/iamwavecut/ngbot/internal/handlers/moderation" 22 | "github.com/iamwavecut/ngbot/internal/i18n" 23 | 24 | api "github.com/OvyFlash/telegram-bot-api" 25 | log "github.com/sirupsen/logrus" 26 | 27 | "github.com/iamwavecut/ngbot/internal/bot" 28 | "github.com/iamwavecut/ngbot/internal/config" 29 | "github.com/iamwavecut/ngbot/internal/infra" 30 | "github.com/iamwavecut/ngbot/internal/observability" 31 | ) 32 | 33 | func main() { 34 | cfg := config.Get() 35 | log.SetFormatter(&config.NbFormatter{}) 36 | log.SetOutput(os.Stdout) 37 | log.SetLevel(log.Level(cfg.LogLevel)) 38 | tool.SetLogger(log.StandardLogger()) 39 | 40 | maskedConfig := maskConfiguration(&cfg) 41 | if configJSON, err := json.MarshalIndent(maskedConfig, "", " "); err != nil { 42 | log.WithField("error", err.Error()).Error("Failed to marshal config to JSON") 43 | } else { 44 | log.WithField("config", string(configJSON)).Debug("Application configuration") 45 | } 46 | 47 | tool.Try(api.SetLogger(log.WithField("context", "bot_api")), true) 48 | i18n.Init() 49 | 50 | ctx := context.Background() 51 | 52 | // Initialize observability stack 53 | if err := observability.Init(ctx); err != nil { 54 | log.Fatalf("Failed to initialize observability: %v", err) 55 | } 56 | 57 | sigChan := make(chan os.Signal, 1) 58 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) 59 | 60 | errChan := make(chan error, 1) 61 | 62 | go runBot(ctx, &cfg, errChan) 63 | 64 | shutdown := false 65 | for !shutdown { 66 | select { 67 | case <-infra.MonitorExecutable(): 68 | log.Info("Executable file was modified, initiating shutdown...") 69 | shutdown = true 70 | case sig := <-sigChan: 71 | log.Infof("Received signal %v, initiating shutdown...", sig) 72 | shutdown = true 73 | case err := <-errChan: 74 | log.WithField("error", err.Error()).Error("Bot error occurred") 75 | shutdown = true 76 | } 77 | } 78 | 79 | log.Info("Starting graceful shutdown...") 80 | 81 | // Wait for graceful shutdown with timeout 82 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) 83 | defer shutdownCancel() 84 | 85 | select { 86 | case <-shutdownCtx.Done(): 87 | log.Warn("Graceful shutdown timed out, forcing exit") 88 | os.Exit(1) 89 | case <-ctx.Done(): 90 | log.Info("Graceful shutdown completed") 91 | } 92 | } 93 | 94 | func maskConfiguration(cfg *config.Config) *config.Config { 95 | maskedConfig := *cfg 96 | maskSecret := func(s string) string { 97 | if len(s) == 0 { 98 | return s 99 | } 100 | visiblePart := len(s) / 5 101 | return s[:visiblePart] + strings.Repeat("*", len(s)-2*visiblePart) + s[len(s)-visiblePart:] 102 | } 103 | maskedConfig.TelegramAPIToken = maskSecret(cfg.TelegramAPIToken) 104 | maskedConfig.LLM.APIKey = maskSecret(cfg.LLM.APIKey) 105 | return &maskedConfig 106 | } 107 | 108 | func runBot(ctx context.Context, cfg *config.Config, errChan chan<- error) { 109 | defer event.RunWorker()() 110 | 111 | // Initialize bot API 112 | botAPI, err := api.NewBotAPI(cfg.TelegramAPIToken) 113 | if err != nil { 114 | log.WithField("error", err.Error()).Error("Failed to initialize bot API") 115 | errChan <- err 116 | return 117 | } 118 | if log.Level(cfg.LogLevel) == log.TraceLevel { 119 | botAPI.Debug = true 120 | } 121 | defer botAPI.StopReceivingUpdates() 122 | 123 | if _, err := botAPI.GetMyCommands(); err != nil { 124 | log.WithError(err).Warn("failed to get bot commands") 125 | } 126 | 127 | // commandsScope := api.NewBotCommandScopeAllGroupChats() 128 | // setMyCommandsConfig := api.NewSetMyCommandsWithScope(commandsScope, api.BotCommand{ 129 | // Command: "ban", 130 | // Description: "Ban user (admin), or start a voting to ban user (all)", 131 | // }) 132 | 133 | // _, err = botAPI.Request(setMyCommandsConfig) 134 | // if err != nil { 135 | // log.WithField("error", err.Error()).Error("Failed to set my commands") 136 | // } 137 | 138 | // Initialize services and handlers 139 | service := bot.NewService(ctx, botAPI, sqlite.NewSQLiteClient(ctx, "bot.db"), log.WithField("context", "service")) 140 | initializeHandlers(service, cfg, log.WithField("context", "handlers")) 141 | 142 | // Configure updates 143 | updateConfig := configureUpdates() 144 | updateProcessor := bot.NewUpdateProcessor(service) 145 | 146 | // Start receiving updates 147 | updateChan, updateErrChan := bot.GetUpdatesChans(ctx, botAPI, updateConfig) 148 | 149 | // Process updates 150 | for { 151 | select { 152 | case err := <-updateErrChan: 153 | log.WithField("error", err.Error()).Error("Bot API get updates error") 154 | errChan <- err 155 | return 156 | case update := <-updateChan: 157 | if err := updateProcessor.Process(ctx, &update); err != nil { 158 | log.WithField("error", err.Error()).Error("Failed to process update") 159 | } 160 | case <-ctx.Done(): 161 | log.Info("Bot shutdown initiated") 162 | return 163 | } 164 | } 165 | } 166 | 167 | func initializeHandlers(service bot.Service, cfg *config.Config, logger *log.Entry) { 168 | banService := moderationHandlers.NewBanService( 169 | service.GetBot(), 170 | service.GetDB(), 171 | ) 172 | spamControl := moderationHandlers.NewSpamControl(service, cfg.SpamControl, banService, cfg.SpamControl.Verbose) 173 | bot.RegisterUpdateHandler("gatekeeper", chatHandlers.NewGatekeeper(service, cfg, banService)) 174 | bot.RegisterUpdateHandler("admin", adminHandlers.NewAdmin(service, banService, spamControl)) 175 | 176 | llmAPI := configureLLM(cfg, logger) 177 | spamDetector := moderationHandlers.NewSpamDetector(llmAPI, logger.WithField("context", "spam_detector")) 178 | 179 | bot.RegisterUpdateHandler("reactor", chatHandlers.NewReactor(service, banService, spamControl, spamDetector, chatHandlers.Config{ 180 | FlaggedEmojis: cfg.Reactor.FlaggedEmojis, 181 | OpenAIModel: cfg.LLM.Model, 182 | SpamControl: cfg.SpamControl, 183 | })) 184 | } 185 | 186 | func configureLLM(cfg *config.Config, logger *log.Entry) adapters.LLM { 187 | switch cfg.LLM.Type { 188 | case "openai": 189 | return openai.NewOpenAI( 190 | cfg.LLM.APIKey, 191 | cfg.LLM.Model, 192 | cfg.LLM.BaseURL, 193 | logger.WithField("context", "llm"), 194 | ) 195 | case "gemini": 196 | return gemini.NewGemini( 197 | cfg.LLM.APIKey, 198 | cfg.LLM.Model, 199 | logger.WithField("context", "llm"), 200 | ) 201 | } 202 | return nil 203 | } 204 | 205 | func configureUpdates() api.UpdateConfig { 206 | updateConfig := api.NewUpdate(0) 207 | updateConfig.Timeout = 60 208 | updateConfig.AllowedUpdates = []string{ 209 | "message", "edited_message", "channel_post", "edited_channel_post", 210 | "message_reaction", "message_reaction_count", "inline_query", 211 | "chosen_inline_result", "callback_query", "shipping_query", 212 | "pre_checkout_query", "poll", "poll_answer", "my_chat_member", 213 | "chat_member", "chat_join_request", 214 | } 215 | return updateConfig 216 | } 217 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # 🤖 Agent Development Rules & Guidelines 2 | 3 | This document serves as the **single source of truth** for all development rules, coding standards, and agent behavior guidelines for this project. 4 | 5 | ## 📋 Table of Contents 6 | 7 | - [🤖 Agent Development Rules \& Guidelines](#-agent-development-rules--guidelines) 8 | - [📋 Table of Contents](#-table-of-contents) 9 | - [🗣️ Communication \& Response Style](#️-communication--response-style) 10 | - [🏛️ Architecture \& Design](#️-architecture--design) 11 | - [Core Philosophy](#core-philosophy) 12 | - [Project Structure \& Module Organization](#project-structure--module-organization) 13 | - [Architecture Layers](#architecture-layers) 14 | - [External Services](#external-services) 15 | - [🐹 Go Development Rules](#-go-development-rules) 16 | - [Core Principles](#core-principles) 17 | - [Go Version \& Documentation](#go-version--documentation) 18 | - [Tool Dependencies](#tool-dependencies) 19 | - [Naming \& Structure](#naming--structure) 20 | - [Error Handling \& Types](#error-handling--types) 21 | - [Best Practices](#best-practices) 22 | - [Concurrency Rules](#concurrency-rules) 23 | - [🗄️ Database \& Schema Management](#️-database--schema-management) 24 | - [Migrations](#migrations) 25 | - [SQLC Configuration](#sqlc-configuration) 26 | - [Storage Layer](#storage-layer) 27 | - [📝 File Editing Strategy](#-file-editing-strategy) 28 | - [Core Principle](#core-principle) 29 | - [Mass Import Replacements](#mass-import-replacements) 30 | - [Best Practices (DOs \& DON'Ts)](#best-practices-dos--donts) 31 | - [🧹 Code Quality \& Hygiene](#-code-quality--hygiene) 32 | - [Linting \& Static Analysis](#linting--static-analysis) 33 | - [Cleanup Rules](#cleanup-rules) 34 | - [🔄 Workflow](#-workflow) 35 | - [Information Gathering](#information-gathering) 36 | - [Feedback \& Communication](#feedback--communication) 37 | - [Agent Workflow Steps](#agent-workflow-steps) 38 | 39 | --- 40 | 41 | ## 🗣️ Communication & Response Style 42 | 43 | - **Language Policy** 🌐: Always reason and edit in English, but answer user in their prompt language. 44 | - **Response Format** 📊: Always format responses using structured tables with emojis instead of long text blocks. 45 | - **Visual Clarity** ✨: Use tables for better visual clarity and quick scanning. Replace lengthy paragraphs with concise, emoji-enhanced tabular format. 46 | - **Present in diagrams** 📊: Present complex flows and business in Mermaid diagrams when appropriate. 47 | - **Continuation Style** ⚡: Continue without stopping to reiterate or provide feedback, and don't report until all planned work is finished. 48 | - **Web Search & Fetch** 🔍: Use ZAI MCP tools when needing to search or fetch web content. 49 | 50 | --- 51 | 52 | ## 🏛️ Architecture & Design 53 | 54 | ### Core Philosophy 55 | - **Architecture Style** 🏗️: Hexagonal / Domain-Driven Design (DDD). Keep it modular and layered but compact. Avoid over-abstraction. 56 | - **Avoid Over-Abstraction** 🎯: Don't abstract prematurely; keep solutions simple and focused. 57 | - **Interfaces Near Consumer** 📍: Ports (interfaces) must be defined near the code that uses them. 58 | 59 | ### Project Structure & Module Organization 60 | TBD 61 | 62 | ### Architecture Layers 63 | TBD 64 | 65 | ### External Services 66 | TBD 67 | 68 | --- 69 | 70 | ## 🐹 Go Development Rules 71 | 72 | ### Core Principles 73 | - **Self-documenting code** 📖: No comments—clear names and structure speak for themselves. 74 | - **No TODOs** 🚫: Write complete code or nothing. No placeholders. 75 | - **Professional standards** 👨‍💻: Write like a professional Go developer would, without unnecessary code bloat. 76 | - **Architecture first** 🏛️: Audit before coding: scan repo, read related packages, plan all changes. 77 | 78 | ### Go Version & Documentation 79 | - **Go Version** 🔢: 1.25 (Latest features where applicable). Ref: [Go Release Notes](https://go.dev/doc/devel/release) 80 | - **Documentation Strategy** 📚: Use `go doc`, `go tool`, `go list` for Go packages. 81 | - **English Only** 🇺🇸: Code and technical reasoning in English. 82 | 83 | ### Tool Dependencies 84 | - **Tool Directive** 🔧: Use Go 1.24+ `tool` directive in `go.mod` for dev tools (sqlc, golangci-lint, etc.). 85 | - **No tools.go Hack** 🚫: Avoid the `tools.go` blank import pattern. 86 | - **Refactor tooling** 🛠️: 87 | - `go tool gorename` — safe, reference-aware renames. 88 | - `go tool godoctor` — extract/inline functions and move code. 89 | - `go tool gopatch` — template-driven patches. 90 | - `go tool goimports` — auto-manage imports. 91 | 92 | ### Naming & Structure 93 | - **Case Convention** 🔤: Use MixedCaps/mixedCaps (no underscores). 94 | - **Acronyms** 🔤: All uppercase (HTTP, URL, ID, API). 95 | - **Getters** 🎣: No "Get" prefix (`user.Name()` not `user.GetName()`). 96 | - **Interfaces** 🔌: Ends in "-er" (Reader) or "-able" (Readable). 97 | - **Organization** 📂: Group related constants/variables/types together. 98 | - **Packages** 📁: One package per directory with short, meaningful names. 99 | - **Formatting** ✨: Use `gofumpt -w .` (tabs, newline at EOF). 100 | 101 | ### Error Handling & Types 102 | - **Check Immediately** ⚠️: Check errors immediately, no panic for normal errors. 103 | - **Wrapping** 🎁: Use `fmt.Errorf("op: %w", err)`. 104 | - **Inspection** 🔍: Use `errors.Is` / `errors.As`. 105 | - **Interface Types** 🔄: Use `any` instead of `interface{}`. 106 | 107 | ### Best Practices 108 | - **Testing** 🧪: Table-driven tests beside code (`*_test.go`). Mock interfaces. 109 | - **Context** ⏱️: Use `context.Context` for cancellation/timeouts (first param). 110 | - **Global Variables** 🚫: Avoid them. 111 | - **Composition** 🔗: Prefer composition over inheritance. 112 | - **Embedding** 📎: Use judiciously. 113 | - **Preallocation** 🧠: Preallocate slices when length is known. 114 | 115 | ### Concurrency Rules 116 | - **Philosophy** 🧠: Share memory by communicating. 117 | - **Coordination** 📡: Channels for coordination, mutexes for state. 118 | - **Error Groups** 👥: Use `errgroup` for concurrent tasks. 119 | - **Leaks** 🚰: Prevent goroutine leaks. 120 | 121 | --- 122 | 123 | ## 🗄️ Database & Schema Management 124 | 125 | ### Migrations 126 | - **Location** 📁: `internal/db/sql/migrations/` as numbered SQL files (e.g., `0_init.sql`). 127 | - **Directives** 📝: Use `+migrate Up/Down`. 128 | - **Functions** ⚙️: Wrap in `-- +migrate StatementBegin` and `-- +migrate StatementEnd`. 129 | 130 | ### SQLC Configuration 131 | - **Queries** 📄: Defined in `internal/db/sql/queries.sql`. 132 | - **Generated Code** 🔧: `internal/db/sqlc/` (`querier.go`, `models.go`, etc.). 133 | - **Regeneration** ♻️: Run `go generate ./...`. 134 | - **Embedding** 📎: Use `sqlc.embed` for cleaner structs. 135 | 136 | ### Storage Layer 137 | - **Architecture** 🏗️: Interface → Base Postgres → Buffered/Cached → Factory. 138 | - **Driver** 🔌: PostgreSQL with pgx v5. 139 | - **Tools** 🛠️: `sqlc` for queries, `sql-migrate` for migrations. 140 | 141 | --- 142 | 143 | ## 📝 File Editing Strategy 144 | 145 | ### Core Principle 146 | **Single-Action Complete Revisions**: Consolidate ALL necessary changes into bulk comprehensive updates. Deliver complete, functional code in a single edit action. 147 | 148 | ### Mass Import Replacements 149 | - **Edge Case** 🔄: When restructuring requires updating imports across many files (30+), use `sed` or `find ... -exec sed`. 150 | - **Example**: `find . -name "*.go" -exec sed -i '' 's|old/path|new/path|g' {} \;` 151 | - **Verification**: Always run `go vet ./...` after mass replacements. 152 | 153 | ### Best Practices (DOs & DON'Ts) 154 | - ✅ **Audit First**: Read and understand the complete file context. 155 | - ✅ **Plan Comprehensively**: Identify all changes needed across the file. 156 | - ✅ **Verify Completeness**: Ensure the edit delivers fully functional code. 157 | - ❌ **No Placeholders**: No "TODO" or incomplete states. 158 | 159 | --- 160 | 161 | ## 🧹 Code Quality & Hygiene 162 | 163 | ### Linting & Static Analysis 164 | - **Full Lint** 🔍: `go tool golangci-lint run --enable=unused --enable=unparam --enable=ineffassign --enable=goconst ./...` 165 | - **Quick Check** ⚡: `go vet ./...` (Do not use `go build` for validation). 166 | - **Compliance** ✅: **Never ignore lint warnings and fix them right away.** 167 | 168 | ### Cleanup Rules 169 | - **Unused Code** 🗑️: Remove unused files, functions, parameters. 170 | - **No Dead Code** 💀: Don't leave dead code unless protected by a describing comment. 171 | - **Constants** 📦: Extract repeated string literals into constants. 172 | - **Signature Refactoring** ✂️: Simplify function signatures by removing unused params/returns. 173 | 174 | --- 175 | 176 | ## 🔄 Workflow 177 | 178 | ### Information Gathering 179 | - **Tool Usage** 🛠️: Use provided tools extensively instead of guessing. 180 | - **Code Inspection** 🔍: Inspect code when unsure: list project structure, read whole files. 181 | - **Dependency Check** 📦: Verify library existence in `go.mod` before importing. 182 | 183 | ### Feedback & Communication 184 | - **Interactive Feedback** 💬: Always call `interactive_feedback` MCP when asking questions. 185 | - **Continuous Feedback** 🔄: Continue calling until user feedback is empty. 186 | - **Reporting** 📋: Request feedback or ask when finished or unsure. 187 | 188 | ### Agent Workflow Steps 189 | 1. **Audit First** 🔍: Read and understand the complete project structure (`tree -a -I .git`). 190 | 2. **Plan** 📋: Identify all changes needed across files. 191 | 3. **Verify** ✅: Use `go vet ./...` to check code; `go test ./...` if applicable. 192 | 4. **Update Docs** 📝: Update `AGENTS.md` if architecture changes or new rules introduced. -------------------------------------------------------------------------------- /internal/bot/service.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | api "github.com/OvyFlash/telegram-bot-api" 11 | "golang.org/x/sync/errgroup" 12 | 13 | "github.com/iamwavecut/ngbot/internal/config" 14 | "github.com/iamwavecut/ngbot/internal/db" 15 | "github.com/iamwavecut/ngbot/internal/i18n" 16 | "github.com/iamwavecut/tool" 17 | "github.com/pkg/errors" 18 | "github.com/sirupsen/logrus" 19 | ) 20 | 21 | type service struct { 22 | bot *api.BotAPI 23 | dbClient db.Client 24 | memberCache map[int64]map[int64]time.Time 25 | settingsCache map[int64]*db.Settings 26 | cacheMutex sync.RWMutex 27 | cacheExpiration time.Duration 28 | log *logrus.Entry 29 | ctx context.Context 30 | cancel context.CancelFunc 31 | } 32 | 33 | func NewService(ctx context.Context, bot *api.BotAPI, dbClient db.Client, log *logrus.Entry) *service { 34 | ctx, cancel := context.WithCancel(ctx) 35 | s := &service{ 36 | bot: bot, 37 | dbClient: dbClient, 38 | memberCache: make(map[int64]map[int64]time.Time), 39 | settingsCache: make(map[int64]*db.Settings), 40 | cacheExpiration: 5 * time.Minute, 41 | log: log, 42 | ctx: ctx, 43 | cancel: cancel, 44 | } 45 | 46 | go func() { 47 | for range time.Tick(24 * time.Hour) { 48 | if err := s.CleanupLeftMembers(s.ctx); err != nil { 49 | s.getLogEntry().WithField("error", err.Error()).Error("Failed to cleanup left members") 50 | } 51 | } 52 | }() 53 | 54 | go func() { 55 | ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second) 56 | defer cancel() 57 | 58 | if err := s.warmupCache(ctx); err != nil { 59 | s.log.WithField("errorv", fmt.Sprintf("%+v", err)).Error("Failed to warm up cache") 60 | } 61 | }() 62 | return s 63 | } 64 | 65 | func (s *service) GetBot() *api.BotAPI { 66 | return s.bot 67 | } 68 | 69 | func (s *service) GetDB() db.Client { 70 | return s.dbClient 71 | } 72 | 73 | func (s *service) IsMember(ctx context.Context, chatID, userID int64) (bool, error) { 74 | select { 75 | case <-ctx.Done(): 76 | return false, fmt.Errorf("context cancelled: %w", ctx.Err()) 77 | default: 78 | s.cacheMutex.RLock() 79 | if chatMembers, ok := s.memberCache[chatID]; ok { 80 | if expTime, ok := chatMembers[userID]; ok { 81 | if time.Now().Before(expTime) { 82 | s.cacheMutex.RUnlock() 83 | return true, nil 84 | } 85 | // Expired entry, remove it 86 | s.cacheMutex.RUnlock() 87 | s.cacheMutex.Lock() 88 | delete(s.memberCache[chatID], userID) 89 | s.cacheMutex.Unlock() 90 | } else { 91 | s.cacheMutex.RUnlock() 92 | } 93 | } else { 94 | s.cacheMutex.RUnlock() 95 | } 96 | 97 | isMember, err := s.dbClient.IsMember(ctx, chatID, userID) 98 | if err != nil { 99 | return false, fmt.Errorf("failed to check membership: %w", err) 100 | } 101 | 102 | if isMember { 103 | chatMember, err := s.bot.GetChatMember(api.GetChatMemberConfig{ 104 | ChatConfigWithUser: api.ChatConfigWithUser{ 105 | ChatConfig: api.ChatConfig{ 106 | ChatID: chatID, 107 | }, 108 | UserID: userID, 109 | }, 110 | }) 111 | if err != nil { 112 | s.log.WithError(err).WithFields(logrus.Fields{ 113 | "chat_id": chatID, 114 | "user_id": userID, 115 | }).Warn("Failed to verify membership with Telegram API") 116 | } else { 117 | if chatMember.HasLeft() || chatMember.WasKicked() { 118 | // User is not in chat anymore, remove from DB and return false 119 | if err := s.DeleteMember(ctx, chatID, userID); err != nil { 120 | s.log.WithError(err).Error("Failed to delete member who left") 121 | } 122 | return false, nil 123 | } 124 | } 125 | 126 | s.cacheMutex.Lock() 127 | if _, ok := s.memberCache[chatID]; !ok { 128 | s.memberCache[chatID] = make(map[int64]time.Time) 129 | } 130 | s.memberCache[chatID][userID] = time.Now().Add(s.cacheExpiration) 131 | s.cacheMutex.Unlock() 132 | } 133 | 134 | return isMember, nil 135 | } 136 | } 137 | 138 | func (s *service) InsertMember(ctx context.Context, chatID, userID int64) error { 139 | select { 140 | case <-ctx.Done(): 141 | return ctx.Err() 142 | default: 143 | chatMember, err := s.bot.GetChatMember(api.GetChatMemberConfig{ 144 | ChatConfigWithUser: api.ChatConfigWithUser{ 145 | ChatConfig: api.ChatConfig{ 146 | ChatID: chatID, 147 | }, 148 | UserID: userID, 149 | }, 150 | }) 151 | if err != nil { 152 | return fmt.Errorf("failed to get chat member: %w", err) 153 | } 154 | 155 | if chatMember.HasLeft() { 156 | return fmt.Errorf("user has left the chat") 157 | } 158 | if chatMember.WasKicked() { 159 | return fmt.Errorf("user was kicked from the chat") 160 | } 161 | 162 | err = s.dbClient.InsertMember(ctx, chatID, userID) 163 | if err != nil { 164 | return fmt.Errorf("failed to insert member: %w", err) 165 | } 166 | 167 | s.cacheMutex.Lock() 168 | defer s.cacheMutex.Unlock() 169 | 170 | if _, ok := s.memberCache[chatID]; !ok { 171 | s.memberCache[chatID] = make(map[int64]time.Time) 172 | } 173 | s.memberCache[chatID][userID] = time.Now().Add(s.cacheExpiration) 174 | return nil 175 | } 176 | } 177 | 178 | func (s *service) DeleteMember(ctx context.Context, chatID, userID int64) error { 179 | select { 180 | case <-ctx.Done(): 181 | return ctx.Err() 182 | default: 183 | err := s.dbClient.DeleteMember(ctx, chatID, userID) 184 | if err != nil { 185 | return err 186 | } 187 | 188 | s.cacheMutex.Lock() 189 | defer s.cacheMutex.Unlock() 190 | if members, ok := s.memberCache[chatID]; ok { 191 | delete(members, userID) 192 | } 193 | 194 | return nil 195 | } 196 | } 197 | 198 | func (s *service) GetSettings(ctx context.Context, chatID int64) (*db.Settings, error) { 199 | s.cacheMutex.RLock() 200 | if settings, ok := s.settingsCache[chatID]; ok { 201 | s.cacheMutex.RUnlock() 202 | return settings, nil 203 | } 204 | s.cacheMutex.RUnlock() 205 | 206 | settings, err := s.dbClient.GetSettings(ctx, chatID) 207 | if err != nil { 208 | if errors.Is(err, sql.ErrNoRows) { 209 | settings = &db.Settings{ 210 | ID: chatID, 211 | Enabled: true, 212 | ChallengeTimeout: (3 * time.Minute).Nanoseconds(), 213 | RejectTimeout: (10 * time.Minute).Nanoseconds(), 214 | Language: "en", 215 | } 216 | if err := s.SetSettings(ctx, settings); err != nil { 217 | return nil, fmt.Errorf("error setting default settings: %w", err) 218 | } 219 | } else { 220 | return nil, fmt.Errorf("error fetching settings from database: %w", err) 221 | } 222 | } 223 | 224 | s.cacheMutex.Lock() 225 | s.settingsCache[chatID] = settings 226 | s.cacheMutex.Unlock() 227 | 228 | return settings, nil 229 | } 230 | 231 | func (s *service) SetSettings(ctx context.Context, settings *db.Settings) error { 232 | err := s.dbClient.SetSettings(ctx, settings) 233 | if err != nil { 234 | return err 235 | } 236 | 237 | s.cacheMutex.Lock() 238 | defer s.cacheMutex.Unlock() 239 | s.settingsCache[settings.ID] = settings 240 | 241 | return nil 242 | } 243 | 244 | func (s *service) warmupCache(ctx context.Context) error { 245 | g, ctx := errgroup.WithContext(ctx) 246 | 247 | g.Go(func() error { 248 | members, err := s.dbClient.GetAllMembers(ctx) 249 | if err != nil { 250 | return fmt.Errorf("failed to warmup member cache: %w", err) 251 | } 252 | s.cacheMutex.Lock() 253 | for chatID, userIDs := range members { 254 | if _, ok := s.memberCache[chatID]; !ok { 255 | s.memberCache[chatID] = make(map[int64]time.Time) 256 | } 257 | for userID := range userIDs { 258 | s.memberCache[chatID][int64(userID)] = time.Now().Add(s.cacheExpiration) 259 | } 260 | } 261 | s.cacheMutex.Unlock() 262 | return nil 263 | }) 264 | 265 | g.Go(func() error { 266 | settings, err := s.dbClient.GetAllSettings(ctx) 267 | if err != nil { 268 | return fmt.Errorf("failed to warmup settings cache: %w", err) 269 | } 270 | s.cacheMutex.Lock() 271 | for chatID, setting := range settings { 272 | s.settingsCache[chatID] = setting 273 | } 274 | s.cacheMutex.Unlock() 275 | return nil 276 | }) 277 | 278 | if err := g.Wait(); err != nil { 279 | return err 280 | } 281 | 282 | membersCount := 0 283 | for _, userIDs := range s.memberCache { 284 | membersCount += len(userIDs) 285 | } 286 | 287 | s.getLogEntry().WithFields(logrus.Fields{ 288 | "membersChats": len(s.memberCache), 289 | "membersCount": membersCount, 290 | "settingsCount": len(s.settingsCache), 291 | }).Info("Cache warmed up successfully") 292 | return nil 293 | } 294 | 295 | func (s *service) getLogEntry() *logrus.Entry { 296 | return s.log.WithField("object", "Service") 297 | } 298 | 299 | func (s *service) Shutdown(ctx context.Context) error { 300 | s.cancel() 301 | s.dbClient.Close() 302 | s.memberCache = nil 303 | s.settingsCache = nil 304 | return nil 305 | } 306 | 307 | func (s *service) GetLanguage(ctx context.Context, chatID int64, user *api.User) string { 308 | if settings, err := s.GetSettings(ctx, chatID); err == nil && settings != nil { 309 | return settings.Language 310 | } 311 | if user != nil && tool.In(user.LanguageCode, i18n.GetLanguagesList()...) { 312 | return user.LanguageCode 313 | } 314 | return config.Get().DefaultLanguage 315 | } 316 | 317 | func (s *service) CleanupLeftMembers(ctx context.Context) error { 318 | members, err := s.dbClient.GetAllMembers(ctx) 319 | if err != nil { 320 | s.getLogEntry().WithField("error", err.Error()).Error("failed to get all members") 321 | return err 322 | } 323 | 324 | skipChats := []int64{} 325 | throttle := time.NewTicker(1 * time.Second) 326 | defer throttle.Stop() 327 | 328 | for chatID, userIDs := range members { 329 | for _, userID := range userIDs { 330 | if tool.In(chatID, skipChats...) { 331 | continue 332 | } 333 | select { 334 | case <-ctx.Done(): 335 | return ctx.Err() 336 | case <-throttle.C: 337 | 338 | chatMember, err := s.bot.GetChatMember(api.GetChatMemberConfig{ 339 | ChatConfigWithUser: api.ChatConfigWithUser{ 340 | ChatConfig: api.ChatConfig{ 341 | ChatID: chatID, 342 | }, 343 | UserID: userID, 344 | }, 345 | }) 346 | if err != nil { 347 | skipChats = append(skipChats, chatID) 348 | continue 349 | } 350 | 351 | if chatMember.HasLeft() || chatMember.WasKicked() { 352 | if err := s.DeleteMember(ctx, chatID, userID); err != nil { 353 | s.getLogEntry().WithField("error", err.Error()).Error("failed to delete left member") 354 | } 355 | } 356 | throttle.Reset(1 * time.Second) 357 | } 358 | } 359 | } 360 | return nil 361 | } 362 | -------------------------------------------------------------------------------- /internal/handlers/moderation/spam_detector.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/iamwavecut/ngbot/internal/adapters" 9 | "github.com/iamwavecut/ngbot/internal/adapters/llm" 10 | "github.com/iamwavecut/tool" 11 | "github.com/pkg/errors" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type spamDetector struct { 16 | llm adapters.LLM 17 | logger *log.Entry 18 | } 19 | 20 | type example struct { 21 | Message string `json:"message"` 22 | Response int `json:"response"` 23 | } 24 | 25 | var examples = []example{ 26 | { 27 | Message: "Hello, how are you?", 28 | Response: 0, 29 | }, 30 | {Message: "Hello, how are you?", Response: 0}, 31 | {Message: "Хочешь зарабатывать на удалёнке но не знаешь как? Напиши мне и я тебе всё расскажу, от 18 лет. жду всех желающих в лс.", Response: 1}, 32 | {Message: "Нужны люди! Стабильнный доход, каждую неделю, на удалёнке, от 18 лет, пишите в лс.", Response: 1}, 33 | {Message: "Ищу людeй, заинтeрeсованных в хoрoшем доп.доходе на удаленке. Не полная занятость, от 21. По вопросам пишите в ЛС", Response: 1}, 34 | {Message: "10000х Орууу в других играл и такого не разу не было, просто капец а такое возможно???? ", Response: 1}, 35 | {Message: `🥇Первая игровая платформа в Telegram 36 | 37 | https://t.me/jetton?start=cdyrsJsbvYy 38 | `, Response: 1}, 39 | {Message: "Набираю команду нужно 2-3 человека на удалённую работу з телефона пк от десят тысяч в день пишите + в лс", Response: 1}, 40 | {Message: `💎 Пᴩᴏᴇᴋᴛ TONCOIN, ʙыᴨуᴄᴛиᴧ ᴄʙᴏᴇᴦᴏ ᴋᴀɜинᴏ бᴏᴛᴀ ʙ ᴛᴇᴧᴇᴦᴩᴀʍʍᴇ 41 | 42 | 👑 Сᴀʍыᴇ ʙыᴄᴏᴋиᴇ ɯᴀнᴄы ʙыиᴦᴩыɯᴀ 43 | ⏳ Мᴏʍᴇнᴛᴀᴧьный ʙʙᴏд и ʙыʙᴏд 44 | 🎲 Нᴇ ᴛᴩᴇбуᴇᴛ ᴩᴇᴦиᴄᴛᴩᴀции 45 | 🏆 Вᴄᴇ ᴧучɯиᴇ ᴨᴩᴏʙᴀйдᴇᴩы и иᴦᴩы 46 | 47 | 🍋 Зᴀбᴩᴀᴛь 1000 USDT 👇 48 | 49 | t.me/slotsTON_BOT?start=cdyoNKvXn75`, Response: 1}, 50 | {Message: "Эротика", Response: 0}, 51 | {Message: "Олегик)))", Response: 0}, 52 | {Message: "Авантюра!", Response: 0}, 53 | {Message: "Я всё понял, спасибо!", Response: 0}, 54 | {Message: "Это не так", Response: 0}, 55 | {Message: "Не сочтите за спам, хочу порекламировать свой канал", Response: 0}, 56 | {Message: "Нет", Response: 0}, 57 | {Message: "???", Response: 0}, 58 | {Message: "...", Response: 0}, 59 | {Message: "Да", Response: 0}, 60 | {Message: "Ищу людей, возьму 2-3 человека 18+ Удаленная деятельность.От 250$ в день.Кому интересно: Пишите + в лс", Response: 1}, 61 | {Message: "Нужны люди, занятость на удалёнке", Response: 1}, 62 | {Message: "3дpaвcтвyйтe,Веду поиск пaртнёров для сoтруднuчества ,свoбoдный гpaфик ,пpuятный зapaбoтok eженeдельно. Ecли интepecуeт пoдpoбнaя инфopмaция пишuте.", Response: 1}, 63 | {Message: `💚💚💚💚💚💚💚💚 64 | Ищy нa oбyчeниe людeй c цeлью зapaбoткa. 💼 65 | *⃣Haпpaвлeниe: Crypto, Тecтнeты, Aиpдpoпы. 66 | *⃣Пo вpeмeни в cyтки 1-2 чaca, мoжнo paбoтaть co cмapтфoнa. 🤝 67 | *⃣Дoxoднocть чиcтaя в дeнь paвняeтcя oт 7-9 пpoцeнтoв. 68 | *⃣БECПЛAТHOE OБУЧEHИE, мoй интepec пpoцeнт oт зapaбoткa. 💶 69 | Ecли зaинтepecoвaлo пишитe нa мoй aкк >>> @Alex51826.`, Response: 1}, 70 | {Message: "Ищу партнеров для заработка пассивной прибыли, много времени не занимает + хороший еженедельный доп.доход. Пишите + в личные", Response: 1}, 71 | {Message: "Удалённая занятость, с хорошей прибылью 350 долларов в день.1-2 часа в день. Ставь плюс мне в личные смс.", Response: 1}, 72 | {Message: "Прибыльное предложение для каждого, подработка на постоянной основе(удаленно) , опыт не важен.Пишите в личные смс !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!", Response: 1}, 73 | {Message: "Здрaвствуйте! Хочу вам прeдложить вaриант пaссивного заработка.Удaленка.Обучение бeсплатное, от вас трeбуeтся только пaрa чaсов свoбoднoгo времeни и тeлeфон или компьютер. Если интересно напиши мне.", Response: 1}, 74 | {Message: "Ищу людей, возьму 3 человека от 20 лет. Удаленная деятельность. От 250 дoлларов в день. Кому интересно пишите плюс в личку", Response: 1}, 75 | {Message: "Добрый вечер! Интересный вопрос) я бы тоже с удовольствием узнала информацию", Response: 0}, 76 | {Message: `Янтарик — кошка-мартышка, сгусток энергии с отличным урчателем ❤️‍🔥 77 | 78 | 🧡 Ищет человека, которому мурчать 79 | 🧡 Около 11 месяцев 80 | 🧡 Стерилизована. Обработана от паразитов. Впереди вакцинация, чип и паспорт 81 | 🧡 C ненавязчивым отслеживанием судьбы 🙏 82 | 🇬🇪 Готова отправиться в любой уголок Грузии, рассмотрим варианты и дальше 83 | 84 | Телеграм nervnyi_komok 85 | WhatsApp +999 599 099 567`, Response: 0}, 86 | {Message: "Есть несложная занятость! Работаем из дому. Доход от 450 долл. в день. Необходимо полтора-два часа в день. Ставьте «+» в л.с.", Response: 1}, 87 | {Message: "Здравствуйте. Есть вoзможность дистанционного зaработка.Стaбильность в виде 45 000 рyблей в неделю. Опыт не требуется. Все подробности у меня в личке", Response: 1}, 88 | {Message: "Удалённая зaнятость, с хорoшей прибылью 550 долларов в день. два часа в день. Ставь плюс мне в личные", Response: 1}, 89 | {Message: `💚💚💚💚💚💚💚💚 90 | Ищy нa oбyчeниe людeй c цeлью зapaбoткa. 💼 91 | *⃣Haпpaвлeниe: Crypto, Тecтнeты, Aиpдpoпы. 92 | *⃣Пo вpeмeни в cyтки 1-2 чaca, мoжнo paбoтaть co cмapтфoнa. 🤝 93 | *⃣Дoxoднocть чиcтaя в дeнь paвняeтcя oт 7-9 пpoцeнтoв. 94 | *⃣БECПЛAТHOE OБУЧEHИE, мoй интepec пpoцeнт oт зapaбoткa. 💶 95 | Ecли зaинтepecoвaлo пишuте нa мoй aкк >>> @Alex51826.`, Response: 1}, 96 | {Message: "Нужны люди для сотрудничества. Хорошая прибыль в неделю, от тысячи долларов и выше. Удаленно. За подробностями пишите мне плюс в личные сообщения, от двадцати лет", Response: 1}, 97 | {Message: `Предлагаю удаленное сотрудничество от $2500 в месяц. 98 | 99 | Требования: 100 | – Мобильный телефон или компьютер 101 | – Немного свободного времени и желания 102 | – Быстрая обучаемость 103 | 104 | За подробностями – пишите в личные сообщения!`, Response: 1}, 105 | {Message: "Добрый вечер. Завтра вечером еду из Кобулети в Брест с остановкой в Минске в 18:00. Возьму небольшие передачки и документы. Писать в лс", Response: 0}, 106 | {Message: "https://anywebsite.com/in/p/1234567890", Response: 0}, 107 | {Message: `Heвepoятный дeнeжный пoтoк кaждый дeнь. 108 | - пpoфuт oт 3OO USD в дeнь 109 | - нoвaя cтopoнa yчacтuя 110 | Cтuмyлupoвaнным пucaть "+" в cмc`, Response: 1}, 111 | {Message: "ᴨᴩиʙᴇᴛ!ищу ᴧюдᴇй дᴧя ᴨᴀccиʙноᴦo зᴀᴩᴀбoᴛᴋᴀ. ᴨᴧюcы:xoᴩoɯий дoxoд, удᴀᴧённый ɸoᴩʍᴀᴛ, ᴨᴩoᴄᴛоᴛᴀ. ᴇᴄᴧи инᴛᴇᴩᴇᴄно, нᴀᴨиɯиᴛᴇ + ʙ ᴧ.c.", Response: 1}, 112 | {Message: "Для тех, у кого цель получать от 1000 доллаpов, есть нaправление не требующее наличие знаний и oпыта. Нужно два часа в день и наличие амбиций. От 21 до 65 лет.", Response: 1}, 113 | {Message: "Зpaвcтвyйтe.Нyжны два три чeлoвeкa.Удаленная Работа Oт 200 долл в дeнь.Зa пoдpoбнocтями пиши плюс в лс", Response: 1}, 114 | {Message: `Добрый день! 115 | Рекомендую "открывашку" контактов, да и с подбором "под ключ" справится оперативно 89111447979`, Response: 1}, 116 | {Message: "Веду пoиск людей для хорoшего доxода нa диcтанционном формaте, от тысячи доллров в неделю, детали в личных сoобщениях", Response: 1}, 117 | {Message: "Нужны заинтересованные люди в команду. Возможен доход от 900 долларов за неделю,полностью дистанционный формат.Пишите мне + в личные сообщения", Response: 1}, 118 | {Message: `🍓 СЛИТЫЕ ИНТИМ ФОТО ЛЮБОЙ ДЕВУШКИ В ЭТОМ БОТЕ 119 | 120 | 🍑 ПЕРЕХОДИ И УБЕДИСЬ ⬇️ 121 | 122 | https://t.me/shop_6o11rU_bot?start=2521`, Response: 1}, 123 | {Message: `Есть несколько мест на УДАЛЕНКУ с хорошим доходом . 124 | 125 | Занятость 1-2 часа в день, от 18 лет 126 | 127 | 128 | Пишите в ЛС за деталями!`, Response: 1}, 129 | {Message: "Oткpыт нaбop в кoмaндy, в нoвoм oнлaйн пpoeктe. Eжeднeвный дoxoд бoлee З4O ЕUR. Жeлaющux ждy в лuчнoм чaтe.", Response: 1}, 130 | {Message: `Пpuветствyю, ecть 4 cвoбoдныx мecта в paзвuвающeecя кoмьюнuтu. 131 | Пpeдocтaвuм вoзмoжнocть пoлyчaть cвышe 2ООО USd в нeдeлю. 132 | Пucaть тoлькo зauнтepecoвaнным.`, Response: 1}, 133 | {Message: `Привет, нужны люди, оплата достойная, берем без опыта, за подробностями в лс 134 | *Для работы нужен телефон 135 | *2-3 часа времени`, Response: 1}, 136 | {Message: "Ночью с 12 на 13 ноября еду из аэропорта Кутаиси до Батуми. Возьму за бензин. Кому интересно пишите в ЛС.", Response: 0}, 137 | {Message: "Купите в зумере, съездите в сарпи, tax free, заберите 11% с покупки и вуаля, норм цена", Response: 0}, 138 | {Message: "Здpaвcтвyйтe.Нyжны двa три чeлoвeкa (Удaлeннaя cфеpa) Oт 570 $/неделю.Зa пoдpoбнocтями пиши плюc в лc", Response: 1}, 139 | {Message: "Всем кoму интереcно имeть xороший cтабильный доxод на yдаленке cо свободной занятостью , ждy в лc.", Response: 1}, 140 | {Message: "Хай. Устали от быстрого заpаботка и пустых обещаний? Давайте лучше рaботать с реальными резyльтатами. Мы предлагаем стабильнoе нaправление, где можно полyчать от 800 дoлларов в неделю с отличной перcпективой ростa. Пишите плюс в личные сообщения и я дам всё необходимое", Response: 1}, 141 | } 142 | 143 | func NewSpamDetector(llm adapters.LLM, logger *log.Entry) *spamDetector { 144 | return &spamDetector{ 145 | llm: llm, 146 | logger: logger, 147 | } 148 | } 149 | 150 | func (d *spamDetector) IsSpam(ctx context.Context, message string) (*bool, error) { 151 | d.logger.WithField("message", message).Debug("checking spam") 152 | 153 | messagesChain := []llm.ChatCompletionMessage{ 154 | { 155 | Role: "system", 156 | Content: spamDetectionPrompt, 157 | }, 158 | } 159 | 160 | for _, example := range examples { 161 | messagesChain = append(messagesChain, llm.ChatCompletionMessage{ 162 | Role: "user", 163 | Content: example.Message, 164 | }) 165 | messagesChain = append(messagesChain, llm.ChatCompletionMessage{ 166 | Role: "assistant", 167 | Content: strconv.Itoa(example.Response), 168 | }) 169 | } 170 | 171 | messagesChain = append(messagesChain, llm.ChatCompletionMessage{ 172 | Role: "user", 173 | Content: message, 174 | }) 175 | 176 | resp, err := d.llm.ChatCompletion( 177 | ctx, 178 | messagesChain, 179 | ) 180 | if err != nil { 181 | return nil, errors.Wrap(err, "failed to check spam with LLM") 182 | } 183 | 184 | if len(resp.Choices) == 0 { 185 | return nil, errors.New("no response from LLM") 186 | } 187 | 188 | if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" { 189 | return nil, errors.New("empty response from LLM") 190 | } 191 | choice := resp.Choices[0].Message.Content 192 | cleanedChoice := strings.Map(func(r rune) rune { 193 | if r >= '0' && r <= '1' { 194 | return r 195 | } 196 | return -1 197 | }, choice) 198 | 199 | if cleanedChoice == "1" { 200 | return tool.Ptr(true), nil 201 | } else if cleanedChoice == "0" { 202 | return tool.Ptr(false), nil 203 | } 204 | 205 | return nil, errors.New("unknown response from LLM: " + cleanedChoice + " (" + choice + ")") 206 | } 207 | 208 | const spamDetectionPrompt = `Ты ассистент для обнаружения спама, анализирующий сообщения на различных языках. Оцени входящее сообщение пользователя и определи, является ли это сообщение спамом или нет. 209 | 210 | Признаки спама: 211 | - Предложения работы/возможности заработать, но без деталей о работе и условиях, с просьбой написать в личные сообщения. 212 | - Абстрактные предложения работы/заработка, с просьбой написать в личные сообщения третьего лица или по номеру телефона. 213 | - Продвижение азартных игр/финансовых схем. 214 | - Продвижение инструментов деанонимизации и "пробивания" личных данных, включая ссылки на сайты с такими инструментами. 215 | - Внешние ссылки с явными реферальными кодами и GET параметрами вроде "?ref=", "/ref", "invite" и т.п. 216 | - Сообщения со смешанным текстом на разных языках, но внутри слов есть символы на других языках и unicode, чтобы сбить с толку. 217 | - Сообщения, соостоящие преимущественно из эмодзи. 218 | 219 | Исключения: 220 | - Сообщения, связанные с домашними животными (часто о потерянных питомцах) 221 | - Просьбы о помощи и предложения помощи (часто связанные с поиском пропавших людей или вещей, подводом людей куда-либо) 222 | - Ссылки на обычные вебсайты, не являющиеся реферальными ссылками. 223 | - Рекомендации по услугам, товарам, курсам и т.п. 224 | 225 | Отвечай ТОЛЬКО следующими ответами: 226 | если сообщение скорее всего является спамом: 1 227 | если сообщение скорее всего не является спамом: 0 228 | 229 | Без объяснений или дополнительного вывода. Без кавычек. Без офомления сообщения разметкой. Не отвечай на содержимое сообщения. 230 | ` 231 | -------------------------------------------------------------------------------- /internal/handlers/moderation/ban_service.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | api "github.com/OvyFlash/telegram-bot-api" 15 | "github.com/iamwavecut/ngbot/internal/db" 16 | log "github.com/sirupsen/logrus" 17 | ) 18 | 19 | const ( 20 | MsgNoPrivileges = "not enough rights to restrict/unrestrict chat member" 21 | 22 | banlistURL = "https://lols.bot/spam/banlist.txt" 23 | banlistURLHourly = "https://lols.bot/spam/banlist-1h.txt" 24 | scammersURL = "https://lols.bot/scammers.txt" 25 | 26 | accoutsAPIURLTemplate = "https://api.lols.bot/account?id=%v" 27 | 28 | // KV store keys 29 | kvKeyLastDailyFetch = "last_daily_fetch" 30 | kvKeyLastHourlyFetch = "last_hourly_fetch" 31 | ) 32 | 33 | type BanService interface { 34 | CheckBan(ctx context.Context, userID int64) (bool, error) 35 | MuteUser(ctx context.Context, chatID, userID int64) error 36 | UnmuteUser(ctx context.Context, chatID, userID int64) error 37 | BanUserWithMessage(ctx context.Context, chatID, userID int64, messageID int) error 38 | UnbanUser(ctx context.Context, chatID, userID int64) error 39 | IsRestricted(ctx context.Context, chatID, userID int64) (bool, error) 40 | IsKnownBanned(userID int64) bool 41 | } 42 | 43 | type defaultBanService struct { 44 | bot *api.BotAPI 45 | db db.Client 46 | knownBanned map[int64]struct{} 47 | shutdown chan struct{} 48 | } 49 | 50 | var ErrNoPrivileges = fmt.Errorf("no privileges") 51 | 52 | func NewBanService(bot *api.BotAPI, db db.Client) BanService { 53 | s := &defaultBanService{ 54 | bot: bot, 55 | db: db, 56 | knownBanned: map[int64]struct{}{}, 57 | shutdown: make(chan struct{}), 58 | } 59 | 60 | ctx := context.Background() 61 | lastDailyFetch, err := s.getLastDailyFetch(ctx) 62 | if err != nil { 63 | log.WithError(err).Error("Failed to get last daily fetch time") 64 | } 65 | 66 | lastHourlyFetch, err := s.getLastHourlyFetch(ctx) 67 | if err != nil { 68 | log.WithError(err).Error("Failed to get last hourly fetch time") 69 | } 70 | 71 | wg := sync.WaitGroup{} 72 | wg.Add(1) 73 | go func() { 74 | defer wg.Done() 75 | if lastDailyFetch.IsZero() || time.Since(lastDailyFetch) > time.Hour { 76 | if err := s.FetchKnownBannedDaily(ctx); err != nil { 77 | log.WithError(err).Error("Failed to fetch known banned users daily") 78 | } 79 | } else if lastHourlyFetch.IsZero() || time.Since(lastHourlyFetch) <= time.Hour { 80 | if err := s.FetchKnownBanned(ctx); err != nil { 81 | log.WithError(err).Error("Failed to fetch known banned users hourly") 82 | } 83 | } 84 | }() 85 | 86 | go func() { 87 | for { 88 | select { 89 | case <-s.shutdown: 90 | log.WithField("routine", "FetchKnownBanned").Info("Graceful shutdown completed") 91 | return 92 | case <-time.After(time.Hour): 93 | ctx := context.Background() 94 | lastFetch, err := s.getLastHourlyFetch(ctx) 95 | if err != nil { 96 | log.WithError(err).Error("Failed to get last hourly fetch time") 97 | continue 98 | } 99 | 100 | // Only fetch if last fetch was more than 1 hour ago 101 | if lastFetch.IsZero() || time.Since(lastFetch) >= time.Hour { 102 | if err := s.FetchKnownBanned(ctx); err != nil { 103 | log.WithError(err).Error("Failed to fetch known banned users hourly") 104 | } 105 | } 106 | } 107 | } 108 | }() 109 | wg.Wait() 110 | return s 111 | } 112 | 113 | func fetchURLs(ctx context.Context, urls []string) (map[int64]struct{}, error) { 114 | results := make(map[int64]struct{}) 115 | for _, url := range urls { 116 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 117 | if err != nil { 118 | return nil, fmt.Errorf("failed to create request: %w", err) 119 | } 120 | req.Header.Set("accept", "text/plain") 121 | 122 | resp, err := http.DefaultClient.Do(req) 123 | if err != nil { 124 | return nil, fmt.Errorf("failed to send request: %w", err) 125 | } 126 | defer resp.Body.Close() 127 | 128 | scanner := bufio.NewScanner(resp.Body) 129 | for scanner.Scan() { 130 | userIDStr := scanner.Text() 131 | if userIDStr == "" { 132 | continue 133 | } 134 | userID, err := strconv.ParseInt(userIDStr, 10, 64) 135 | if err != nil { 136 | return nil, fmt.Errorf("failed to parse user ID: %w", err) 137 | } 138 | results[userID] = struct{}{} 139 | } 140 | if err := scanner.Err(); err != nil { 141 | return nil, fmt.Errorf("failed to scan response body: %w", err) 142 | } 143 | } 144 | return results, nil 145 | } 146 | 147 | func (s *defaultBanService) getLastDailyFetch(ctx context.Context) (time.Time, error) { 148 | val, err := s.db.GetKV(ctx, kvKeyLastDailyFetch) 149 | if err != nil { 150 | return time.Time{}, fmt.Errorf("failed to get last daily fetch time: %w", err) 151 | } 152 | if val == "" { 153 | return time.Time{}, nil 154 | } 155 | t, err := time.Parse(time.RFC3339, val) 156 | if err != nil { 157 | return time.Time{}, fmt.Errorf("failed to parse last daily fetch time: %w", err) 158 | } 159 | return t, nil 160 | } 161 | 162 | func (s *defaultBanService) getLastHourlyFetch(ctx context.Context) (time.Time, error) { 163 | val, err := s.db.GetKV(ctx, kvKeyLastHourlyFetch) 164 | if err != nil { 165 | return time.Time{}, fmt.Errorf("failed to get last hourly fetch time: %w", err) 166 | } 167 | if val == "" { 168 | return time.Time{}, nil 169 | } 170 | t, err := time.Parse(time.RFC3339, val) 171 | if err != nil { 172 | return time.Time{}, fmt.Errorf("failed to parse last hourly fetch time: %w", err) 173 | } 174 | return t, nil 175 | } 176 | 177 | func (s *defaultBanService) updateLastDailyFetch(ctx context.Context) error { 178 | return s.db.SetKV(ctx, kvKeyLastDailyFetch, time.Now().Format(time.RFC3339)) 179 | } 180 | 181 | func (s *defaultBanService) updateLastHourlyFetch(ctx context.Context) error { 182 | return s.db.SetKV(ctx, kvKeyLastHourlyFetch, time.Now().Format(time.RFC3339)) 183 | } 184 | 185 | func (s *defaultBanService) FetchKnownBannedDaily(ctx context.Context) error { 186 | results, err := fetchURLs(ctx, []string{scammersURL, banlistURL}) 187 | if err != nil { 188 | return fmt.Errorf("failed to fetch known banned daily: %w", err) 189 | } 190 | 191 | userIDs := make([]int64, 0, len(results)) 192 | for userID := range results { 193 | userIDs = append(userIDs, userID) 194 | } 195 | if err := s.db.UpsertBanlist(ctx, userIDs); err != nil { 196 | return fmt.Errorf("failed to upsert banlist: %w", err) 197 | } 198 | fullBanlist, err := s.db.GetBanlist(ctx) 199 | if err != nil { 200 | return fmt.Errorf("failed to get banlist: %w", err) 201 | } 202 | s.knownBanned = fullBanlist 203 | log.WithField("count", len(fullBanlist)).Debug("fetched known banned ids daily") 204 | 205 | if err := s.updateLastDailyFetch(ctx); err != nil { 206 | log.WithError(err).Error("Failed to update last daily fetch time") 207 | } 208 | 209 | return nil 210 | } 211 | 212 | func (s *defaultBanService) FetchKnownBanned(ctx context.Context) error { 213 | results, err := fetchURLs(ctx, []string{banlistURLHourly}) 214 | if err != nil { 215 | return fmt.Errorf("failed to fetch known banned hourly: %w", err) 216 | } 217 | userIDs := make([]int64, 0, len(results)) 218 | for userID := range results { 219 | userIDs = append(userIDs, userID) 220 | } 221 | if err := s.db.UpsertBanlist(ctx, userIDs); err != nil { 222 | return fmt.Errorf("failed to upsert banlist: %w", err) 223 | } 224 | fullBanlist, err := s.db.GetBanlist(ctx) 225 | if err != nil { 226 | return fmt.Errorf("failed to get banlist: %w", err) 227 | } 228 | s.knownBanned = fullBanlist 229 | log.WithField("count", len(fullBanlist)).Debug("fetched known banned ids hourly") 230 | 231 | if err := s.updateLastHourlyFetch(ctx); err != nil { 232 | log.WithError(err).Error("Failed to update last hourly fetch time") 233 | } 234 | 235 | return nil 236 | } 237 | 238 | func (s *defaultBanService) IsKnownBanned(userID int64) bool { 239 | _, banned := s.knownBanned[userID] 240 | return banned 241 | } 242 | 243 | func (s *defaultBanService) CheckBan(ctx context.Context, userID int64) (bool, error) { 244 | banned := s.IsKnownBanned(userID) 245 | if banned { 246 | return true, nil 247 | } 248 | 249 | url := fmt.Sprintf(accoutsAPIURLTemplate, userID) 250 | 251 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 252 | if err != nil { 253 | return false, fmt.Errorf("failed to create request: %w", err) 254 | } 255 | req.Header.Set("accept", "text/plain") 256 | 257 | resp, err := http.DefaultClient.Do(req) 258 | if err != nil { 259 | return false, fmt.Errorf("failed to send request: %w", err) 260 | } 261 | defer resp.Body.Close() 262 | 263 | var banInfo struct { 264 | OK bool `json:"ok"` 265 | UserID int64 `json:"user_id"` 266 | Banned bool `json:"banned"` 267 | When string `json:"when"` 268 | Offenses int `json:"offenses"` 269 | SpamFactor float64 `json:"spam_factor"` 270 | } 271 | 272 | if err := json.NewDecoder(resp.Body).Decode(&banInfo); err != nil { 273 | return false, fmt.Errorf("failed to decode response: %w", err) 274 | } 275 | if banInfo.Banned { 276 | s.knownBanned[userID] = struct{}{} 277 | } 278 | return banInfo.Banned, nil 279 | } 280 | 281 | func (s *defaultBanService) MuteUser(ctx context.Context, chatID, userID int64) error { 282 | expiresAt := time.Now().Add(10 * time.Minute) 283 | config := api.RestrictChatMemberConfig{ 284 | ChatMemberConfig: api.ChatMemberConfig{ 285 | ChatConfig: api.ChatConfig{ChatID: chatID}, 286 | UserID: userID, 287 | }, 288 | Permissions: &api.ChatPermissions{}, 289 | UntilDate: expiresAt.Unix(), 290 | 291 | UseIndependentChatPermissions: true, 292 | } 293 | 294 | if _, err := s.bot.Request(config); err != nil { 295 | if strings.Contains(err.Error(), MsgNoPrivileges) { 296 | return ErrNoPrivileges 297 | } 298 | return fmt.Errorf("failed to restrict user: %w", err) 299 | } 300 | 301 | restriction := &db.UserRestriction{ 302 | UserID: userID, 303 | ChatID: chatID, 304 | RestrictedAt: time.Now(), 305 | ExpiresAt: expiresAt, 306 | Reason: "Spam suspect", 307 | } 308 | 309 | if err := s.db.AddRestriction(ctx, restriction); err != nil { 310 | return fmt.Errorf("failed to add restriction: %w", err) 311 | } 312 | 313 | return nil 314 | } 315 | 316 | func (s *defaultBanService) UnmuteUser(ctx context.Context, chatID, userID int64) error { 317 | config := api.RestrictChatMemberConfig{ 318 | ChatMemberConfig: api.ChatMemberConfig{ 319 | ChatConfig: api.ChatConfig{ChatID: chatID}, 320 | UserID: userID, 321 | }, 322 | Permissions: &api.ChatPermissions{}, 323 | UntilDate: 0, 324 | 325 | UseIndependentChatPermissions: false, 326 | } 327 | 328 | if _, err := s.bot.Request(config); err != nil { 329 | if strings.Contains(err.Error(), MsgNoPrivileges) { 330 | return ErrNoPrivileges 331 | } 332 | return fmt.Errorf("failed to unrestrict user: %w", err) 333 | } 334 | 335 | if err := s.db.RemoveRestriction(ctx, chatID, userID); err != nil { 336 | return fmt.Errorf("failed to remove restriction: %w", err) 337 | } 338 | 339 | return nil 340 | } 341 | 342 | func (s *defaultBanService) BanUserWithMessage(ctx context.Context, chatID, userID int64, messageID int) error { 343 | expiresAt := time.Now().Add(10 * time.Minute) 344 | config := api.BanChatMemberConfig{ 345 | ChatMemberConfig: api.ChatMemberConfig{ 346 | ChatConfig: api.ChatConfig{ 347 | ChatID: chatID, 348 | }, 349 | UserID: userID, 350 | }, 351 | UntilDate: expiresAt.Unix(), 352 | RevokeMessages: true, 353 | } 354 | if _, err := s.bot.Request(config); err != nil { 355 | if strings.Contains(err.Error(), MsgNoPrivileges) { 356 | return ErrNoPrivileges 357 | } 358 | return fmt.Errorf("failed to ban user: %w", err) 359 | } 360 | 361 | restriction := &db.UserRestriction{ 362 | UserID: userID, 363 | ChatID: chatID, 364 | RestrictedAt: time.Now(), 365 | ExpiresAt: expiresAt, 366 | Reason: "Spam detection", 367 | } 368 | 369 | if err := s.db.AddRestriction(ctx, restriction); err != nil { 370 | return fmt.Errorf("failed to add ban: %w", err) 371 | } 372 | 373 | return nil 374 | } 375 | 376 | func (s *defaultBanService) UnbanUser(ctx context.Context, chatID, userID int64) error { 377 | config := api.UnbanChatMemberConfig{ 378 | ChatMemberConfig: api.ChatMemberConfig{ 379 | ChatConfig: api.ChatConfig{ChatID: chatID}, 380 | UserID: userID, 381 | }, 382 | } 383 | 384 | if _, err := s.bot.Request(config); err != nil { 385 | return fmt.Errorf("failed to unban user: %w", err) 386 | } 387 | 388 | if err := s.db.RemoveRestriction(ctx, chatID, userID); err != nil { 389 | return fmt.Errorf("failed to remove restriction: %w", err) 390 | } 391 | 392 | return nil 393 | } 394 | 395 | func (s *defaultBanService) IsRestricted(ctx context.Context, chatID, userID int64) (bool, error) { 396 | restriction, err := s.db.GetActiveRestriction(ctx, chatID, userID) 397 | if err != nil { 398 | return false, fmt.Errorf("failed to check restrictions: %w", err) 399 | } 400 | return restriction != nil && restriction.ExpiresAt.After(time.Now()), nil 401 | } 402 | -------------------------------------------------------------------------------- /internal/bot/update_processor.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | api "github.com/OvyFlash/telegram-bot-api" 10 | "github.com/pkg/errors" 11 | log "github.com/sirupsen/logrus" 12 | 13 | "github.com/iamwavecut/ngbot/internal/config" 14 | ) 15 | 16 | const ( 17 | UpdateTimeout = 5 * time.Minute 18 | ) 19 | 20 | type ( 21 | UpdateProcessor struct { 22 | s Service 23 | updateHandlers []Handler 24 | } 25 | 26 | MessageType string 27 | ) 28 | 29 | const ( 30 | MessageTypeText MessageType = "text" 31 | MessageTypeAnimation MessageType = "animation" 32 | MessageTypeAudio MessageType = "audio" 33 | MessageTypeContact MessageType = "contact" 34 | MessageTypeDice MessageType = "dice" 35 | MessageTypeDocument MessageType = "document" 36 | MessageTypeGame MessageType = "game" 37 | MessageTypeInvoice MessageType = "invoice" 38 | MessageTypeLocation MessageType = "location" 39 | MessageTypePhoto MessageType = "photo" 40 | MessageTypePoll MessageType = "poll" 41 | MessageTypeSticker MessageType = "sticker" 42 | MessageTypeStory MessageType = "story" 43 | MessageTypeVenue MessageType = "venue" 44 | MessageTypeVideo MessageType = "video" 45 | MessageTypeVideoNote MessageType = "video_note" 46 | MessageTypeVoice MessageType = "voice" 47 | MessageTypeEditedMessage MessageType = "edited_message" 48 | MessageTypeChannelPost MessageType = "channel_post" 49 | MessageTypeEditedChannelPost MessageType = "edited_channel_post" 50 | MessageTypePollAnswer MessageType = "poll_answer" 51 | MessageTypeMyChatMember MessageType = "my_chat_member" 52 | MessageTypeChatMember MessageType = "chat_member" 53 | MessageTypeChatJoinRequest MessageType = "chat_join_request" 54 | MessageTypeChatBoost MessageType = "chat_boost" 55 | ) 56 | 57 | var registeredHandlers = make(map[string]Handler) 58 | 59 | func RegisterUpdateHandler(title string, handler Handler) { 60 | registeredHandlers[title] = handler 61 | } 62 | 63 | func NewUpdateProcessor(s Service) *UpdateProcessor { 64 | enabledHandlers := make([]Handler, 0) 65 | for _, handlerName := range config.Get().EnabledHandlers { 66 | if _, ok := registeredHandlers[handlerName]; !ok || registeredHandlers[handlerName] == nil { 67 | log.Warnf("no registered handler: %s", handlerName) 68 | continue 69 | } 70 | enabledHandlers = append(enabledHandlers, registeredHandlers[handlerName]) 71 | } 72 | 73 | return &UpdateProcessor{ 74 | s: s, 75 | updateHandlers: enabledHandlers, 76 | } 77 | } 78 | 79 | func (up *UpdateProcessor) Process(ctx context.Context, u *api.Update) error { 80 | if u == nil { 81 | return errors.New("update is nil") 82 | } 83 | 84 | select { 85 | case <-ctx.Done(): 86 | return ctx.Err() 87 | default: 88 | var updateTime time.Time 89 | switch { 90 | case u.Message != nil: 91 | updateTime = time.Unix(int64(u.Message.Date), 0) 92 | case u.ChannelPost != nil: 93 | updateTime = time.Unix(int64(u.ChannelPost.Date), 0) 94 | default: 95 | return nil 96 | } 97 | 98 | if time.Since(updateTime) > UpdateTimeout { 99 | log.WithFields(log.Fields{ 100 | "update_time": updateTime, 101 | "age": time.Since(updateTime), 102 | }).Debug("Skipping outdated update") 103 | return nil 104 | } 105 | 106 | chat := u.FromChat() 107 | if chat == nil && u.ChatJoinRequest != nil { 108 | chat = &u.ChatJoinRequest.Chat 109 | } 110 | 111 | user := u.SentFrom() 112 | if user == nil && u.ChatJoinRequest != nil { 113 | user = &u.ChatJoinRequest.From 114 | } 115 | 116 | for _, handler := range up.updateHandlers { 117 | if handler == nil { 118 | continue 119 | } 120 | select { 121 | case <-ctx.Done(): 122 | return ctx.Err() 123 | default: 124 | proceed, err := handler.Handle(ctx, u, chat, user) 125 | if err != nil { 126 | return errors.WithMessage(err, "handling error") 127 | } 128 | if !proceed { 129 | log.Trace("not proceeding") 130 | return nil 131 | } 132 | } 133 | } 134 | return nil 135 | } 136 | } 137 | 138 | func DeleteChatMessage(ctx context.Context, bot *api.BotAPI, chatID int64, messageID int) error { 139 | select { 140 | case <-ctx.Done(): 141 | return ctx.Err() 142 | default: 143 | if _, err := bot.Request(api.NewDeleteMessage(chatID, messageID)); err != nil { 144 | return err 145 | } 146 | return nil 147 | } 148 | } 149 | 150 | func BanUserFromChat(ctx context.Context, bot *api.BotAPI, userID int64, chatID int64, untilUnix int64) error { 151 | select { 152 | case <-ctx.Done(): 153 | return ctx.Err() 154 | default: 155 | if _, err := bot.Request(api.BanChatMemberConfig{ 156 | ChatMemberConfig: api.ChatMemberConfig{ 157 | ChatConfig: api.ChatConfig{ 158 | ChatID: chatID, 159 | }, 160 | UserID: userID, 161 | }, 162 | UntilDate: untilUnix, 163 | RevokeMessages: true, 164 | }); err != nil { 165 | return errors.WithMessage(err, "cant kick") 166 | } 167 | return nil 168 | } 169 | } 170 | 171 | func RestrictChatting(ctx context.Context, bot *api.BotAPI, userID int64, chatID int64) error { 172 | select { 173 | case <-ctx.Done(): 174 | return ctx.Err() 175 | default: 176 | if _, err := bot.Request(api.RestrictChatMemberConfig{ 177 | ChatMemberConfig: api.ChatMemberConfig{ 178 | ChatConfig: api.ChatConfig{ 179 | ChatID: chatID, 180 | }, 181 | UserID: userID, 182 | }, 183 | UntilDate: time.Now().Add(10 * time.Minute).Unix(), 184 | Permissions: &api.ChatPermissions{ 185 | CanSendMessages: false, 186 | CanSendAudios: false, 187 | CanSendDocuments: false, 188 | CanSendPhotos: false, 189 | CanSendVideos: false, 190 | CanSendVideoNotes: false, 191 | CanSendVoiceNotes: false, 192 | CanSendPolls: false, 193 | CanSendOtherMessages: false, 194 | CanAddWebPagePreviews: false, 195 | CanChangeInfo: false, 196 | CanInviteUsers: false, 197 | CanPinMessages: false, 198 | CanManageTopics: false, 199 | }, 200 | }); err != nil { 201 | return errors.WithMessage(err, "cant restrict") 202 | } 203 | return nil 204 | } 205 | } 206 | 207 | func UnrestrictChatting(ctx context.Context, bot *api.BotAPI, userID int64, chatID int64) error { 208 | select { 209 | case <-ctx.Done(): 210 | return ctx.Err() 211 | default: 212 | if _, err := bot.Request(api.RestrictChatMemberConfig{ 213 | ChatMemberConfig: api.ChatMemberConfig{ 214 | ChatConfig: api.ChatConfig{ 215 | ChatID: chatID, 216 | }, 217 | UserID: userID, 218 | }, 219 | UntilDate: time.Now().Add(10 * time.Minute).Unix(), 220 | Permissions: &api.ChatPermissions{ 221 | CanSendMessages: true, 222 | CanSendAudios: true, 223 | CanSendDocuments: true, 224 | CanSendPhotos: true, 225 | CanSendVideos: true, 226 | CanSendVideoNotes: true, 227 | CanSendVoiceNotes: true, 228 | CanSendPolls: true, 229 | CanSendOtherMessages: true, 230 | CanAddWebPagePreviews: true, 231 | CanChangeInfo: true, 232 | CanInviteUsers: true, 233 | CanPinMessages: true, 234 | CanManageTopics: true, 235 | }, 236 | }); err != nil { 237 | return errors.WithMessage(err, "cant unrestrict") 238 | } 239 | return nil 240 | } 241 | } 242 | 243 | func ApproveJoinRequest(ctx context.Context, bot *api.BotAPI, userID int64, chatID int64) error { 244 | select { 245 | case <-ctx.Done(): 246 | return ctx.Err() 247 | default: 248 | if _, err := bot.Request(api.ApproveChatJoinRequestConfig{ 249 | ChatConfig: api.ChatConfig{ 250 | ChatID: chatID, 251 | }, 252 | UserID: userID, 253 | }); err != nil { 254 | return errors.WithMessage(err, "cant accept join request") 255 | } 256 | return nil 257 | } 258 | } 259 | 260 | func DeclineJoinRequest(ctx context.Context, bot *api.BotAPI, userID int64, chatID int64) error { 261 | select { 262 | case <-ctx.Done(): 263 | return ctx.Err() 264 | default: 265 | if _, err := bot.Request(api.DeclineChatJoinRequest{ 266 | ChatConfig: api.ChatConfig{ 267 | ChatID: chatID, 268 | }, 269 | UserID: userID, 270 | }); err != nil { 271 | return errors.WithMessage(err, "cant decline join request") 272 | } 273 | return nil 274 | } 275 | } 276 | 277 | func GetUpdatesChans(ctx context.Context, bot *api.BotAPI, config api.UpdateConfig) (api.UpdatesChannel, chan error) { 278 | ch := make(chan api.Update, bot.Buffer) 279 | chErr := make(chan error) 280 | 281 | go func() { 282 | defer close(ch) 283 | defer close(chErr) 284 | for { 285 | select { 286 | case <-ctx.Done(): 287 | chErr <- ctx.Err() 288 | return 289 | default: 290 | updates, err := bot.GetUpdates(config) 291 | if err != nil { 292 | chErr <- err 293 | return 294 | } 295 | 296 | for _, update := range updates { 297 | if update.UpdateID >= config.Offset { 298 | config.Offset = update.UpdateID + 1 299 | select { 300 | case ch <- update: 301 | case <-ctx.Done(): 302 | chErr <- ctx.Err() 303 | return 304 | } 305 | } 306 | } 307 | } 308 | } 309 | }() 310 | 311 | return ch, chErr 312 | } 313 | 314 | func GetUN(user *api.User) string { 315 | if user == nil { 316 | return "" 317 | } 318 | userName := user.UserName 319 | if len(userName) == 0 { 320 | userName = user.FirstName + " " + user.LastName 321 | userName = strings.TrimSpace(userName) 322 | } 323 | return userName 324 | } 325 | 326 | func GetFullName(user *api.User) string { 327 | if user == nil { 328 | return "" 329 | } 330 | fullName := user.FirstName + " " + user.LastName 331 | fullName = strings.TrimSpace(fullName) 332 | if len(fullName) == 0 { 333 | fullName = user.UserName 334 | } 335 | return fullName 336 | } 337 | 338 | func ExtractContentFromMessage(msg *api.Message) (content string) { 339 | var markupContent string 340 | defer func() { 341 | content = strings.TrimSpace(content) 342 | markupContent = strings.TrimSpace(markupContent) 343 | if markupContent != "" { 344 | content = strings.TrimSpace(content + " " + markupContent) 345 | } 346 | }() 347 | 348 | content = strings.TrimSpace(msg.Text + " " + msg.Caption) 349 | 350 | addMessageType := false 351 | messageType := GetMessageType(msg) 352 | switch messageType { 353 | case MessageTypeAnimation: 354 | addMessageType = true 355 | case MessageTypeAudio: 356 | content += fmt.Sprintf(" [%s] %s", messageType, msg.Audio.Title) 357 | case MessageTypeContact: 358 | content += fmt.Sprintf(" [%s] %s", messageType, msg.Contact.PhoneNumber) 359 | case MessageTypeDice: 360 | content += fmt.Sprintf(" [%s] %s (%d)", messageType, msg.Dice.Emoji, msg.Dice.Value) 361 | case MessageTypeDocument: 362 | addMessageType = true 363 | case MessageTypeGame: 364 | content += fmt.Sprintf(" [%s] %s %s", messageType, msg.Game.Title, msg.Game.Description) 365 | case MessageTypeInvoice: 366 | content += fmt.Sprintf(" [%s] %s %s", messageType, msg.Invoice.Title, msg.Invoice.Description) 367 | case MessageTypeLocation: 368 | content += fmt.Sprintf(" [%s] %f,%f", messageType, msg.Location.Latitude, msg.Location.Longitude) 369 | case MessageTypePoll: 370 | content += fmt.Sprintf(" [%s] %s", messageType, msg.Poll.Question) 371 | case MessageTypeStory: 372 | addMessageType = true 373 | case MessageTypeVenue: 374 | content += fmt.Sprintf(" [%s] %s %s", messageType, msg.Venue.Title, msg.Venue.Address) 375 | case MessageTypeVideo: 376 | addMessageType = true 377 | case MessageTypeVideoNote: 378 | addMessageType = true 379 | case MessageTypeVoice: 380 | addMessageType = true 381 | } 382 | if addMessageType { 383 | content += fmt.Sprintf(" [%s]", messageType) 384 | } 385 | 386 | if msg.ReplyMarkup != nil { 387 | var buttonTexts []string 388 | for _, row := range msg.ReplyMarkup.InlineKeyboard { 389 | for _, button := range row { 390 | if button.Text != "" { 391 | buttonTexts = append(buttonTexts, button.Text) 392 | } 393 | } 394 | } 395 | if len(buttonTexts) > 0 { 396 | markupContent = strings.Join(buttonTexts, " ") 397 | } 398 | } 399 | 400 | return content 401 | } 402 | 403 | func GetMessageType(msg *api.Message) MessageType { 404 | switch { 405 | case msg.Animation != nil: 406 | return MessageTypeAnimation 407 | case msg.Audio != nil: 408 | return MessageTypeAudio 409 | case msg.Contact != nil: 410 | return MessageTypeContact 411 | case msg.Dice != nil: 412 | return MessageTypeDice 413 | case msg.Document != nil: 414 | return MessageTypeDocument 415 | case msg.Game != nil: 416 | return MessageTypeGame 417 | case msg.Invoice != nil: 418 | return MessageTypeInvoice 419 | case msg.Location != nil: 420 | return MessageTypeLocation 421 | case msg.Photo != nil: 422 | return MessageTypePhoto 423 | case msg.Poll != nil: 424 | return MessageTypePoll 425 | case msg.Sticker != nil: 426 | return MessageTypeSticker 427 | case msg.Story != nil: 428 | return MessageTypeStory 429 | case msg.Venue != nil: 430 | return MessageTypeVenue 431 | case msg.Video != nil: 432 | return MessageTypeVideo 433 | case msg.VideoNote != nil: 434 | return MessageTypeVideoNote 435 | case msg.Voice != nil: 436 | return MessageTypeVoice 437 | default: 438 | return MessageTypeText 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /internal/handlers/moderation/spam_control.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | "time" 9 | 10 | api "github.com/OvyFlash/telegram-bot-api" 11 | "github.com/iamwavecut/ngbot/internal/bot" 12 | "github.com/iamwavecut/ngbot/internal/config" 13 | "github.com/iamwavecut/ngbot/internal/db" 14 | "github.com/iamwavecut/ngbot/internal/i18n" 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | type SpamControl struct { 19 | s bot.Service 20 | config config.SpamControl 21 | banService BanService 22 | verbose bool 23 | } 24 | 25 | func NewSpamControl(s bot.Service, config config.SpamControl, banService BanService, verbose bool) *SpamControl { 26 | return &SpamControl{ 27 | s: s, 28 | config: config, 29 | banService: banService, 30 | verbose: verbose, 31 | } 32 | } 33 | 34 | func (sc *SpamControl) ProcessSuspectMessage(ctx context.Context, msg *api.Message, lang string) error { 35 | spamCase, err := sc.s.GetDB().GetActiveSpamCase(ctx, msg.Chat.ID, msg.From.ID) 36 | if err != nil { 37 | log.WithField("error", err.Error()).Debug("failed to get active spam case") 38 | } 39 | if spamCase == nil { 40 | spamCase, err = sc.s.GetDB().CreateSpamCase(ctx, &db.SpamCase{ 41 | ChatID: msg.Chat.ID, 42 | UserID: msg.From.ID, 43 | MessageText: bot.ExtractContentFromMessage(msg), 44 | CreatedAt: time.Now(), 45 | Status: "pending", 46 | }) 47 | if err != nil { 48 | return fmt.Errorf("failed to create spam case: %w", err) 49 | } 50 | } 51 | 52 | notifMsg := sc.createInChatNotification(msg, spamCase.ID, lang, true) 53 | notification, err := sc.s.GetBot().Send(notifMsg) 54 | if err != nil { 55 | log.WithField("error", err.Error()).Error("failed to send notification") 56 | } else { 57 | spamCase.NotificationMessageID = notification.MessageID 58 | 59 | time.AfterFunc(sc.config.SuspectNotificationTimeout, func() { 60 | if _, err := sc.s.GetBot().Request(api.NewDeleteMessage(msg.Chat.ID, notification.MessageID)); err != nil { 61 | log.WithField("error", err.Error()).Error("failed to delete notification") 62 | } 63 | }) 64 | } 65 | 66 | if err := sc.s.GetDB().UpdateSpamCase(ctx, spamCase); err != nil { 67 | log.WithField("error", err.Error()).Error("failed to update spam case") 68 | } 69 | 70 | time.AfterFunc(sc.config.VotingTimeoutMinutes, func() { 71 | if err := sc.ResolveCase(context.Background(), spamCase.ID); err != nil { 72 | log.WithField("error", err.Error()).Error("failed to resolve spam case") 73 | } 74 | }) 75 | return nil 76 | } 77 | 78 | func (sc *SpamControl) getSpamCase(ctx context.Context, msg *api.Message) (*db.SpamCase, error) { 79 | spamCase, err := sc.s.GetDB().GetActiveSpamCase(ctx, msg.Chat.ID, msg.From.ID) 80 | if err != nil { 81 | log.WithField("error", err.Error()).Debug("failed to get active spam case") 82 | } 83 | if spamCase == nil { 84 | spamCase, err = sc.s.GetDB().CreateSpamCase(ctx, &db.SpamCase{ 85 | ChatID: msg.Chat.ID, 86 | UserID: msg.From.ID, 87 | MessageText: bot.ExtractContentFromMessage(msg), 88 | CreatedAt: time.Now(), 89 | Status: "pending", 90 | }) 91 | if err != nil { 92 | log.WithField("error", err.Error()).Debug("failed to create spam case") 93 | return nil, fmt.Errorf("failed to create spam case: %w", err) 94 | } 95 | } 96 | return spamCase, nil 97 | } 98 | 99 | type ProcessingResult struct { 100 | MessageDeleted bool 101 | UserBanned bool 102 | Error string 103 | } 104 | 105 | func (sc *SpamControl) preprocessMessage(ctx context.Context, msg *api.Message, chat *api.Chat, lang string, voting bool) (*ProcessingResult, error) { 106 | result := &ProcessingResult{} 107 | success := true 108 | if err := bot.DeleteChatMessage(ctx, sc.s.GetBot(), msg.Chat.ID, msg.MessageID); err != nil { 109 | log.WithField("error", err.Error()).WithField("chat_title", msg.Chat.Title).WithField("chat_username", msg.Chat.UserName).Error("failed to delete message") 110 | if strings.Contains(err.Error(), "CHAT_ADMIN_REQUIRED") { 111 | success = false 112 | } 113 | } else { 114 | result.MessageDeleted = true 115 | } 116 | chatMember, err := sc.s.GetBot().GetChatMember(api.GetChatMemberConfig{ 117 | ChatConfigWithUser: api.ChatConfigWithUser{ 118 | UserID: msg.From.ID, 119 | ChatConfig: api.ChatConfig{ 120 | ChatID: msg.Chat.ID, 121 | }, 122 | }, 123 | }) 124 | if err != nil { 125 | log.WithField("error", err.Error()).Error("failed to get chat member") 126 | } 127 | 128 | banUntil := time.Now().Add(10 * time.Minute).Unix() 129 | if chatMember.HasLeft() || chatMember.WasKicked() { 130 | banUntil = 0 // Permanent ban 131 | } 132 | 133 | if err := bot.BanUserFromChat(ctx, sc.s.GetBot(), msg.From.ID, msg.Chat.ID, banUntil); err != nil { 134 | log.WithField("error", err.Error()).WithField("chat_title", msg.Chat.Title).WithField("chat_username", msg.Chat.UserName).Error("failed to ban user") 135 | if strings.Contains(err.Error(), "CHAT_ADMIN_REQUIRED") { 136 | success = false 137 | result.Error = "CHAT_ADMIN_REQUIRED" 138 | } 139 | } else { 140 | result.UserBanned = true 141 | } 142 | if !success { 143 | unsuccessReply := api.NewMessage(chat.ID, "I don't have enough rights to ban this user") 144 | unsuccessReply.ReplyParameters = api.ReplyParameters{ 145 | ChatID: chat.ID, 146 | MessageID: msg.MessageID, 147 | AllowSendingWithoutReply: true, 148 | } 149 | unsuccessReply.DisableNotification = true 150 | unsuccessReply.LinkPreviewOptions.IsDisabled = true 151 | apiResult, err := sc.s.GetBot().Send(unsuccessReply) 152 | if err != nil { 153 | log.WithField("error", err.Error()).Error("failed to send unsuccess reply") 154 | } 155 | if apiResult.MessageID != 0 { 156 | time.AfterFunc(sc.config.SuspectNotificationTimeout, func() { 157 | if _, err := sc.s.GetBot().Request(api.NewDeleteMessage(chat.ID, apiResult.MessageID)); err != nil { 158 | log.WithField("error", err.Error()).Error("failed to delete unsuccess reply") 159 | } 160 | }) 161 | } 162 | 163 | return result, nil 164 | } 165 | 166 | spamCase, err := sc.getSpamCase(ctx, msg) 167 | if err != nil { 168 | return result, err 169 | } 170 | var notifMsg api.Chattable 171 | if sc.config.LogChannelUsername != "" { 172 | channelMsg, err := sc.SendChannelPost(ctx, msg, lang, true) 173 | if err != nil { 174 | log.WithField("error", err.Error()).Error("failed to send channel post") 175 | } 176 | if sc.verbose && channelMsg.MessageID != 0 { 177 | channelPostLink := fmt.Sprintf("https://t.me/%s/%d", sc.config.LogChannelUsername, channelMsg.MessageID) 178 | notifMsg = sc.createChannelNotification(msg, channelPostLink, lang) 179 | } 180 | } else { 181 | notifMsg = sc.createInChatNotification(msg, spamCase.ID, lang, voting) 182 | } 183 | 184 | if notifMsg != nil { 185 | notification, err := sc.s.GetBot().Send(notifMsg) 186 | if err != nil { 187 | log.WithField("error", err.Error()).Error("failed to send notification") 188 | } else { 189 | spamCase.NotificationMessageID = notification.MessageID 190 | 191 | time.AfterFunc(sc.config.SuspectNotificationTimeout, func() { 192 | if _, err := sc.s.GetBot().Request(api.NewDeleteMessage(msg.Chat.ID, notification.MessageID)); err != nil { 193 | log.WithField("error", err.Error()).Error("failed to delete notification") 194 | } 195 | }) 196 | } 197 | } 198 | 199 | if err := sc.s.GetDB().UpdateSpamCase(ctx, spamCase); err != nil { 200 | log.WithField("error", err.Error()).Error("failed to update spam case") 201 | } 202 | 203 | return result, nil 204 | } 205 | 206 | func (sc *SpamControl) ProcessBannedMessage(ctx context.Context, msg *api.Message, chat *api.Chat, lang string) (*ProcessingResult, error) { 207 | return sc.preprocessMessage(ctx, msg, chat, lang, false) 208 | } 209 | 210 | func (sc *SpamControl) ProcessSpamMessage(ctx context.Context, msg *api.Message, chat *api.Chat, lang string) (*ProcessingResult, error) { 211 | return sc.preprocessMessage(ctx, msg, chat, lang, true) 212 | } 213 | 214 | func (sc *SpamControl) SendChannelPost(ctx context.Context, msg *api.Message, lang string, voting bool) (*api.Message, error) { 215 | spamCase, err := sc.getSpamCase(ctx, msg) 216 | if err != nil { 217 | return nil, fmt.Errorf("failed to get spam case: %w", err) 218 | } 219 | channelMsg := sc.createChannelPost(msg, spamCase.ID, lang, voting) 220 | sent, err := sc.s.GetBot().Send(channelMsg) 221 | if err != nil { 222 | log.WithField("error", err.Error()).Error("failed to send channel post") 223 | } 224 | spamCase.ChannelUsername = sc.config.LogChannelUsername 225 | spamCase.ChannelPostID = sent.MessageID 226 | if err := sc.s.GetDB().UpdateSpamCase(ctx, spamCase); err != nil { 227 | log.WithField("error", err.Error()).Error("failed to update spam case") 228 | } 229 | 230 | return &sent, nil 231 | } 232 | 233 | func (sc *SpamControl) createInChatNotification(msg *api.Message, caseID int64, lang string, voting bool) api.Chattable { 234 | text := fmt.Sprintf(i18n.Get("⚠️ Potential spam message from %s\n\nMessage: %s\n\nPlease vote:", lang), 235 | bot.GetUN(msg.From), 236 | bot.ExtractContentFromMessage(msg), 237 | ) 238 | 239 | replyMsg := api.NewMessage(msg.Chat.ID, text) 240 | if voting { 241 | markup := api.NewInlineKeyboardMarkup( 242 | api.NewInlineKeyboardRow( 243 | api.NewInlineKeyboardButtonData("✅ "+i18n.Get("Not Spam", lang), fmt.Sprintf("spam_vote:%d:0", caseID)), 244 | api.NewInlineKeyboardButtonData("🚫 "+i18n.Get("Spam", lang), fmt.Sprintf("spam_vote:%d:1", caseID)), 245 | ), 246 | ) 247 | replyMsg.ReplyMarkup = &markup 248 | } 249 | 250 | replyMsg.ParseMode = api.ModeMarkdown 251 | replyMsg.DisableNotification = true 252 | replyMsg.LinkPreviewOptions.IsDisabled = true 253 | return replyMsg 254 | } 255 | 256 | func (sc *SpamControl) createChannelPost(msg *api.Message, caseID int64, lang string, voting bool) api.Chattable { 257 | from := bot.GetUN(msg.From) 258 | textSlice := strings.Split(bot.ExtractContentFromMessage(msg), "\n") 259 | for i, line := range textSlice { 260 | line = strings.ReplaceAll(line, "http", "_ttp") 261 | line = strings.ReplaceAll(line, "+7", "+*") 262 | 263 | line = api.EscapeText(api.ModeMarkdownV2, line) 264 | line = regexp.MustCompile(`@(\w+)`).ReplaceAllString(line, "@**") 265 | textSlice[i] = line 266 | } 267 | text := fmt.Sprintf(i18n.Get(">%s\n**>%s", lang), 268 | api.EscapeText(api.ModeMarkdownV2, from), 269 | strings.Join(textSlice, "\n>"), 270 | ) 271 | channelMsg := api.NewMessageToChannel("@"+strings.TrimPrefix(sc.config.LogChannelUsername, "@"), text) 272 | 273 | if voting { 274 | markup := api.NewInlineKeyboardMarkup( 275 | api.NewInlineKeyboardRow( 276 | api.NewInlineKeyboardButtonData("✅ "+i18n.Get("Not Spam", lang), fmt.Sprintf("spam_vote:%d:0", caseID)), 277 | api.NewInlineKeyboardButtonData("🚫 "+i18n.Get("Spam", lang), fmt.Sprintf("spam_vote:%d:1", caseID)), 278 | ), 279 | ) 280 | channelMsg.ReplyMarkup = &markup 281 | } 282 | 283 | channelMsg.ParseMode = api.ModeMarkdownV2 284 | return channelMsg 285 | } 286 | 287 | func (sc *SpamControl) createChannelNotification(msg *api.Message, channelPostLink string, lang string) api.Chattable { 288 | from := bot.GetUN(msg.From) 289 | text := fmt.Sprintf(i18n.Get("Message from %s is being reviewed for spam\n\nAppeal here: [link](%s)", lang), from, channelPostLink) 290 | notificationMsg := api.NewMessage(msg.Chat.ID, text) 291 | notificationMsg.ParseMode = api.ModeMarkdown 292 | notificationMsg.DisableNotification = true 293 | notificationMsg.LinkPreviewOptions.IsDisabled = true 294 | 295 | return notificationMsg 296 | } 297 | 298 | func (sc *SpamControl) ResolveCase(ctx context.Context, caseID int64) error { 299 | entry := sc.getLogEntry().WithField("method", "resolveCase").WithField("case_id", caseID) 300 | spamCase, err := sc.s.GetDB().GetSpamCase(ctx, caseID) 301 | if err != nil { 302 | return fmt.Errorf("failed to get case: %w", err) 303 | } 304 | if spamCase.Status != "pending" { 305 | entry.WithField("status", spamCase.Status).Debug("case is not pending, skipping") 306 | return nil 307 | } 308 | 309 | votes, err := sc.s.GetDB().GetSpamVotes(ctx, caseID) 310 | if err != nil { 311 | return fmt.Errorf("failed to get votes: %w", err) 312 | } 313 | 314 | members, err := sc.s.GetDB().GetMembers(ctx, spamCase.ChatID) 315 | if err != nil { 316 | log.WithField("error", err.Error()).Error("failed to get members count") 317 | } 318 | 319 | minVotersFromPercentage := int(float64(len(members)) * sc.config.MinVotersPercentage / 100) 320 | 321 | requiredVoters := max(sc.config.MinVoters, minVotersFromPercentage) 322 | 323 | if len(votes) >= requiredVoters { 324 | yesVotes := 0 325 | noVotes := 0 326 | for _, vote := range votes { 327 | if vote.Vote { 328 | yesVotes++ 329 | } else { 330 | noVotes++ 331 | } 332 | } 333 | 334 | if noVotes >= yesVotes { 335 | spamCase.Status = "spam" 336 | } else { 337 | spamCase.Status = "false_positive" 338 | } 339 | } else { 340 | entry.WithField("required_voters", requiredVoters).WithField("got_votes", len(votes)).Debug("not enough voters") 341 | spamCase.Status = "spam" 342 | } 343 | 344 | if spamCase.Status == "spam" { 345 | if err := bot.BanUserFromChat(ctx, sc.s.GetBot(), spamCase.UserID, spamCase.ChatID, 0); err != nil { 346 | log.WithField("error", err.Error()).Error("failed to ban user") 347 | } 348 | } else { 349 | if err := sc.banService.UnmuteUser(ctx, spamCase.ChatID, spamCase.UserID); err != nil { 350 | log.WithField("error", err.Error()).Error("failed to unmute user") 351 | } 352 | } 353 | 354 | now := time.Now() 355 | spamCase.ResolvedAt = &now 356 | if err := sc.s.GetDB().UpdateSpamCase(ctx, spamCase); err != nil { 357 | return fmt.Errorf("failed to update case: %w", err) 358 | } 359 | return nil 360 | } 361 | 362 | func (sc *SpamControl) getLogEntry() *log.Entry { 363 | return log.WithField("object", "SpamControl") 364 | } 365 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= 2 | cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= 3 | cloud.google.com/go/ai v0.10.0 h1:hwj6CI6sMKubXodoJJGTy/c2T1RbbLGM6TL3QoAvzU8= 4 | cloud.google.com/go/ai v0.10.0/go.mod h1:kvnt2KeHqX8+41PVeMRBETDyQAp/RFvBWGdx/aGjNMo= 5 | cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= 6 | cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= 7 | cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= 8 | cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= 9 | cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= 10 | cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= 11 | cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= 12 | cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= 13 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 14 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 15 | github.com/OvyFlash/telegram-bot-api v0.0.0-20241219171906-3f2ca0c14ada h1:5ZtieioZyyfiJsGvjpj3d5Eso/3YjJJhNQ1M8at5U5k= 16 | github.com/OvyFlash/telegram-bot-api v0.0.0-20241219171906-3f2ca0c14ada/go.mod h1:2nRUdsKyWhvezqW/rBGWEQdcTQeTtnbSNd2dgx76WYA= 17 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 18 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 19 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 20 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 21 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 23 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 25 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 26 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 27 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 28 | github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= 29 | github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= 30 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 31 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 32 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 33 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 34 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 35 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 36 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 37 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 38 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 39 | github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg= 40 | github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= 41 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 42 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 43 | github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg= 44 | github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 45 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 46 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 47 | github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 48 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 49 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 50 | github.com/googleapis/enterprise-certificate-proxy v0.3.5 h1:VgzTY2jogw3xt39CusEnFJWm7rlsq5yL5q9XdLOuP5g= 51 | github.com/googleapis/enterprise-certificate-proxy v0.3.5/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= 52 | github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= 53 | github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= 54 | github.com/iamwavecut/tool v1.3.0 h1:ux47c7YxWPl9QIrsH6eKhu2QyEC9R4UhoWFJEGWE2GY= 55 | github.com/iamwavecut/tool v1.3.0/go.mod h1:ZZbGsfpyXo6d57IG/Kagc10S9N0WgIOZk5EAKWT4gFo= 56 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 57 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 58 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 59 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 60 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 61 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 62 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 63 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 64 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 65 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 66 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 67 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 68 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 69 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 70 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 71 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 72 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 73 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 74 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 75 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 76 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 77 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 78 | github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= 79 | github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= 80 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 81 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 82 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 83 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 84 | github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= 85 | github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= 86 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 87 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 88 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 89 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 90 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 91 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 92 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 93 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 94 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 95 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 96 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 97 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 98 | github.com/rubenv/sql-migrate v1.7.1 h1:f/o0WgfO/GqNuVg+6801K/KW3WdDSupzSjDYODmiUq4= 99 | github.com/rubenv/sql-migrate v1.7.1/go.mod h1:Ob2Psprc0/3ggbM6wCzyYVFFuc6FyZrb2AS+ezLDFb4= 100 | github.com/sashabaranov/go-openai v1.38.0 h1:hNN5uolKwdbpiqOn7l+Z2alch/0n0rSFyg4n+GZxR5k= 101 | github.com/sashabaranov/go-openai v1.38.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 102 | github.com/sethvargo/go-envconfig v1.1.1 h1:JDu8Q9baIzJf47NPkzhIB6aLYL0vQ+pPypoYrejS9QY= 103 | github.com/sethvargo/go-envconfig v1.1.1/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= 104 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 105 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 106 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 107 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 108 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 109 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 110 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 111 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 112 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= 113 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= 114 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= 115 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= 116 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 117 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 118 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 119 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 120 | go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= 121 | go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= 122 | go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= 123 | go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= 124 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 125 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 126 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 127 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 128 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 129 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 130 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 131 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 132 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 133 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 134 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= 135 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= 136 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 137 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 138 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 139 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 140 | golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= 141 | golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 142 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 143 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 144 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 145 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 146 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 147 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 148 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 149 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 150 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 151 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 152 | golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= 153 | golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= 154 | google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU= 155 | google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ= 156 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= 157 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= 158 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= 159 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= 160 | google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= 161 | google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= 162 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 163 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 164 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 165 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 166 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 167 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 168 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 169 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 170 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 171 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 172 | modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= 173 | modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 174 | modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= 175 | modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= 176 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 177 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 178 | modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= 179 | modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 180 | modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= 181 | modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= 182 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 183 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 184 | modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= 185 | modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= 186 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 187 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 188 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 189 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 190 | modernc.org/sqlite v1.36.0 h1:EQXNRn4nIS+gfsKeUTymHIz1waxuv5BzU7558dHSfH8= 191 | modernc.org/sqlite v1.36.0/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU= 192 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 193 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 194 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 195 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 196 | -------------------------------------------------------------------------------- /internal/db/sqlite/client.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "path/filepath" 9 | "sync" 10 | 11 | "github.com/iamwavecut/ngbot/internal/db" 12 | "github.com/iamwavecut/ngbot/internal/infra" 13 | "github.com/iamwavecut/ngbot/resources" 14 | 15 | "github.com/jmoiron/sqlx" 16 | migrate "github.com/rubenv/sql-migrate" 17 | log "github.com/sirupsen/logrus" 18 | _ "modernc.org/sqlite" 19 | ) 20 | 21 | type sqliteClient struct { 22 | db *sqlx.DB 23 | mutex sync.RWMutex 24 | } 25 | 26 | func NewSQLiteClient(ctx context.Context, dbPath string) *sqliteClient { 27 | dbx, err := sqlx.Open("sqlite", filepath.Join(infra.GetWorkDir(), dbPath)) 28 | if err != nil { 29 | log.WithField("error", err.Error()).Fatal("Failed to open database") 30 | } 31 | dbx.SetMaxOpenConns(42) 32 | 33 | migrationsSource := &migrate.EmbedFileSystemMigrationSource{ 34 | FileSystem: resources.FS, 35 | Root: "migrations", 36 | } 37 | 38 | if _, _, err := migrate.PlanMigration(dbx.DB, "sqlite3", migrationsSource, migrate.Up, 0); err != nil { 39 | log.WithField("error", err.Error()).Fatal("Failed to plan migration") 40 | } 41 | 42 | if n, err := migrate.Exec(dbx.DB, "sqlite3", migrationsSource, migrate.Up); err != nil { 43 | log.WithField("error", err.Error()).WithField("migration", migrationsSource).Fatal("Failed to execute migration") 44 | } else if n > 0 { 45 | log.Infof("Applied %d migrations", n) 46 | } 47 | 48 | return &sqliteClient{db: dbx} 49 | } 50 | 51 | func (c *sqliteClient) GetSettings(ctx context.Context, chatID int64) (*db.Settings, error) { 52 | c.mutex.RLock() 53 | defer c.mutex.RUnlock() 54 | 55 | res := &db.Settings{} 56 | query := "SELECT id, language, enabled, challenge_timeout, reject_timeout FROM chats WHERE id = ?" 57 | err := c.db.QueryRowxContext(ctx, query, chatID).StructScan(res) 58 | if err != nil { 59 | if errors.Is(err, sql.ErrNoRows) { 60 | log.WithField("chatID", chatID).Debug("No settings found for chat") 61 | return nil, nil 62 | } 63 | return nil, fmt.Errorf("failed to get settings for chat %d: %w", chatID, err) 64 | } 65 | 66 | log.WithFields(log.Fields{ 67 | "chatID": chatID, 68 | "settings": res, 69 | }).Debug("Successfully retrieved settings") 70 | return res, nil 71 | } 72 | 73 | func (c *sqliteClient) GetAllSettings(ctx context.Context) (map[int64]*db.Settings, error) { 74 | c.mutex.RLock() 75 | defer c.mutex.RUnlock() 76 | 77 | query := "SELECT id, language, enabled, challenge_timeout, reject_timeout FROM chats" 78 | rows, err := c.db.QueryxContext(ctx, query) 79 | if err != nil { 80 | return nil, fmt.Errorf("failed to query all settings: %w", err) 81 | } 82 | defer rows.Close() 83 | 84 | res := make(map[int64]*db.Settings) 85 | for rows.Next() { 86 | var s db.Settings 87 | if err := rows.StructScan(&s); err != nil { 88 | return nil, fmt.Errorf("failed to scan settings: %w", err) 89 | } 90 | res[s.ID] = &s 91 | } 92 | 93 | if err := rows.Err(); err != nil { 94 | return nil, fmt.Errorf("error iterating over settings rows: %w", err) 95 | } 96 | 97 | return res, nil 98 | } 99 | 100 | func (c *sqliteClient) SetSettings(ctx context.Context, settings *db.Settings) error { 101 | c.mutex.Lock() 102 | defer c.mutex.Unlock() 103 | 104 | query := ` 105 | INSERT INTO chats (id, language, enabled, challenge_timeout, reject_timeout) 106 | VALUES (?, ?, ?, ?, ?) 107 | ON CONFLICT(id) DO UPDATE SET 108 | language = ?, 109 | enabled = ?, 110 | challenge_timeout = ?, 111 | reject_timeout = ? 112 | ` 113 | _, err := c.db.ExecContext(ctx, query, 114 | settings.ID, settings.Language, settings.Enabled, settings.ChallengeTimeout, settings.RejectTimeout, 115 | settings.Language, settings.Enabled, settings.ChallengeTimeout, settings.RejectTimeout) 116 | if err != nil { 117 | return fmt.Errorf("failed to set settings: %w", err) 118 | } 119 | return nil 120 | } 121 | 122 | func (c *sqliteClient) InsertMember(ctx context.Context, chatID, userID int64) error { 123 | c.mutex.Lock() 124 | defer c.mutex.Unlock() 125 | 126 | tx, err := c.db.BeginTxx(ctx, nil) 127 | if err != nil { 128 | return fmt.Errorf("failed to begin transaction: %w", err) 129 | } 130 | defer func() { 131 | if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) { 132 | log.WithError(err).Error("failed to rollback transaction") 133 | } 134 | }() 135 | 136 | // First check if member already exists to avoid duplicates 137 | var exists bool 138 | err = tx.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM chat_members WHERE chat_id = ? AND user_id = ?)", chatID, userID) 139 | if err != nil { 140 | return fmt.Errorf("failed to check member existence: %w", err) 141 | } 142 | if exists { 143 | return nil 144 | } 145 | 146 | // Insert the new member 147 | _, err = tx.ExecContext(ctx, "INSERT INTO chat_members (chat_id, user_id) VALUES (?, ?)", chatID, userID) 148 | if err != nil { 149 | return fmt.Errorf("failed to insert member: %w", err) 150 | } 151 | 152 | if err := tx.Commit(); err != nil { 153 | return fmt.Errorf("failed to commit transaction: %w", err) 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (c *sqliteClient) InsertMembers(ctx context.Context, chatID int64, userIDs []int64) error { 160 | c.mutex.Lock() 161 | defer c.mutex.Unlock() 162 | 163 | tx, err := c.db.BeginTxx(ctx, nil) 164 | if err != nil { 165 | return err 166 | } 167 | defer func() { _ = tx.Rollback() }() 168 | 169 | stmt, err := tx.Preparex("INSERT OR IGNORE INTO chat_members (chat_id, user_id) VALUES (?, ?)") 170 | if err != nil { 171 | return err 172 | } 173 | defer stmt.Close() 174 | 175 | for _, userID := range userIDs { 176 | if _, err = stmt.Exec(chatID, userID); err != nil { 177 | return err 178 | } 179 | } 180 | 181 | return tx.Commit() 182 | } 183 | 184 | func (c *sqliteClient) DeleteMember(ctx context.Context, chatID, userID int64) error { 185 | c.mutex.Lock() 186 | defer c.mutex.Unlock() 187 | 188 | _, err := c.db.ExecContext(ctx, "DELETE FROM chat_members WHERE chat_id = ? AND user_id = ?", chatID, userID) 189 | return err 190 | } 191 | 192 | func (c *sqliteClient) DeleteMembers(ctx context.Context, chatID int64, userIDs []int64) error { 193 | c.mutex.Lock() 194 | defer c.mutex.Unlock() 195 | 196 | query, args, err := sqlx.In("DELETE FROM chat_members WHERE chat_id = ? AND user_id IN (?)", chatID, userIDs) 197 | if err != nil { 198 | return err 199 | } 200 | query = c.db.Rebind(query) 201 | _, err = c.db.ExecContext(ctx, query, args...) 202 | return err 203 | } 204 | 205 | func (c *sqliteClient) GetMembers(ctx context.Context, chatID int64) ([]int64, error) { 206 | c.mutex.RLock() 207 | defer c.mutex.RUnlock() 208 | 209 | var userIDs []int64 210 | err := c.db.SelectContext(ctx, &userIDs, "SELECT user_id FROM chat_members WHERE chat_id = ?", chatID) 211 | return userIDs, err 212 | } 213 | 214 | func (c *sqliteClient) GetAllMembers(ctx context.Context) (map[int64][]int64, error) { 215 | c.mutex.RLock() 216 | defer c.mutex.RUnlock() 217 | 218 | rows, err := c.db.QueryxContext(ctx, "SELECT chat_id, user_id FROM chat_members") 219 | if err != nil { 220 | return nil, fmt.Errorf("failed to query settings: %w", err) 221 | } 222 | defer rows.Close() 223 | 224 | members := make(map[int64][]int64) 225 | for rows.Next() { 226 | var chatID, userID int64 227 | if err := rows.Scan(&chatID, &userID); err != nil { 228 | return nil, err 229 | } 230 | members[chatID] = append(members[chatID], userID) 231 | } 232 | 233 | return members, rows.Err() 234 | } 235 | 236 | func (c *sqliteClient) IsMember(ctx context.Context, chatID, userID int64) (bool, error) { 237 | c.mutex.RLock() 238 | defer c.mutex.RUnlock() 239 | 240 | var count int 241 | err := c.db.GetContext(ctx, &count, "SELECT COUNT(*) FROM chat_members WHERE chat_id = ? AND user_id = ?", chatID, userID) 242 | return count > 0, err 243 | } 244 | 245 | func (c *sqliteClient) Close() error { 246 | return c.db.Close() 247 | } 248 | 249 | func (s *sqliteClient) AddRestriction(ctx context.Context, restriction *db.UserRestriction) error { 250 | s.mutex.Lock() 251 | defer s.mutex.Unlock() 252 | 253 | query := ` 254 | INSERT INTO user_restrictions (user_id, chat_id, restricted_at, expires_at, reason) 255 | VALUES (?, ?, ?, ?, ?) 256 | ` 257 | _, err := s.db.ExecContext(ctx, query, 258 | restriction.UserID, 259 | restriction.ChatID, 260 | restriction.RestrictedAt, 261 | restriction.ExpiresAt, 262 | restriction.Reason, 263 | ) 264 | return err 265 | } 266 | 267 | func (s *sqliteClient) RemoveRestriction(ctx context.Context, chatID int64, userID int64) error { 268 | s.mutex.Lock() 269 | defer s.mutex.Unlock() 270 | 271 | query := `DELETE FROM user_restrictions WHERE chat_id = ? AND user_id = ?` 272 | _, err := s.db.ExecContext(ctx, query, chatID, userID) 273 | return err 274 | } 275 | 276 | func (s *sqliteClient) CreateSpamCase(ctx context.Context, sc *db.SpamCase) (*db.SpamCase, error) { 277 | s.mutex.Lock() 278 | defer s.mutex.Unlock() 279 | 280 | query := ` 281 | INSERT INTO spam_cases (chat_id, user_id, message_text, created_at, channel_username, channel_post_id, 282 | notification_message_id, status, resolved_at) 283 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 284 | ` 285 | result, err := s.db.ExecContext(ctx, query, 286 | sc.ChatID, 287 | sc.UserID, 288 | sc.MessageText, 289 | sc.CreatedAt, 290 | sc.ChannelUsername, 291 | sc.ChannelPostID, 292 | sc.NotificationMessageID, 293 | sc.Status, 294 | sc.ResolvedAt, 295 | ) 296 | if err != nil { 297 | return nil, err 298 | } 299 | 300 | id, err := result.LastInsertId() 301 | if err != nil { 302 | return nil, err 303 | } 304 | sc.ID = id 305 | return sc, nil 306 | } 307 | 308 | func (s *sqliteClient) UpdateSpamCase(ctx context.Context, sc *db.SpamCase) error { 309 | s.mutex.Lock() 310 | defer s.mutex.Unlock() 311 | 312 | query := ` 313 | UPDATE spam_cases 314 | SET channel_username = ?, 315 | channel_post_id = ?, 316 | notification_message_id = ?, 317 | status = ?, 318 | resolved_at = ? 319 | WHERE id = ? 320 | ` 321 | _, err := s.db.ExecContext(ctx, query, 322 | sc.ChannelUsername, 323 | sc.ChannelPostID, 324 | sc.NotificationMessageID, 325 | sc.Status, 326 | sc.ResolvedAt, 327 | sc.ID, 328 | ) 329 | return err 330 | } 331 | 332 | func (s *sqliteClient) GetSpamCase(ctx context.Context, id int64) (*db.SpamCase, error) { 333 | s.mutex.RLock() 334 | defer s.mutex.RUnlock() 335 | 336 | var sc db.SpamCase 337 | err := s.db.GetContext(ctx, &sc, `SELECT * FROM spam_cases WHERE id = ?`, id) 338 | if err != nil { 339 | return nil, err 340 | } 341 | return &sc, nil 342 | } 343 | 344 | func (s *sqliteClient) GetActiveSpamCase(ctx context.Context, chatID, userID int64) (*db.SpamCase, error) { 345 | s.mutex.RLock() 346 | defer s.mutex.RUnlock() 347 | 348 | var sc db.SpamCase 349 | err := s.db.GetContext(ctx, &sc, ` 350 | SELECT * FROM spam_cases 351 | WHERE chat_id = ? 352 | AND user_id = ? 353 | AND status = 'pending' 354 | AND resolved_at IS NULL 355 | ORDER BY created_at DESC 356 | LIMIT 1 357 | `, chatID, userID) 358 | if err != nil { 359 | if err == sql.ErrNoRows { 360 | return nil, nil 361 | } 362 | return nil, err 363 | } 364 | return &sc, nil 365 | } 366 | 367 | func (s *sqliteClient) GetPendingSpamCases(ctx context.Context) ([]*db.SpamCase, error) { 368 | s.mutex.RLock() 369 | defer s.mutex.RUnlock() 370 | 371 | var cases []*db.SpamCase 372 | err := s.db.SelectContext(ctx, &cases, ` 373 | SELECT * FROM spam_cases 374 | WHERE status = 'pending' AND resolved_at IS NULL 375 | ORDER BY created_at DESC 376 | `) 377 | return cases, err 378 | } 379 | 380 | func (s *sqliteClient) AddSpamVote(ctx context.Context, vote *db.SpamVote) error { 381 | s.mutex.Lock() 382 | defer s.mutex.Unlock() 383 | 384 | query := ` 385 | INSERT INTO spam_votes (case_id, voter_id, vote, voted_at) 386 | VALUES (?, ?, ?, ?) 387 | ON CONFLICT(case_id, voter_id) DO UPDATE SET 388 | vote = excluded.vote, 389 | voted_at = excluded.voted_at 390 | ` 391 | _, err := s.db.ExecContext(ctx, query, 392 | vote.CaseID, 393 | vote.VoterID, 394 | vote.Vote, 395 | vote.VotedAt, 396 | ) 397 | return err 398 | } 399 | 400 | func (s *sqliteClient) GetSpamVotes(ctx context.Context, caseID int64) ([]*db.SpamVote, error) { 401 | s.mutex.RLock() 402 | defer s.mutex.RUnlock() 403 | 404 | var votes []*db.SpamVote 405 | err := s.db.SelectContext(ctx, &votes, ` 406 | SELECT * FROM spam_votes 407 | WHERE case_id = ? 408 | ORDER BY voted_at ASC 409 | `, caseID) 410 | return votes, err 411 | } 412 | 413 | func (s *sqliteClient) GetActiveRestriction(ctx context.Context, chatID, userID int64) (*db.UserRestriction, error) { 414 | s.mutex.RLock() 415 | defer s.mutex.RUnlock() 416 | 417 | var restriction db.UserRestriction 418 | err := s.db.GetContext(ctx, &restriction, ` 419 | SELECT * FROM user_restrictions 420 | WHERE chat_id = ? AND user_id = ? AND expires_at > datetime('now') 421 | ORDER BY restricted_at DESC LIMIT 1 422 | `, chatID, userID) 423 | if err != nil { 424 | return nil, err 425 | } 426 | return &restriction, nil 427 | } 428 | 429 | func (s *sqliteClient) RemoveExpiredRestrictions(ctx context.Context) error { 430 | s.mutex.Lock() 431 | defer s.mutex.Unlock() 432 | 433 | query := `DELETE FROM user_restrictions WHERE expires_at <= datetime('now')` 434 | _, err := s.db.ExecContext(ctx, query) 435 | return err 436 | } 437 | 438 | func (s *sqliteClient) AddChatRecentJoiner(ctx context.Context, joiner *db.RecentJoiner) (*db.RecentJoiner, error) { 439 | s.mutex.Lock() 440 | defer s.mutex.Unlock() 441 | 442 | query := ` 443 | INSERT INTO recent_joiners (chat_id, user_id, username, joined_at, join_message_id, processed, is_spammer) 444 | VALUES (?, ?, ?, ?, ?, ?, ?) 445 | ` 446 | result, err := s.db.ExecContext(ctx, query, 447 | joiner.ChatID, 448 | joiner.UserID, 449 | joiner.Username, 450 | joiner.JoinedAt, 451 | joiner.JoinMessageID, 452 | joiner.Processed, 453 | joiner.IsSpammer, 454 | ) 455 | if err != nil { 456 | return nil, err 457 | } 458 | id, err := result.LastInsertId() 459 | if err != nil { 460 | return nil, err 461 | } 462 | joiner.ID = id 463 | return joiner, nil 464 | } 465 | 466 | func (s *sqliteClient) GetChatRecentJoiners(ctx context.Context, chatID int64) ([]*db.RecentJoiner, error) { 467 | s.mutex.RLock() 468 | defer s.mutex.RUnlock() 469 | 470 | var joiners []*db.RecentJoiner 471 | err := s.db.SelectContext(ctx, &joiners, ` 472 | SELECT * FROM recent_joiners 473 | WHERE chat_id = ? 474 | ORDER BY joined_at DESC 475 | `, chatID) 476 | return joiners, err 477 | } 478 | 479 | func (s *sqliteClient) GetUnprocessedRecentJoiners(ctx context.Context) ([]*db.RecentJoiner, error) { 480 | s.mutex.RLock() 481 | defer s.mutex.RUnlock() 482 | 483 | var joiners []*db.RecentJoiner 484 | err := s.db.SelectContext(ctx, &joiners, ` 485 | SELECT * FROM recent_joiners 486 | WHERE processed = FALSE 487 | ORDER BY joined_at ASC 488 | `) 489 | return joiners, err 490 | } 491 | 492 | func (s *sqliteClient) ProcessRecentJoiner(ctx context.Context, chatID int64, userID int64, isSpammer bool) error { 493 | s.mutex.Lock() 494 | defer s.mutex.Unlock() 495 | 496 | query := ` 497 | UPDATE recent_joiners 498 | SET processed = TRUE, is_spammer = ? 499 | WHERE chat_id = ? AND user_id = ? 500 | ` 501 | _, err := s.db.ExecContext(ctx, query, isSpammer, chatID, userID) 502 | return err 503 | } 504 | 505 | func (s *sqliteClient) UpsertBanlist(ctx context.Context, userIDs []int64) error { 506 | s.mutex.Lock() 507 | defer s.mutex.Unlock() 508 | 509 | tx, err := s.db.BeginTx(ctx, nil) 510 | if err != nil { 511 | return fmt.Errorf("failed to begin transaction: %w", err) 512 | } 513 | 514 | rollback := true 515 | defer func() { 516 | if rollback { 517 | if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) { 518 | log.WithError(err).Error("failed to rollback transaction") 519 | } 520 | } 521 | }() 522 | 523 | stmt, err := tx.PrepareContext(ctx, ` 524 | INSERT INTO banlist (user_id) VALUES (?) 525 | ON CONFLICT(user_id) DO NOTHING 526 | `) 527 | if err != nil { 528 | return fmt.Errorf("failed to prepare statement: %w", err) 529 | } 530 | defer stmt.Close() 531 | 532 | for _, userID := range userIDs { 533 | if _, err := stmt.ExecContext(ctx, userID); err != nil { 534 | return fmt.Errorf("failed to insert user %d: %w", userID, err) 535 | } 536 | } 537 | 538 | if err := tx.Commit(); err != nil { 539 | return fmt.Errorf("failed to commit transaction: %w", err) 540 | } 541 | rollback = false 542 | return nil 543 | } 544 | 545 | func (s *sqliteClient) GetBanlist(ctx context.Context) (map[int64]struct{}, error) { 546 | s.mutex.RLock() 547 | defer s.mutex.RUnlock() 548 | 549 | var userIDs []int64 550 | err := s.db.SelectContext(ctx, &userIDs, `SELECT user_id FROM banlist`) 551 | if err != nil { 552 | return nil, err 553 | } 554 | results := make(map[int64]struct{}) 555 | for _, userID := range userIDs { 556 | results[userID] = struct{}{} 557 | } 558 | return results, nil 559 | } 560 | 561 | func (s *sqliteClient) GetKV(ctx context.Context, key string) (string, error) { 562 | s.mutex.RLock() 563 | defer s.mutex.RUnlock() 564 | 565 | var value string 566 | err := s.db.GetContext(ctx, &value, `SELECT value FROM kv_store WHERE key = ?`, key) 567 | if err != nil { 568 | if err == sql.ErrNoRows { 569 | return "", nil 570 | } 571 | return "", fmt.Errorf("failed to get value for key %s: %w", key, err) 572 | } 573 | return value, nil 574 | } 575 | 576 | func (s *sqliteClient) SetKV(ctx context.Context, key string, value string) error { 577 | s.mutex.Lock() 578 | defer s.mutex.Unlock() 579 | 580 | query := ` 581 | INSERT INTO kv_store (key, value, updated_at) 582 | VALUES (?, ?, datetime('now')) 583 | ON CONFLICT(key) DO UPDATE SET 584 | value = excluded.value, 585 | updated_at = excluded.updated_at 586 | ` 587 | _, err := s.db.ExecContext(ctx, query, key, value) 588 | if err != nil { 589 | return fmt.Errorf("failed to set value for key %s: %w", key, err) 590 | } 591 | return nil 592 | } 593 | --------------------------------------------------------------------------------