├── img ├── logo.png ├── commands.png └── example.png ├── .gitignore ├── docker-compose.yml ├── .vscode └── launch.json ├── chart ├── Chart.yaml ├── templates │ ├── configmap.yaml │ └── deployment.yaml ├── README.md └── values.yaml ├── Dockerfile ├── user ├── users_manager.go ├── history.go ├── types.go └── usage_tracker.go ├── config.yaml ├── go.mod ├── LICENSE ├── lang ├── lang.go ├── EN.json └── RU.json ├── .env.example ├── config ├── config_manager.go ├── param.go └── config.go ├── README.md ├── README_RU.md ├── go.sum ├── .github └── workflows │ └── build.yml ├── main.go └── api └── openrouter.go /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lifailon/openrouter-bot/HEAD/img/logo.png -------------------------------------------------------------------------------- /img/commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lifailon/openrouter-bot/HEAD/img/commands.png -------------------------------------------------------------------------------- /img/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lifailon/openrouter-bot/HEAD/img/example.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dev config 2 | .env 3 | 4 | # logs 5 | logs 6 | 7 | # bin 8 | openrouter-bot 9 | *debug* 10 | *.exe -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | OpenRouter-Bot: 3 | container_name: OpenRouter-Bot 4 | image: lifailon/openrouter-bot:latest 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | volumes: 9 | - .env:/openrouter-bot/.env 10 | restart: unless-stopped -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Default", 5 | "type": "go", 6 | "request": "launch", 7 | "mode": "debug", 8 | "program": "${workspaceFolder}/main.go", 9 | // "console": "integratedTerminal", 10 | "args": [] 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: openrouter-bot 3 | description: OpenRouter in Telegram 4 | version: 0.2.0 5 | appVersion: "0.5.0" 6 | 7 | icon: https://raw.githubusercontent.com/Lifailon/openrouter-bot/main/img/logo.png 8 | 9 | home: https://github.com/Lifailon/openrouter-bot 10 | 11 | sources: 12 | - https://github.com/Lifailon/openrouter-bot 13 | 14 | maintainers: 15 | - name: Lifailon 16 | url: https://github.com/Lifailon 17 | 18 | keywords: 19 | - telegram 20 | - bot 21 | - openrouter 22 | - ai 23 | - llm 24 | - gpt -------------------------------------------------------------------------------- /chart/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ .Release.Name }}-config 5 | 6 | data: 7 | .env: | 8 | API_KEY={{ .Values.API_KEY }} 9 | TELEGRAM_BOT_TOKEN={{ .Values.TELEGRAM_BOT_TOKEN }} 10 | ADMIN_IDS={{ .Values.ADMIN_IDS }} 11 | ALLOWED_USER_IDS={{ .Values.ALLOWED_USER_IDS }} 12 | BASE_URL={{ .Values.BASE_URL }} 13 | MODEL={{ .Values.MODEL }} 14 | VISION={{ .Values.VISION }} 15 | MAX_HISTORY_SIZE={{ .Values.MAX_HISTORY_SIZE }} 16 | MAX_HISTORY_TIME={{ .Values.MAX_HISTORY_TIME }} 17 | GUEST_BUDGET={{ .Values.GUEST_BUDGET }} 18 | LANG={{ .Values.LANG }} 19 | STATS_MIN_ROLE={{ .Values.STATS_MIN_ROLE }} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build image 2 | FROM golang:1.25 AS build 3 | WORKDIR /openrouter-bot 4 | COPY . . 5 | # Download dependencies for caching 6 | RUN go mod download 7 | # Build bot for determining os and architecture 8 | ARG TARGETOS TARGETARCH 9 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /openrouter-bot/openrouter-bot 10 | 11 | # Final image 12 | FROM alpine:3.22 13 | WORKDIR /openrouter-bot 14 | # Copy config and langs 15 | COPY --from=build /openrouter-bot/config.yaml ./ 16 | COPY --from=build /openrouter-bot/lang/ ./lang/ 17 | # Copy bot binary 18 | COPY --from=build /openrouter-bot/openrouter-bot ./ 19 | # Creating directory for logs 20 | RUN mkdir logs 21 | 22 | ENTRYPOINT ["/openrouter-bot/openrouter-bot"] -------------------------------------------------------------------------------- /chart/README.md: -------------------------------------------------------------------------------- 1 | Helm chart for installing the [OpenRouter Bot](https://github.com/Lifailon/openrouter-bot) in Kubernetes cluster. 2 | 3 | ```bash 4 | helm repo add openrouter-bot https://lifailon.github.io/openrouter-bot 5 | 6 | helm upgrade --install openrouter-bot openrouter-bot/openrouter-bot \ 7 | --set API_KEY="sk-or-v1-XXX" \ 8 | --set TELEGRAM_BOT_TOKEN="7777777777:XXX" \ 9 | --set ADMIN_IDS="7777777777" \ 10 | --set ALLOWED_USER_IDS="7777777777\,8888888888" \ 11 | --set BASE_URL=https://openrouter.ai/api/v1 \ 12 | --set MODEL=deepseek/deepseek-r1:free \ 13 | --set VISION=false \ 14 | --set MAX_HISTORY_SIZE=20 \ 15 | --set MAX_HISTORY_TIME=60 \ 16 | --set GUEST_BUDGET=0 \ 17 | --set LANG=RU \ 18 | --set STATS_MIN_ROLE=ADMIN 19 | ``` -------------------------------------------------------------------------------- /user/users_manager.go: -------------------------------------------------------------------------------- 1 | // user_manager.go 2 | package user 3 | 4 | import ( 5 | "openrouter-bot/config" 6 | "strconv" 7 | "sync" 8 | ) 9 | 10 | type Manager struct { 11 | LogsDir string 12 | users map[int64]*UsageTracker 13 | mu sync.Mutex 14 | } 15 | 16 | func NewUserManager(logsDir string) *Manager { 17 | return &Manager{ 18 | LogsDir: logsDir, 19 | users: make(map[int64]*UsageTracker), 20 | } 21 | } 22 | 23 | func (um *Manager) GetUser(userID int64, userName string, conf *config.Config) *UsageTracker { 24 | um.mu.Lock() 25 | defer um.mu.Unlock() 26 | 27 | if user, exists := um.users[userID]; exists { 28 | return user 29 | } 30 | 31 | user := NewUsageTracker(strconv.FormatInt(userID, 10), userName, um.LogsDir, conf) 32 | um.users[userID] = user 33 | return user 34 | } 35 | -------------------------------------------------------------------------------- /chart/values.yaml: -------------------------------------------------------------------------------- 1 | # Deployment 2 | replicaCount: 1 3 | image: 4 | img: "lifailon/openrouter-bot" 5 | tag: "latest" 6 | resources: 7 | requests: 8 | cpu: "100m" 9 | memory: "128Mi" 10 | limits: 11 | cpu: "200m" 12 | memory: "256Mi" 13 | readinessProbe: 14 | enabled: true 15 | livenessProbe: 16 | enabled: true 17 | probeSettings: 18 | exec: 19 | command: ["pgrep", "-f", "openrouter-bot"] 20 | initialDelaySeconds: 5 21 | periodSeconds: 10 22 | timeoutSeconds: 3 23 | failureThreshold: 3 24 | 25 | # configMap for env 26 | API_KEY: "sk-or-v1-XXX" 27 | TELEGRAM_BOT_TOKEN: "7777777777:XXX" 28 | ADMIN_IDS: "7777777777" 29 | ALLOWED_USER_IDS: "7777777777,8888888888" 30 | BASE_URL: https://openrouter.ai/api/v1 31 | MODEL: deepseek/deepseek-r1:free 32 | VISION: false 33 | MAX_HISTORY_SIZE: 20 34 | MAX_HISTORY_TIME: 60 35 | GUEST_BUDGET: 0 36 | LANG: RU 37 | STATS_MIN_ROLE: ADMIN -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # Telegram user ID of admins, or empty to assign no admin 2 | admin_ids: "" 3 | 4 | # Allowed USER Ids 5 | allowed_user_ids: "" 6 | 7 | # Budget configuration 8 | user_budget: 1 9 | guest_budget: 0.5 10 | # Budget period: daily, monthly, total 11 | budget_period: monthly 12 | # Language to use for the bot, now supported: EN, RU 13 | lang: EN 14 | 15 | # Minimum role to show stats. Supported values: ADMIN, USER, GUEST 16 | stats_min_role: ADMIN 17 | token_price: 0.002 18 | 19 | # Model configuration 20 | type: openrouter 21 | model: openai/gpt-4o-mini 22 | base_url: https://openrouter.ai/api/v1 23 | temperature: 0.7 24 | top_p: 0.7 25 | 26 | # Assistant configuration 27 | assistant_prompt: | 28 | • Read the entire convo history line by line before answering. You are Assistant. 29 | 30 | 31 | # Vision settings 32 | vision: true 33 | vision_prompt: Describe the image 34 | vision_detail: low 35 | 36 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module openrouter-bot 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.9.0 7 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 8 | github.com/joho/godotenv v1.5.1 9 | github.com/sashabaranov/go-openai v1.41.1 10 | github.com/spf13/viper v1.20.1 11 | ) 12 | 13 | require ( 14 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 15 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 16 | github.com/rogpeppe/go-internal v1.11.0 // indirect 17 | github.com/sagikazarmark/locafero v0.10.0 // indirect 18 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 19 | github.com/spf13/afero v1.14.0 // indirect 20 | github.com/spf13/cast v1.9.2 // indirect 21 | github.com/spf13/pflag v1.0.7 // indirect 22 | github.com/subosito/gotenv v1.6.0 // indirect 23 | golang.org/x/sys v0.35.0 // indirect 24 | golang.org/x/text v0.28.0 // indirect 25 | gopkg.in/yaml.v3 v3.0.1 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) deinfinite & fork Lifailon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /user/history.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "time" 4 | 5 | func (ut *UsageTracker) AddMessage(role, content string) { 6 | ut.History.mu.Lock() 7 | defer ut.History.mu.Unlock() 8 | ut.History.messages = append(ut.History.messages, Message{Role: role, Content: content}) 9 | } 10 | 11 | func (ut *UsageTracker) GetMessages() []Message { 12 | ut.History.mu.Lock() 13 | defer ut.History.mu.Unlock() 14 | return ut.History.messages 15 | } 16 | 17 | func (ut *UsageTracker) ClearHistory() { 18 | ut.History.mu.Lock() 19 | defer ut.History.mu.Unlock() 20 | ut.History.messages = []Message{} 21 | } 22 | 23 | func (ut *UsageTracker) CheckHistory(maxMessages int, maxTime int) { 24 | ut.History.mu.Lock() 25 | defer ut.History.mu.Unlock() 26 | //Удаляем старые сообщения 27 | if ut.LastMessageTime.IsZero() { 28 | ut.LastMessageTime = time.Now() 29 | } 30 | if ut.LastMessageTime.Before(time.Now().Add(-time.Duration(maxTime) * time.Minute)) { 31 | // Remove messages older than the maximum time limit 32 | ut.History.messages = make([]Message, 0) 33 | } 34 | 35 | if len(ut.History.messages) > maxMessages { 36 | // Удаляем первые сообщения, чтобы оставить только последние maxMessages 37 | ut.History.messages = ut.History.messages[len(ut.History.messages)-maxMessages:] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lang/lang.go: -------------------------------------------------------------------------------- 1 | package lang 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | var translations map[string]map[string]interface{} 12 | 13 | func LoadTranslations(langDir string) error { 14 | translations = make(map[string]map[string]interface{}) 15 | 16 | languages := []string{"EN", "RU"} 17 | 18 | for _, lang := range languages { 19 | filePath := filepath.Join(langDir, lang+".json") 20 | data, err := os.ReadFile(filePath) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | var langMap map[string]interface{} 26 | err = json.Unmarshal(data, &langMap) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | translations[lang] = langMap 32 | } 33 | 34 | for _, lang := range languages { 35 | filePath := filepath.Join(langDir, lang+".json") 36 | log.Printf("Loading translations from: %s", filePath) 37 | } 38 | 39 | //log.Printf("Loaded translations: %+v", translations) 40 | return nil 41 | } 42 | 43 | func Translate(key string, lang string) string { 44 | //log.Printf("Translating key: %s, language: %s", key, lang) 45 | if translations == nil { 46 | log.Println("Translations not loaded. Did you call LoadTranslations?") 47 | return key 48 | } 49 | keys := strings.Split(key, ".") 50 | value := interface{}(translations[lang]) 51 | 52 | for _, k := range keys { 53 | if m, ok := value.(map[string]interface{}); ok { 54 | value = m[k] 55 | } else { 56 | return key 57 | } 58 | } 59 | 60 | if str, ok := value.(string); ok { 61 | return str 62 | } 63 | 64 | return key 65 | } 66 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # OpenRouter api key from https://openrouter.ai/settings/keys 2 | API_KEY= 3 | 4 | # Telegram api key from https://telegram.me/BotFather 5 | TELEGRAM_BOT_TOKEN= 6 | 7 | # Your Telegram id from https://t.me/getmyid_bot 8 | ADMIN_IDS= 9 | # List of users to access the bot, separated by commas 10 | ALLOWED_USER_IDS= 11 | 12 | # Enable user access (enabled by default) from ALLOWED_USER_IDS 13 | #USER_BUDGET=1 14 | # Disable guest access (enabled by default) 15 | GUEST_BUDGET=0 16 | 17 | # Default base url for OpenRouter API 18 | BASE_URL=https://openrouter.ai/api/v1 19 | # List of free models: https://openrouter.ai/models?max_price=0 20 | MODEL=deepseek/deepseek-r1:free 21 | 22 | # Using local LLM via LM Studio (https://lmstudio.ai) 23 | #BASE_URL=http://localhost:1234/v1 24 | # Using local model: https://huggingface.co/ruslandev/llama-3-8b-gpt-4o-ru1.0 25 | #MODEL=llama-3-8b-gpt-4o-ru1.0 26 | 27 | # Model settings (default values) 28 | #MAX_TOKENS=2000 29 | #TEMPERATURE=0.7 30 | #TOP_P=0.7 31 | # System preset that specifies the role for AI 32 | #ASSISTANT_PROMPT="Ты переводчик, умеешь только переводить текст с русского на англйский язык (и наоборот) и не отвечаешь на вопросы." 33 | 34 | # Enable analysis of transmitted images 35 | VISION=false 36 | #VISION_PROMPT="Описание изображения" 37 | #VISION_DETAIL="низкий" 38 | 39 | # The maximum number of messages or time in minutes for store messages in history 40 | MAX_HISTORY_SIZE=20 # default 10 41 | MAX_HISTORY_TIME=120 # default 60 42 | 43 | # Language used for bot responses (supported: EN/RU) 44 | LANG=RU 45 | 46 | # Who can receive statistics (ADMIN/USER/GUEST) 47 | STATS_MIN_ROLE=ADMIN 48 | 49 | #BUDGET_PERIOD=monthly -------------------------------------------------------------------------------- /config/config_manager.go: -------------------------------------------------------------------------------- 1 | // config/manager.go 2 | package config 3 | 4 | import ( 5 | "log" 6 | "sync" 7 | 8 | "github.com/fsnotify/fsnotify" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | type Manager struct { 13 | config *Config 14 | mutex sync.RWMutex 15 | listeners []chan<- Config 16 | } 17 | 18 | func NewManager(configPath string) (*Manager, error) { 19 | // Устанавливаем значения по умолчанию 20 | 21 | viper.SetConfigFile(configPath) 22 | viper.AutomaticEnv() 23 | 24 | if err := viper.ReadInConfig(); err != nil { 25 | log.Fatalf("Error reading config file, %s", err) 26 | return nil, err 27 | } 28 | 29 | manager := &Manager{ 30 | listeners: make([]chan<- Config, 0), 31 | } 32 | 33 | // Initial config load 34 | config, err := Load() 35 | if err != nil { 36 | log.Fatalf("Failed to load configuration: %v", err) 37 | return nil, err 38 | } 39 | manager.config = config 40 | 41 | // Setup file watcher 42 | viper.WatchConfig() 43 | viper.OnConfigChange(func(e fsnotify.Event) { 44 | manager.reloadConfig() 45 | }) 46 | 47 | return manager, nil 48 | } 49 | 50 | func (m *Manager) reloadConfig() { 51 | newConfig, err := Load() 52 | if err != nil { 53 | log.Printf("Failed to reload config: %v", err) 54 | return 55 | } 56 | 57 | m.mutex.Lock() 58 | m.config = newConfig 59 | m.mutex.Unlock() 60 | 61 | // Notify all listeners 62 | for _, listener := range m.listeners { 63 | listener <- *newConfig 64 | } 65 | } 66 | 67 | func (m *Manager) Subscribe() <-chan Config { 68 | ch := make(chan Config, 1) 69 | m.listeners = append(m.listeners, ch) 70 | return ch 71 | } 72 | 73 | func (m *Manager) GetConfig() *Config { 74 | m.mutex.RLock() 75 | defer m.mutex.RUnlock() 76 | return m.config 77 | } 78 | -------------------------------------------------------------------------------- /user/types.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/sashabaranov/go-openai" 8 | ) 9 | 10 | type UsageTracker struct { 11 | UserID string 12 | UserName string 13 | LogsDir string 14 | SystemPrompt string 15 | LastMessageTime time.Time 16 | CurrentStream *openai.ChatCompletionStream 17 | Usage *UserUsage 18 | History History 19 | UsageMu sync.Mutex `json:"-"` // Мьютекс для синхронизации доступа к Usage 20 | FileMu sync.Mutex `json:"-"` // Мьютекс для синхронизации доступа к файлу 21 | } 22 | 23 | type Message struct { 24 | Role string 25 | Content string 26 | } 27 | 28 | type History struct { 29 | messages []Message 30 | mu sync.Mutex 31 | } 32 | 33 | type UserUsage struct { 34 | UserName string `json:"user_name"` 35 | UsageHistory UsageHist `json:"usage_history"` 36 | } 37 | 38 | type Cost struct { 39 | Day float64 `json:"day"` 40 | Month float64 `json:"month"` 41 | AllTime float64 `json:"all_time"` 42 | LastUpdate string `json:"last_update"` 43 | } 44 | 45 | type UsageHist struct { 46 | ChatCost map[string]float64 `json:"chat_cost"` 47 | } 48 | 49 | type GenerationResponse struct { 50 | Data GenerationData `json:"data"` 51 | } 52 | 53 | type GenerationData struct { 54 | ID string `json:"id"` 55 | Model string `json:"model"` 56 | Streamed bool `json:"streamed"` 57 | GenerationTime int `json:"generation_time"` 58 | CreatedAt string `json:"created_at"` 59 | TokensPrompt int `json:"tokens_prompt"` 60 | TokensCompletion int `json:"tokens_completion"` 61 | NativeTokensPrompt int `json:"native_tokens_prompt"` 62 | NativeTokensCompletion int `json:"native_tokens_completion"` 63 | NumMediaPrompt int `json:"num_media_prompt"` 64 | NumMediaCompletion int `json:"num_media_completion"` 65 | Origin string `json:"origin"` 66 | TotalCost float64 `json:"total_cost"` 67 | } 68 | -------------------------------------------------------------------------------- /config/param.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type ModelResponse struct { 11 | Model string `json:"model"` 12 | FrequencyPenaltyP10 float64 `json:"frequency_penalty_p10"` 13 | FrequencyPenaltyP50 float64 `json:"frequency_penalty_p50"` 14 | FrequencyPenaltyP90 float64 `json:"frequency_penalty_p90"` 15 | MinPP10 float64 `json:"min_p_p10"` 16 | MinPP50 float64 `json:"min_p_p50"` 17 | MinPP90 float64 `json:"min_p_p90"` 18 | PresencePenaltyP10 float64 `json:"presence_penalty_p10"` 19 | PresencePenaltyP50 float64 `json:"presence_penalty_p50"` 20 | PresencePenaltyP90 float64 `json:"presence_penalty_p90"` 21 | RepetitionPenaltyP10 float64 `json:"repetition_penalty_p10"` 22 | RepetitionPenaltyP50 float64 `json:"repetition_penalty_p50"` 23 | RepetitionPenaltyP90 float64 `json:"repetition_penalty_p90"` 24 | TemperatureP10 float64 `json:"temperature_p10"` 25 | TemperatureP50 float64 `json:"temperature_p50"` 26 | TemperatureP90 float64 `json:"temperature_p90"` 27 | TopAP10 float64 `json:"top_a_p10"` 28 | TopAP50 float64 `json:"top_a_p50"` 29 | TopAP90 float64 `json:"top_a_p90"` 30 | TopKP10 float64 `json:"top_k_p10"` 31 | TopKP50 float64 `json:"top_k_p50"` 32 | TopKP90 float64 `json:"top_k_p90"` 33 | TopPP10 float64 `json:"top_p_p10"` 34 | TopPP50 float64 `json:"top_p_p50"` 35 | TopPP90 float64 `json:"top_p_p90"` 36 | } 37 | 38 | // Response structure to wrap the model parameters 39 | type Response struct { 40 | Data ModelResponse `json:"data"` 41 | } 42 | 43 | func GetParameters(conf *Config) (ModelResponse, error) { 44 | url := fmt.Sprintf("https://openrouter.ai/api/v1/parameters/%s", conf.Model.ModelName) 45 | 46 | req, err := http.NewRequest("GET", url, nil) 47 | if err != nil { 48 | return ModelResponse{}, err 49 | } 50 | 51 | bearer := fmt.Sprintf("Bearer %s", conf.OpenAIApiKey) 52 | req.Header.Add("Authorization", bearer) 53 | 54 | client := &http.Client{ 55 | Timeout: 10 * time.Second, 56 | } 57 | resp, err := client.Do(req) 58 | if err != nil { 59 | return ModelResponse{}, err 60 | } 61 | defer resp.Body.Close() 62 | 63 | var parametersResponse Response 64 | if err := json.NewDecoder(resp.Body).Decode(¶metersResponse); err != nil { 65 | return ModelResponse{}, err 66 | } 67 | 68 | return parametersResponse.Data, nil 69 | } 70 | -------------------------------------------------------------------------------- /chart/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ .Release.Name }} 5 | labels: 6 | app: {{ .Release.Name }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | app: {{ .Release.Name }} 12 | strategy: 13 | type: RollingUpdate 14 | template: 15 | metadata: 16 | labels: 17 | app: {{ .Release.Name }} 18 | spec: 19 | affinity: 20 | podAntiAffinity: 21 | requiredDuringSchedulingIgnoredDuringExecution: 22 | - labelSelector: 23 | matchExpressions: 24 | - key: app 25 | operator: In 26 | values: [openrouter-bot] 27 | topologyKey: "kubernetes.io/hostname" 28 | terminationGracePeriodSeconds: 30 29 | containers: 30 | - image: {{ .Values.image.img }}:{{ .Values.image.tag }} 31 | name: {{ .Release.Name }} 32 | imagePullPolicy: IfNotPresent 33 | resources: 34 | requests: 35 | cpu: "{{ .Values.resources.requests.cpu | default "100m" }}" 36 | memory: "{{ .Values.resources.requests.memory | default "128Mi" }}" 37 | limits: 38 | cpu: "{{ .Values.resources.limits.cpu | default "200m" }}" 39 | memory: "{{ .Values.resources.limits.memory | default "256Mi" }}" 40 | {{- if .Values.readinessProbe.enabled }} 41 | readinessProbe: 42 | exec: 43 | command: {{ .Values.probeSettings.exec.command | toJson }} 44 | initialDelaySeconds: {{ .Values.probeSettings.initialDelaySeconds }} 45 | periodSeconds: {{ .Values.probeSettings.periodSeconds }} 46 | timeoutSeconds: {{ .Values.probeSettings.timeoutSeconds }} 47 | failureThreshold: {{ .Values.probeSettings.failureThreshold }} 48 | {{- end }} 49 | {{- if .Values.livenessProbe.enabled }} 50 | livenessProbe: 51 | exec: 52 | command: {{ .Values.probeSettings.exec.command | toJson }} 53 | initialDelaySeconds: {{ .Values.probeSettings.initialDelaySeconds }} 54 | periodSeconds: {{ .Values.probeSettings.periodSeconds }} 55 | timeoutSeconds: {{ .Values.probeSettings.timeoutSeconds }} 56 | failureThreshold: {{ .Values.probeSettings.failureThreshold }} 57 | {{- end }} 58 | volumeMounts: 59 | - name: config-volume 60 | mountPath: /openrouter-bot/.env 61 | subPath: .env 62 | volumes: 63 | - name: config-volume 64 | configMap: 65 | name: {{ .Release.Name }}-config 66 | items: 67 | - key: .env 68 | path: .env -------------------------------------------------------------------------------- /lang/EN.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "english", 3 | "commands": { 4 | "start": "Hi! I'm an open source GPT bot created to provide quick help on your questions.\n\nPossibilities:\n\n• Conduct dialogue on various topics and answer questions\n• Help with solving problems and analyzing data\n• Write code in any programming language\n• Generate ideas and offer solutions to problems\n\n", 5 | "start_end": "\n\nJust send me a message, and I'll try to help!\n\nYou can run the bot yourself and for free by following the instructions at the link: https://github.com/Lifailon/openrouter-bot", 6 | "help": "Available Commands:\n\n/help - Show this help message\n/get_models - Get list of free models\n/set_model [model name] - Set another model\n/set_model default - Set model default\n/reset - Clear conversation history\n/reset [new prompt] - Set a new system prompt\n/reset system - Reset system prompt to default\n/stats - Show current usage statistics\n/stop - Stop the active request\n\nAdvice: Before asking a new question that is not related to the old topic, reset the message memory so as not to send the old context, in this case, the answers will be more accurate and the request will take less time to process.", 7 | "getModels": "List of ↗️ [free models](https://openrouter.ai/models?max_price=0):\n\n", 8 | "setModel": "Model changed to", 9 | "noArgsModel": "Model name not passed.\n\nCorrect format: `/set_model [название модели]`\n\nExample: `/set_model deepseek/deepseek-chat-v3-0324:free`", 10 | "noSpaceModel": "The model name must not contain spaces.\n\nCorrect format: `/set_model [название модели]`\n\nExample: `/set_model deepseek/deepseek-chat-v3-0324:free`", 11 | "stats": "Usage Statistics\n\nCounted Usage: $%s\nToday's Usage: $%s\nMonth's Usage: $%s\nTotal Usage: $%s\n\nThe number of messages in memory.: %s", 12 | "stats_min": "Usage Statistics\n\nThe number of messages in memory.: %s", 13 | "reset": "Message memory cleared.", 14 | "reset_system": "Message memory cleared. System prompt set to default.", 15 | "reset_prompt": "Message memory cleared. System prompt set to ", 16 | "stop": "Request stopped.", 17 | "stop_err": "There is no active request." 18 | }, 19 | "description": { 20 | "start": "Start working with the bot", 21 | "help": "Show help", 22 | "getModels": "Get list of free models", 23 | "setModel": "Set model", 24 | "reset": "Clear conversation history", 25 | "stats": "Show usage statistics", 26 | "stop": "Stop the current request" 27 | }, 28 | "budget_out": "You have no budget or you have exhausted it.", 29 | "loadText": "Processing request", 30 | "errorText": "Error processing request" 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | OpenRouter 5 |
6 | Bot 7 |
8 |

9 | 10 |

11 | English (🇺🇸) | Русский (🇷🇺) 12 |

13 | 14 | This project allows you to launch your Telegram bot in a few minutes to communicate with free and paid AI models via [OpenRouter](https://openrouter.ai), or local LLMs, for example, via [LM Studio](https://lmstudio.ai). 15 | 16 | > [!NOTE] 17 | > This repository is a fork of the [openrouter-gpt-telegram-bot](https://github.com/deinfinite/openrouter-gpt-telegram-bot) project, which adds new features (such as switch current model and `Markdown` formatting in bot responses) and optimizes the container startup process. 18 | 19 |
20 | Example 21 | 22 | 23 |
24 | 25 | ## Preparation 26 | 27 | - Register with [OpenRouter](https://openrouter.ai) and get an [API key](https://openrouter.ai/settings/keys). 28 | 29 | - Create your Telegram bot using [@BotFather](https://telegram.me/BotFather) and get its API token. 30 | 31 | - Get your telegram id using [@getmyid_bot](https://t.me/getmyid_bot). 32 | 33 | > [!TIP] 34 | > When you launch the bot, you will be able to see the IDs of other users in the log, to whom you can also grant access to the bot in the future. 35 | 36 | ## Installation 37 | 38 | To run locally on Windows or Linux system, download the pre-built binary (without dependencies) from the [releases](https://github.com/Lifailon/openrouter-bot/releases) page. 39 | 40 | ### Running in Docker 41 | 42 | - Create a working directory: 43 | 44 | ```bash 45 | mkdir openrouter-bot 46 | cd openrouter-bot 47 | ``` 48 | 49 | - Create `.env` file and fill in the basic parameters: 50 | 51 | ```bash 52 | # OpenRouter api key 53 | API_KEY= 54 | # Free modeles: https://openrouter.ai/models?max_price=0 55 | MODEL=deepseek/deepseek-r1:free 56 | # Telegram api key 57 | TELEGRAM_BOT_TOKEN= 58 | # Your Telegram id 59 | ADMIN_IDS= 60 | # List of users to access the bot, separated by commas 61 | ALLOWED_USER_IDS= 62 | # Disable guest access (enabled by default) 63 | GUEST_BUDGET=0 64 | # Language used for bot responses (supported: EN/RU) 65 | LANG=EN 66 | ``` 67 | 68 | The list of all available parameters is listed in the [.env.example](https://github.com/Lifailon/openrouter-bot/blob/main/.env.example) file 69 | 70 | - Run a container using the image from [Docker Hub](https://hub.docker.com/r/lifailon/openrouter-bot): 71 | 72 | ```bash 73 | docker run -d --name OpenRouter-Bot \ 74 | -v ./.env:/openrouter-bot/.env \ 75 | --restart unless-stopped \ 76 | lifailon/openrouter-bot:latest 77 | ``` 78 | 79 | The image is build for `amd64` and `arm64` (Raspberry Pi) platforms using [docker buildx](https://github.com/docker/buildx). 80 | 81 | ## Build 82 | 83 | ```bash 84 | git clone https://github.com/Lifailon/openrouter-bot 85 | cd openrouter-bot 86 | docker-compose up -d --build 87 | ``` 88 | -------------------------------------------------------------------------------- /lang/RU.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "russian", 3 | "commands": { 4 | "start": "Привет! Я GPT-бот с открытым исходным кодом, созданный для быстрой помощи на поставленные вопросы.\n\nВозможности:\n\n• Вести диалог на различные темы и отвечать на вопросы\n• Помогать с решением задач и анализировать данные\n• Писать код на любом языке программирования\n• Генерировать идеи и предлагать решения проблем\n\n", 5 | "start_end": "\n\nПросто отправьте мне сообщение, и я постараюсь помочь!\n\nВы можете запустить бота самостоятельно и бесплатно, следуя инструкциям по ссылке: https://github.com/Lifailon/openrouter-bot/blob/main/README_RU.md", 6 | "help": "Доступные команды:\n\n/help - Показать это сообщение справки\n/get_models - Получить список бесплатных моделей\n/set_model [название модели] - Установить другую модель\n/set_model default - Установить модель по умолчанию\n/reset - Очистить историю разговора\n/reset [новый промпт] - Установить новый системный промпт\n/reset system - Сбросить системный промпт на значение по умолчанию\n/stats - Показать текущую статистику использования\n/stop - Остановить активный запрос\n\nСовет: Перед тем как задать новый вопрос, который не относится к старой теме, сбросьте память сообщений, чтобы не отправлять старый контекст, в таком случае ответы будут более точными, а обработка запроса займет меньше времени.", 7 | "getModels": "Список ↗️ [бесплатных моделей](https://openrouter.ai/models?max_price=0):\n\n", 8 | "setModel": "Модель изменена на", 9 | "noArgsModel": "Не передано название модели.\n\nКорректный формат: `/set_model [название модели]`\n\nПример: `/set_model deepseek/deepseek-chat-v3-0324:free`", 10 | "noSpaceModel": "Название модели не должно содержать пробелы.\n\nКорректный формат: `/set_model [название модели]`\n\nПример: `/set_model deepseek/deepseek-chat-v3-0324:free`", 11 | "stats": "Статистика использования\n\nУчтенное использование: $%s\nИспользование сегодня: $%s\nИспользование за месяц: $%s\nОбщее использование: $%s\n\nКоличество сообщений в памяти: %s", 12 | "stats_min": "Статистика использования\n\nКоличество сообщений в памяти: %s", 13 | "reset": "Память сообщений очищена.", 14 | "reset_system": "Память сообщений очищена. Системный промпт установлен на значение по умолчанию.", 15 | "reset_prompt": "Память сообщений очищена. Системный промпт установлен на ", 16 | "stop": "Запрос остановлен.", 17 | "stop_err": "Нет активного запроса." 18 | }, 19 | "description": { 20 | "start": "Начать работу с ботом", 21 | "help": "Показать справку", 22 | "getModels": "Получить список бесплатных моделей", 23 | "setModel": "Сменить модель", 24 | "reset": "Очистить историю разговора", 25 | "stats": "Показать статистику использования", 26 | "stop": "Остановить текущий запрос" 27 | }, 28 | "budget_out": "У вас нет бюджета или вы его исчерпали.", 29 | "loadText": "Обработка запроса", 30 | "errorText": "Ошибка обработки запроса" 31 | } 32 | -------------------------------------------------------------------------------- /README_RU.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | OpenRouter 5 |
6 | Bot 7 |
8 |

9 | 10 |

11 | English (🇺🇸) | Русский (🇷🇺) 12 |

13 | 14 | Этот проект позволяет за несколько минут запустить своего Telegram бота для общения с бесплатными и платными моделями ИИ через [OpenRouter](https://openrouter.ai), или локальными LLM, например, через [LM Studio](https://lmstudio.ai). 15 | 16 | > [!NOTE] 17 | > Этот репозиторий является форком проекта [openrouter-gpt-telegram-bot](https://github.com/deinfinite/openrouter-gpt-telegram-bot), который добавляет новые функции (например, изменение текущей модели и форматирование `Markdown` в ответах бота) и оптимизирует процесс запуска в контейнере. 18 | 19 |
20 | Пример 21 | 22 | 23 |
24 | 25 | ## Подготовка 26 | 27 | - Зарегистрируйтесь в [OpenRouter](https://openrouter.ai) и получите [API ключ](https://openrouter.ai/settings/keys). 28 | 29 | - Создайте своего Telegram бота, используя [@BotFather](https://telegram.me/BotFather) и получите его API токен. 30 | 31 | - Получите свой Telegram id, используя [@getmyid_bot](https://t.me/getmyid_bot). 32 | 33 | > [!TIP] 34 | > При запуске бота вы сможете увидеть в логах идентификаторы других пользователей, которым вы также сможете предоставить доступ к боту в дальнейшем. 35 | 36 | ## Установка 37 | 38 | Для локального запуска в системе Windows или Linux, загрузите предварительно собранный бинарный файл (без зависимостей) на странице [релизов](https://github.com/Lifailon/openrouter-bot/releases). 39 | 40 | ### Запуск в Docker 41 | 42 | - Создайте рабочий каталог бота: 43 | 44 | ```bash 45 | mkdir openrouter-bot 46 | cd openrouter-bot 47 | ``` 48 | 49 | - Создайте `.env` файл и заполните базовые параметры: 50 | 51 | ```bash 52 | # OpenRouter api key 53 | API_KEY= 54 | # Список бесплатных моделей: https://openrouter.ai/models?max_price=0 55 | MODEL=deepseek/deepseek-r1:free 56 | # Telegram api key 57 | TELEGRAM_BOT_TOKEN= 58 | # Ваш Telegram id 59 | ADMIN_IDS= 60 | # Список пользователей для доступа к боту, разделенных запятыми 61 | ALLOWED_USER_IDS= 62 | # Отключить гостевой доступ (включен по умолчанию) 63 | GUEST_BUDGET=0 64 | # Язык, используемый в ответах бота 65 | LANG=RU 66 | ``` 67 | 68 | Список всех доступных параметров приведен в файле [.env.example](https://github.com/Lifailon/openrouter-bot/blob/main/.env.example) 69 | 70 | - Запустите контейнер, используя образ из [Docker Hub](https://hub.docker.com/r/lifailon/openrouter-bot): 71 | 72 | ```bash 73 | docker run -d --name OpenRouter-Bot \ 74 | -v ./.env:/openrouter-bot/.env \ 75 | --restart unless-stopped \ 76 | lifailon/openrouter-bot:latest 77 | ``` 78 | 79 | Образ собран для платформ `amd64` и `aarch64` (Raspberry Pi) с использованием [docker buildx](https://github.com/docker/buildx). 80 | 81 | ## Сборка 82 | 83 | ```bash 84 | git clone https://github.com/Lifailon/openrouter-bot 85 | cd openrouter-bot 86 | docker-compose up -d --build 87 | ``` 88 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 4 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 5 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 6 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 7 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= 8 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= 9 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 10 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 14 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 15 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 16 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 17 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 18 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 19 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 20 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 24 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 25 | github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= 26 | github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= 27 | github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4= 28 | github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 29 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 30 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 31 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 32 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 33 | github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= 34 | github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 35 | github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= 36 | github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 37 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 38 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 39 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 40 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 41 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 42 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 43 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 44 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 45 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 46 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 48 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 49 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 51 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "reflect" 7 | 8 | "github.com/joho/godotenv" 9 | "github.com/sashabaranov/go-openai" 10 | "github.com/spf13/viper" 11 | 12 | "openrouter-bot/lang" 13 | "os" 14 | "strconv" 15 | "strings" 16 | ) 17 | 18 | type Config struct { 19 | TelegramBotToken string 20 | OpenAIApiKey string 21 | Model ModelParameters 22 | MaxTokens int 23 | BotLanguage string 24 | OpenAIBaseURL string 25 | SystemPrompt string 26 | BudgetPeriod string 27 | GuestBudget float64 28 | UserBudget float64 29 | AdminChatIDs []int64 30 | AllowedUserChatIDs []int64 31 | MaxHistorySize int 32 | MaxHistoryTime int 33 | Vision string 34 | VisionPrompt string 35 | VisionDetails string 36 | StatsMinRole string 37 | Lang string 38 | } 39 | 40 | type ModelParameters struct { 41 | Type string 42 | ModelName string 43 | ModelNameDefault string 44 | ModelReq openai.ChatCompletionRequest 45 | FrequencyPenalty float64 46 | MinP float64 47 | PresencePenalty float64 48 | RepetitionPenalty float64 49 | Temperature float64 50 | TopA float64 51 | TopK float64 52 | TopP float64 53 | } 54 | 55 | func Load() (*Config, error) { 56 | err := godotenv.Load() 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | // Default params 62 | viper.SetDefault("MAX_TOKENS", 2000) 63 | viper.SetDefault("TEMPERATURE", 0.7) 64 | viper.SetDefault("TOP_P", 0.7) 65 | viper.SetDefault("BASE_URL", "https://openrouter.ai/api/v1") // or https://api.openai.com/v1 66 | viper.SetDefault("BUDGET_PERIOD", "monthly") 67 | viper.SetDefault("MAX_HISTORY_SIZE", 10) 68 | viper.SetDefault("MAX_HISTORY_TIME", 60) 69 | viper.SetDefault("LANG", "en") 70 | 71 | config := &Config{ 72 | TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"), 73 | OpenAIApiKey: os.Getenv("API_KEY"), 74 | Model: ModelParameters{ 75 | Type: viper.GetString("TYPE"), 76 | ModelName: viper.GetString("MODEL"), 77 | ModelNameDefault: viper.GetString("MODEL"), 78 | Temperature: viper.GetFloat64("TEMPERATURE"), 79 | TopP: viper.GetFloat64("TOP_P"), 80 | }, 81 | MaxTokens: viper.GetInt("MAX_TOKENS"), 82 | OpenAIBaseURL: viper.GetString("BASE_URL"), 83 | SystemPrompt: viper.GetString("ASSISTANT_PROMPT"), 84 | BudgetPeriod: viper.GetString("BUDGET_PERIOD"), 85 | GuestBudget: viper.GetFloat64("GUEST_BUDGET"), 86 | UserBudget: viper.GetFloat64("USER_BUDGET"), 87 | AdminChatIDs: getStrAsIntList("ADMIN_IDS"), 88 | AllowedUserChatIDs: getStrAsIntList("ALLOWED_USER_IDS"), 89 | MaxHistorySize: viper.GetInt("MAX_HISTORY_SIZE"), 90 | MaxHistoryTime: viper.GetInt("MAX_HISTORY_TIME"), 91 | Vision: viper.GetString("VISION"), 92 | VisionPrompt: viper.GetString("VISION_PROMPT"), 93 | VisionDetails: viper.GetString("VISION_DETAIL"), 94 | StatsMinRole: viper.GetString("STATS_MIN_ROLE"), 95 | Lang: viper.GetString("LANG"), 96 | } 97 | if config.BudgetPeriod == "" { 98 | log.Fatalf("Set budget_period in config file") 99 | } 100 | language := lang.Translate("language", config.Lang) 101 | config.SystemPrompt = "Always answer in " + language + " language." + config.SystemPrompt 102 | printConfig(config) 103 | return config, nil 104 | } 105 | 106 | func getStrAsIntList(name string) []int64 { 107 | valueStr := viper.GetString(name) 108 | if valueStr == "" { 109 | log.Println("Missing required environment variable, " + name) 110 | var emptyArray []int64 111 | return emptyArray 112 | } 113 | var values []int64 114 | for _, str := range strings.Split(valueStr, ",") { 115 | value, err := strconv.ParseInt(strings.TrimSpace(str), 10, 64) 116 | if err != nil { 117 | log.Printf("Invalid value for environment variable %s: %v", name, err) 118 | continue 119 | } 120 | values = append(values, value) 121 | } 122 | return values 123 | } 124 | 125 | func printConfig(c *Config) { 126 | if c == nil { 127 | fmt.Println("Config is nil") 128 | return 129 | } 130 | v := reflect.ValueOf(*c) 131 | t := v.Type() 132 | 133 | for i := 0; i < v.NumField(); i++ { 134 | field := v.Field(i) 135 | fieldName := t.Field(i).Name 136 | 137 | if field.Kind() == reflect.Struct { 138 | fmt.Printf("%s:\n", fieldName) 139 | printStructFields(field) 140 | } else { 141 | fmt.Printf("%s: %v\n", fieldName, field.Interface()) 142 | } 143 | } 144 | } 145 | 146 | func printStructFields(v reflect.Value) { 147 | t := v.Type() 148 | for i := 0; i < v.NumField(); i++ { 149 | field := v.Field(i) 150 | fieldName := t.Field(i).Name 151 | fmt.Printf(" %s: %v\n", fieldName, field.Interface()) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | Docker: 7 | description: 'Build and push to Docker Hub' 8 | default: false 9 | type: boolean 10 | Binary: 11 | description: 'Build binary' 12 | default: true 13 | type: boolean 14 | Version: 15 | description: 'Version for binary and tag for image' 16 | required: true 17 | default: 'latest' 18 | type: string 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Clone main repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Login to Docker Hub 29 | if: ${{ github.event.inputs.Docker == 'true' }} 30 | uses: docker/login-action@v3 31 | with: 32 | username: ${{ secrets.DOCKER_USERNAME }} 33 | password: ${{ secrets.DOCKER_PASSWORD }} 34 | 35 | - name: Install Docker Buildx 36 | if: ${{ github.event.inputs.Docker == 'true' }} 37 | uses: docker/setup-buildx-action@v3 38 | with: 39 | driver: docker-container 40 | install: true 41 | 42 | - name: Build and push Docker images for amd64 and arm64 43 | if: ${{ github.event.inputs.Docker == 'true' }} 44 | run: | 45 | # docker build -t lifailon/openrouter-bot:${{ github.event.inputs.Version }} . 46 | # docker push lifailon/openrouter-bot:${{ github.event.inputs.Version }} 47 | docker buildx build \ 48 | --platform \ 49 | linux/amd64,linux/arm64 \ 50 | -t lifailon/openrouter-bot:${{ github.event.inputs.Version }} \ 51 | --push . 52 | continue-on-error: true 53 | 54 | - name: Install Go 55 | if: ${{ github.event.inputs.Binary == 'true' }} 56 | uses: actions/setup-go@v5 57 | with: 58 | go-version: 1.25 59 | 60 | - name: Build binaries 61 | if: ${{ github.event.inputs.Binary == 'true' }} 62 | run: | 63 | mkdir -p bin 64 | architectures=("amd64" "arm64") 65 | for arch in "${architectures[@]}"; do 66 | CGO_ENABLED=0 GOOS=windows GOARCH=$arch go build -o bin/openrouter-bot-${{ github.event.inputs.Version }}-windows-$arch.exe main.go 67 | CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -o bin/openrouter-bot-${{ github.event.inputs.Version }}-linux-$arch main.go 68 | # CGO_ENABLED=0 GOOS=darwin GOARCH=$arch go build -o bin/openrouter-bot-${{ github.event.inputs.Version }}-darwin-$arch main.go 69 | done 70 | ls -lh bin 71 | mkdir -p bin/{openrouter-bot-linux,openrouter-bot-windows,openrouter-bot-raspberry-pi} 72 | mv bin/openrouter-bot-${{ github.event.inputs.Version }}-windows-amd64.exe bin/openrouter-bot-windows/openrouter-bot.exe 73 | cp ./.env.example bin/openrouter-bot-windows/.env 74 | cp ./config.yaml bin/openrouter-bot-windows/config.yaml 75 | cp -r ./lang bin/openrouter-bot-windows/lang 76 | mkdir bin/openrouter-bot-windows/logs 77 | mv bin/openrouter-bot-${{ github.event.inputs.Version }}-linux-amd64 bin/openrouter-bot-linux/openrouter-bot 78 | cp ./.env.example bin/openrouter-bot-linux/.env 79 | cp ./config.yaml bin/openrouter-bot-linux/config.yaml 80 | cp -r ./lang bin/openrouter-bot-linux/lang 81 | mkdir bin/openrouter-bot-linux/logs 82 | mv bin/openrouter-bot-${{ github.event.inputs.Version }}-linux-arm64 bin/openrouter-bot-raspberry-pi/openrouter-bot 83 | cp ./.env.example bin/openrouter-bot-raspberry-pi/.env 84 | cp ./config.yaml bin/openrouter-bot-raspberry-pi/config.yaml 85 | cp -r ./lang bin/openrouter-bot-raspberry-pi/lang 86 | mkdir bin/openrouter-bot-raspberry-pi/logs 87 | ls -lha bin/* 88 | tar -cvf bin/openrouter-bot-windows.tar -C bin/openrouter-bot-windows/ openrouter-bot.exe .env config.yaml lang logs 89 | tar -cvf bin/openrouter-bot-linux.tar -C bin/openrouter-bot-linux/ openrouter-bot .env config.yaml lang logs 90 | tar -cvf bin/openrouter-bot-raspberry-pi.tar -C bin/openrouter-bot-raspberry-pi/ openrouter-bot .env config.yaml lang logs 91 | 92 | - name: Upload binaries for Windows 93 | if: ${{ github.event.inputs.Binary == 'true' }} 94 | uses: actions/upload-artifact@v4 95 | with: 96 | name: openrouter-bot-windows-${{ github.event.inputs.Version }} 97 | path: bin/openrouter-bot-windows.tar 98 | - name: Upload binaries for Linux 99 | if: ${{ github.event.inputs.Binary == 'true' }} 100 | uses: actions/upload-artifact@v4 101 | with: 102 | name: openrouter-bot-linux-${{ github.event.inputs.Version }} 103 | path: bin/openrouter-bot-linux.tar 104 | 105 | - name: Upload binaries for Raspberry Pi 106 | if: ${{ github.event.inputs.Binary == 'true' }} 107 | uses: actions/upload-artifact@v4 108 | with: 109 | name: openrouter-bot-raspberry-pi-${{ github.event.inputs.Version }} 110 | path: bin/openrouter-bot-raspberry-pi.tar 111 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "openrouter-bot/api" 7 | "openrouter-bot/config" 8 | "openrouter-bot/lang" 9 | "openrouter-bot/user" 10 | "strconv" 11 | "strings" 12 | 13 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 14 | "github.com/sashabaranov/go-openai" 15 | ) 16 | 17 | func main() { 18 | err := lang.LoadTranslations("./lang/") 19 | if err != nil { 20 | log.Fatalf("Error loading translations: %v", err) 21 | } 22 | 23 | manager, err := config.NewManager("./config.yaml") 24 | if err != nil { 25 | log.Fatalf("Error initializing config manager: %v", err) 26 | } 27 | 28 | conf := manager.GetConfig() 29 | 30 | bot, err := tgbotapi.NewBotAPI(conf.TelegramBotToken) 31 | if err != nil { 32 | log.Panic(err) 33 | } 34 | bot.Debug = false 35 | 36 | // Delete the webhook 37 | _, err = bot.Request(tgbotapi.DeleteWebhookConfig{}) 38 | if err != nil { 39 | log.Fatalf("Failed to delete webhook: %v", err) 40 | } 41 | 42 | // Now you can safely use getUpdates 43 | u := tgbotapi.NewUpdate(0) 44 | u.Timeout = 60 45 | 46 | updates := bot.GetUpdatesChan(u) 47 | 48 | // Set bot commands 49 | commands := []tgbotapi.BotCommand{ 50 | {Command: "start", Description: lang.Translate("description.start", conf.Lang)}, 51 | {Command: "help", Description: lang.Translate("description.help", conf.Lang)}, 52 | {Command: "get_models", Description: lang.Translate("description.getModels", conf.Lang)}, 53 | {Command: "set_model", Description: lang.Translate("description.setModel", conf.Lang)}, 54 | {Command: "reset", Description: lang.Translate("description.reset", conf.Lang)}, 55 | {Command: "stats", Description: lang.Translate("description.stats", conf.Lang)}, 56 | {Command: "stop", Description: lang.Translate("description.stop", conf.Lang)}, 57 | } 58 | _, err = bot.Request(tgbotapi.NewSetMyCommands(commands...)) 59 | if err != nil { 60 | log.Fatalf("Failed to set bot commands: %v", err) 61 | } 62 | 63 | clientOptions := openai.DefaultConfig(conf.OpenAIApiKey) 64 | clientOptions.BaseURL = conf.OpenAIBaseURL 65 | client := openai.NewClientWithConfig(clientOptions) 66 | 67 | userManager := user.NewUserManager("logs") 68 | 69 | for update := range updates { 70 | if update.Message == nil { 71 | continue 72 | } 73 | userStats := userManager.GetUser(update.SentFrom().ID, update.SentFrom().UserName, conf) 74 | //userStats.AddCost(0.0) 75 | if update.Message.IsCommand() { 76 | switch update.Message.Command() { 77 | case "start": 78 | msgText := lang.Translate("commands.start", conf.Lang) + lang.Translate("commands.help", conf.Lang) + lang.Translate("commands.start_end", conf.Lang) 79 | msg := tgbotapi.NewMessage(update.Message.Chat.ID, msgText) 80 | msg.ParseMode = "HTML" 81 | bot.Send(msg) 82 | case "help": 83 | msg := tgbotapi.NewMessage(update.Message.Chat.ID, lang.Translate("commands.help", conf.Lang)) 84 | msg.ParseMode = "HTML" 85 | bot.Send(msg) 86 | case "get_models": 87 | models, _ := api.GetFreeModels() 88 | if err != nil { 89 | fmt.Printf("Error: %v\n", err) 90 | return 91 | } 92 | // fmt.Println(models) 93 | text := lang.Translate("commands.getModels", conf.Lang) + models 94 | msg := tgbotapi.NewMessage(update.Message.Chat.ID, text) 95 | msg.ParseMode = tgbotapi.ModeMarkdown 96 | _, err := bot.Send(msg) 97 | if err != nil { 98 | fmt.Printf("Error: %v\n", err) 99 | return 100 | } 101 | case "set_model": 102 | args := update.Message.CommandArguments() 103 | argsArr := strings.Split(args, " ") 104 | msg := tgbotapi.NewMessage(update.Message.Chat.ID, conf.Model.ModelName) 105 | msg.ParseMode = tgbotapi.ModeMarkdown 106 | switch { 107 | case args == "default": 108 | conf.Model.ModelName = conf.Model.ModelNameDefault 109 | msg.Text = lang.Translate("commands.setModel", conf.Lang) + " `" + conf.Model.ModelName + "`" 110 | case args == "": 111 | msg.Text = lang.Translate("commands.noArgsModel", conf.Lang) 112 | case len(argsArr) > 1: 113 | msg.Text = lang.Translate("commands.noSpaceModel", conf.Lang) 114 | default: 115 | conf.Model.ModelName = argsArr[0] 116 | msg.Text = lang.Translate("commands.setModel", conf.Lang) + " `" + conf.Model.ModelName + "`" 117 | } 118 | bot.Send(msg) 119 | case "reset": 120 | args := update.Message.CommandArguments() 121 | msg := tgbotapi.NewMessage(update.Message.Chat.ID, "") 122 | if args == "system" { 123 | userStats.SystemPrompt = conf.SystemPrompt 124 | msg.Text = lang.Translate("commands.reset_system", conf.Lang) 125 | } else if args != "" { 126 | userStats.SystemPrompt = args 127 | msg.Text = lang.Translate("commands.reset_prompt", conf.Lang) + args + "." 128 | } else { 129 | userStats.ClearHistory() 130 | msg.Text = lang.Translate("commands.reset", conf.Lang) 131 | } 132 | bot.Send(msg) 133 | case "stats": 134 | userStats.CheckHistory(conf.MaxHistorySize, conf.MaxHistoryTime) 135 | countedUsage := strconv.FormatFloat(userStats.GetCurrentCost(conf.BudgetPeriod), 'f', 6, 64) 136 | todayUsage := strconv.FormatFloat(userStats.GetCurrentCost("daily"), 'f', 6, 64) 137 | monthUsage := strconv.FormatFloat(userStats.GetCurrentCost("monthly"), 'f', 6, 64) 138 | totalUsage := strconv.FormatFloat(userStats.GetCurrentCost("total"), 'f', 6, 64) 139 | messagesCount := strconv.Itoa(len(userStats.GetMessages())) 140 | 141 | var statsMessage string 142 | if userStats.CanViewStats(conf) { 143 | statsMessage = fmt.Sprintf( 144 | lang.Translate("commands.stats", conf.Lang), 145 | countedUsage, todayUsage, monthUsage, totalUsage, messagesCount) 146 | } else { 147 | statsMessage = fmt.Sprintf( 148 | lang.Translate("commands.stats_min", conf.Lang), messagesCount) 149 | } 150 | 151 | msg := tgbotapi.NewMessage(update.Message.Chat.ID, statsMessage) 152 | msg.ParseMode = "HTML" 153 | bot.Send(msg) 154 | 155 | case "stop": 156 | if userStats.CurrentStream != nil { 157 | userStats.CurrentStream.Close() 158 | msg := tgbotapi.NewMessage(update.Message.Chat.ID, lang.Translate("commands.stop", conf.Lang)) 159 | bot.Send(msg) 160 | } else { 161 | msg := tgbotapi.NewMessage(update.Message.Chat.ID, lang.Translate("commands.stop_err", conf.Lang)) 162 | bot.Send(msg) 163 | } 164 | } 165 | } else { 166 | go func(userStats *user.UsageTracker) { 167 | // Handle user message 168 | if userStats.HaveAccess(conf) { 169 | responseID := api.HandleChatGPTStreamResponse(bot, client, update.Message, conf, userStats) 170 | if conf.Model.Type == "openrouter" { 171 | userStats.GetUsageFromApi(responseID, conf) 172 | } 173 | } else { 174 | msg := tgbotapi.NewMessage(update.Message.Chat.ID, lang.Translate("budget_out", conf.Lang)) 175 | _, err := bot.Send(msg) 176 | if err != nil { 177 | log.Println(err) 178 | } 179 | } 180 | 181 | }(userStats) 182 | } 183 | } 184 | 185 | } 186 | -------------------------------------------------------------------------------- /api/openrouter.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "openrouter-bot/config" 12 | configs "openrouter-bot/config" 13 | "openrouter-bot/lang" 14 | "openrouter-bot/user" 15 | "strings" 16 | "time" 17 | 18 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 19 | "github.com/sashabaranov/go-openai" 20 | ) 21 | 22 | type Model struct { 23 | ID string `json:"id"` 24 | Description string `json:"description"` 25 | Pricing struct { 26 | Prompt string `json:"prompt"` 27 | } `json:"pricing"` 28 | } 29 | 30 | type APIResponse struct { 31 | Data []Model `json:"data"` 32 | } 33 | 34 | func GetFreeModels() (string, error) { 35 | manager, err := config.NewManager("./config.yaml") 36 | if err != nil { 37 | log.Fatalf("Error initializing config manager: %v", err) 38 | } 39 | conf := manager.GetConfig() 40 | 41 | resp, err := http.Get(conf.OpenAIBaseURL + "/models") 42 | if err != nil { 43 | return "", fmt.Errorf("error get models: %v", err) 44 | } 45 | defer resp.Body.Close() 46 | 47 | body, err := io.ReadAll(resp.Body) 48 | if err != nil { 49 | return "", fmt.Errorf("error read response: %v", err) 50 | } 51 | 52 | var apiResponse APIResponse 53 | err = json.Unmarshal(body, &apiResponse) 54 | if err != nil { 55 | return "", fmt.Errorf("error parse json: %v", err) 56 | } 57 | 58 | var result strings.Builder 59 | for _, model := range apiResponse.Data { 60 | // Filter by price 61 | if model.Pricing.Prompt == "0" { 62 | // escapedDesc := strings.ReplaceAll(model.Description, "*", "\\*") 63 | // escapedDesc = strings.ReplaceAll(escapedDesc, "_", "\\_") 64 | // result.WriteString(fmt.Sprintf("%s - %s\n", model.ID, escapedDesc)) 65 | result.WriteString(fmt.Sprintf("➡ `%s`\n", model.ID)) 66 | // result.WriteString(fmt.Sprintf("➡ `/set_model %s`\n", model.ID)) 67 | } 68 | } 69 | return result.String(), nil 70 | } 71 | 72 | func HandleChatGPTStreamResponse(bot *tgbotapi.BotAPI, client *openai.Client, message *tgbotapi.Message, config *config.Config, user *user.UsageTracker) string { 73 | ctx := context.Background() 74 | user.CheckHistory(config.MaxHistorySize, config.MaxHistoryTime) 75 | user.LastMessageTime = time.Now() 76 | 77 | err := lang.LoadTranslations("./lang/") 78 | if err != nil { 79 | log.Fatalf("Error loading translations: %v", err) 80 | } 81 | 82 | manager, err := configs.NewManager("./config.yaml") 83 | if err != nil { 84 | log.Fatalf("Error initializing config manager: %v", err) 85 | } 86 | 87 | conf := manager.GetConfig() 88 | 89 | // Send a loading message with animation points 90 | loadMessage := lang.Translate("loadText", conf.Lang) 91 | errorMessage := lang.Translate("errorText", conf.Lang) 92 | 93 | processingMsg := tgbotapi.NewMessage(message.Chat.ID, loadMessage) 94 | sentMsg, err := bot.Send(processingMsg) 95 | if err != nil { 96 | log.Printf("Failed to send processing message: %v", err) 97 | return "" 98 | } 99 | lastMessageID := sentMsg.MessageID 100 | 101 | // Goroutine for animation points 102 | stopAnimation := make(chan bool) 103 | go func() { 104 | dots := []string{"", ".", "..", "...", "..", "."} 105 | i := 0 106 | for { 107 | select { 108 | case <-stopAnimation: 109 | return 110 | default: 111 | text := fmt.Sprintf("%s%s", loadMessage, dots[i]) 112 | editMsg := tgbotapi.NewEditMessageText(message.Chat.ID, lastMessageID, text) 113 | _, err := bot.Send(editMsg) 114 | if err != nil { 115 | log.Printf("Failed to update processing message: %v", err) 116 | } 117 | 118 | i = (i + 1) % len(dots) 119 | time.Sleep(500 * time.Millisecond) 120 | } 121 | } 122 | }() 123 | 124 | messages := []openai.ChatCompletionMessage{ 125 | { 126 | Role: openai.ChatMessageRoleSystem, 127 | Content: user.SystemPrompt, 128 | }, 129 | } 130 | 131 | for _, msg := range user.GetMessages() { 132 | messages = append(messages, openai.ChatCompletionMessage{ 133 | Role: msg.Role, 134 | Content: msg.Content, 135 | }) 136 | } 137 | 138 | if config.Vision == "true" { 139 | messages = append(messages, addVisionMessage(bot, message, config)) 140 | } else { 141 | messages = append(messages, openai.ChatCompletionMessage{ 142 | Role: openai.ChatMessageRoleUser, 143 | Content: message.Text, 144 | }) 145 | } 146 | 147 | req := openai.ChatCompletionRequest{ 148 | Model: config.Model.ModelName, 149 | FrequencyPenalty: float32(config.Model.FrequencyPenalty), 150 | PresencePenalty: float32(config.Model.PresencePenalty), 151 | Temperature: float32(config.Model.Temperature), 152 | TopP: float32(config.Model.TopP), 153 | MaxTokens: config.MaxTokens, 154 | Messages: messages, 155 | Stream: true, 156 | } 157 | 158 | // Error handling and sending a response message 159 | stream, err := client.CreateChatCompletionStream(ctx, req) 160 | if err != nil { 161 | fmt.Printf("ChatCompletionStream error: %v\n", err) 162 | stopAnimation <- true 163 | bot.Send(tgbotapi.NewEditMessageText(message.Chat.ID, lastMessageID, errorMessage)) 164 | return "" 165 | } 166 | defer stream.Close() 167 | user.CurrentStream = stream 168 | 169 | // Stop the animation when we start receiving a response 170 | stopAnimation <- true 171 | var messageText string 172 | responseID := "" 173 | log.Printf("User: " + user.UserName + " Stream response. ") 174 | 175 | for { 176 | response, err := stream.Recv() 177 | if responseID == "" { 178 | responseID = response.ID 179 | } 180 | if errors.Is(err, io.EOF) { 181 | fmt.Println("\nStream finished, response ID:", responseID) 182 | user.AddMessage(openai.ChatMessageRoleUser, message.Text) 183 | user.AddMessage(openai.ChatMessageRoleAssistant, messageText) 184 | editMsg := tgbotapi.NewEditMessageText(message.Chat.ID, lastMessageID, messageText) 185 | editMsg.ParseMode = tgbotapi.ModeMarkdown 186 | _, err := bot.Send(editMsg) 187 | if err != nil { 188 | log.Printf("Failed to edit message: %v", err) 189 | } 190 | user.CurrentStream = nil 191 | return responseID 192 | } 193 | 194 | if err != nil { 195 | fmt.Printf("\nStream error: %v\n", err) 196 | msg := tgbotapi.NewMessage(message.Chat.ID, err.Error()) 197 | msg.ParseMode = tgbotapi.ModeMarkdown 198 | bot.Send(msg) 199 | user.CurrentStream = nil 200 | return responseID 201 | } 202 | 203 | if len(response.Choices) > 0 { 204 | messageText += response.Choices[0].Delta.Content 205 | editMsg := tgbotapi.NewEditMessageText(message.Chat.ID, lastMessageID, messageText) 206 | editMsg.ParseMode = tgbotapi.ModeMarkdown 207 | _, err := bot.Send(editMsg) 208 | if err != nil { 209 | continue 210 | } 211 | } else { 212 | log.Printf("Received empty response choices") 213 | continue 214 | } 215 | } 216 | } 217 | 218 | func addVisionMessage(bot *tgbotapi.BotAPI, message *tgbotapi.Message, config *config.Config) openai.ChatCompletionMessage { 219 | if len(message.Photo) > 0 { 220 | // Assuming you want the largest photo size 221 | photoSize := message.Photo[len(message.Photo)-1] 222 | fileID := photoSize.FileID 223 | 224 | // Download the photo 225 | file, err := bot.GetFile(tgbotapi.FileConfig{FileID: fileID}) 226 | if err != nil { 227 | log.Printf("Error getting file: %v", err) 228 | return openai.ChatCompletionMessage{ 229 | Role: openai.ChatMessageRoleUser, 230 | Content: message.Text, 231 | } 232 | } 233 | 234 | // Access the file URL 235 | fileURL := file.Link(bot.Token) 236 | fmt.Println("Photo URL:", fileURL) 237 | if message.Text == "" { 238 | message.Text = config.VisionPrompt 239 | } 240 | 241 | return openai.ChatCompletionMessage{ 242 | Role: openai.ChatMessageRoleUser, 243 | MultiContent: []openai.ChatMessagePart{ 244 | { 245 | Type: openai.ChatMessagePartTypeText, 246 | Text: message.Text, 247 | }, 248 | { 249 | Type: openai.ChatMessagePartTypeImageURL, 250 | ImageURL: &openai.ChatMessageImageURL{ 251 | URL: fileURL, 252 | Detail: openai.ImageURLDetail(config.VisionDetails), 253 | }, 254 | }, 255 | }, 256 | } 257 | } else { 258 | return openai.ChatCompletionMessage{ 259 | Role: openai.ChatMessageRoleUser, 260 | Content: message.Text, 261 | } 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /user/usage_tracker.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "openrouter-bot/config" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | // NewUsageTracker creates a new UsageTracker. 16 | func NewUsageTracker(userID, userName, logsDir string, conf *config.Config) *UsageTracker { 17 | usageTracker := &UsageTracker{ 18 | UserID: userID, 19 | UserName: userName, 20 | LogsDir: logsDir, 21 | Usage: &UserUsage{ // Initialize as pointer 22 | UsageHistory: UsageHist{ 23 | ChatCost: make(map[string]float64), 24 | }, 25 | }, 26 | History: History{ 27 | messages: make([]Message, 0), 28 | }, 29 | SystemPrompt: conf.SystemPrompt, 30 | } 31 | 32 | err := usageTracker.loadUsage() 33 | if err != nil { 34 | log.Printf("Error loading usage for user %s: %v", userID, err) 35 | } 36 | 37 | return usageTracker 38 | } 39 | 40 | func (ut *UsageTracker) HaveAccess(conf *config.Config) bool { 41 | for _, id := range conf.AdminChatIDs { 42 | idStr := fmt.Sprintf("%d", id) 43 | if ut.UserID == idStr { 44 | log.Println("Admin") 45 | return true 46 | } 47 | } 48 | 49 | for _, id := range conf.AllowedUserChatIDs { 50 | idStr := fmt.Sprintf("%d", id) 51 | if ut.UserID == idStr { 52 | currentCost := ut.GetCurrentCost(conf.BudgetPeriod) 53 | 54 | if float64(conf.UserBudget) > currentCost { 55 | log.Println("ID:", idStr, " UserBudget:", conf.UserBudget, " CurrentCost:", currentCost) 56 | return true 57 | } 58 | return false 59 | } 60 | } 61 | currentCost := ut.GetCurrentCost(conf.BudgetPeriod) 62 | 63 | if float64(conf.GuestBudget) > currentCost { 64 | log.Println("ID:", ut.UserID, " GuestBudget:", conf.GuestBudget, " CurrentCost:", currentCost) 65 | return true 66 | } 67 | log.Printf("UserID: %s, AdminChatIDs: %v, AllowedUserChatIDs: %v", ut.UserID, conf.AdminChatIDs, conf.AllowedUserChatIDs) 68 | log.Printf("UserBudget: %f, GuestBudget: %f, CurrentCost: %f", float64(conf.UserBudget), float64(conf.GuestBudget), currentCost) 69 | return false 70 | 71 | } 72 | 73 | func (ut *UsageTracker) GetUserRole(conf *config.Config) string { 74 | for _, id := range conf.AdminChatIDs { 75 | idStr := fmt.Sprintf("%d", id) 76 | if ut.UserID == idStr { 77 | return "ADMIN" 78 | } 79 | } 80 | for _, id := range conf.AllowedUserChatIDs { 81 | idStr := fmt.Sprintf("%d", id) 82 | if ut.UserID == idStr { 83 | return "USER" 84 | } 85 | } 86 | return "GUEST" 87 | } 88 | 89 | func (ut *UsageTracker) CanViewStats(conf *config.Config) bool { 90 | userRole := ut.GetUserRole(conf) 91 | return userRole == "ADMIN" || (conf.StatsMinRole == "USER" && userRole != "GUEST") 92 | } 93 | 94 | // loadOrCreateUsage loads or creates the usage file for a user 95 | func (ut *UsageTracker) loadOrCreateUsage() error { 96 | userFile := filepath.Join(ut.LogsDir, ut.UserID+".json") 97 | if _, err := os.Stat(userFile); os.IsNotExist(err) { 98 | ut.UsageMu.Lock() // Added lock 99 | ut.Usage = &UserUsage{ // Initialize as a pointer 100 | UserName: ut.UserName, 101 | UsageHistory: UsageHist{ 102 | ChatCost: make(map[string]float64), 103 | }, 104 | } 105 | ut.UsageMu.Unlock() // Added unlock 106 | err := ut.saveUsage() 107 | if err != nil { 108 | return err 109 | } 110 | } else { 111 | data, err := os.ReadFile(userFile) 112 | if err != nil { 113 | log.Println(err) 114 | return err 115 | } 116 | ut.UsageMu.Lock() // Added lock 117 | err = json.Unmarshal(data, ut.Usage) 118 | ut.UsageMu.Unlock() // Added unlock 119 | if err != nil { 120 | log.Println(err) 121 | return err 122 | } 123 | } 124 | return nil 125 | } 126 | 127 | // saveUsage saves the user usage to a JSON file. 128 | func (ut *UsageTracker) saveUsage() error { 129 | ut.FileMu.Lock() 130 | defer ut.FileMu.Unlock() 131 | 132 | ut.UsageMu.Lock() 133 | data, err := json.MarshalIndent(ut.Usage, "", " ") 134 | ut.UsageMu.Unlock() 135 | 136 | if err != nil { 137 | log.Printf("Error marshalling usage data for user %s: %v", ut.UserID, err) 138 | return fmt.Errorf("error marshalling usage data: %w", err) 139 | } 140 | 141 | filename := fmt.Sprintf("%s/%s.json", ut.LogsDir, ut.UserID) 142 | err = os.WriteFile(filename, data, 0644) // Use os.WriteFile instead of ioutil.WriteFile 143 | if err != nil { 144 | log.Printf("Error writing usage data to file for user %s: %v", ut.UserID, err) 145 | return fmt.Errorf("error writing usage data to file: %w", err) 146 | } 147 | 148 | return nil 149 | } 150 | 151 | // loadUsage loads the user usage from a JSON file. 152 | func (ut *UsageTracker) loadUsage() error { 153 | filePath := filepath.Join(ut.LogsDir, ut.UserID+".json") 154 | data, err := os.ReadFile(filePath) 155 | if err != nil { 156 | if os.IsNotExist(err) { 157 | log.Printf("File not found for user %s, creating new usage data.", ut.UserID) 158 | ut.UsageMu.Lock() 159 | ut.Usage = &UserUsage{ // Initialize as pointer 160 | UsageHistory: UsageHist{ 161 | ChatCost: make(map[string]float64), 162 | }, 163 | } 164 | ut.UsageMu.Unlock() 165 | return ut.saveUsage() 166 | } 167 | log.Printf("Error reading usage data from file for user %s: %v", ut.UserID, err) 168 | return fmt.Errorf("error reading usage data from file: %w", err) 169 | } 170 | 171 | ut.UsageMu.Lock() 172 | var usage UserUsage 173 | err = json.Unmarshal(data, &usage) // Unmarshal into temporary variable 174 | if err != nil { 175 | ut.UsageMu.Unlock() 176 | log.Printf("Error unmarshalling usage data for user %s: %v", ut.UserID, err) 177 | return fmt.Errorf("error unmarshalling usage data: %w", err) 178 | } 179 | ut.Usage = &usage // Assign pointer to unmarshaled data 180 | ut.UsageMu.Unlock() 181 | 182 | return nil 183 | } 184 | 185 | // AddCost Добавляет стоимость к текущему использованию и сохраняет данные 186 | func (ut *UsageTracker) AddCost(cost float64) { 187 | ut.UsageMu.Lock() 188 | 189 | today := time.Now().Format("2006-01-02") 190 | if ut.Usage.UsageHistory.ChatCost == nil { // Добавлена проверка на nil 191 | ut.Usage.UsageHistory.ChatCost = make(map[string]float64) 192 | } 193 | ut.Usage.UsageHistory.ChatCost[today] += cost 194 | 195 | ut.UsageMu.Unlock() // Переместил Unlock после вызова saveUsage() 196 | 197 | if err := ut.saveUsage(); err != nil { 198 | log.Printf("Failed to save usage after adding cost for user %s: %v", ut.UserID, err) 199 | } 200 | } 201 | 202 | // GetCurrentCost returns the current cost based on the specified period. 203 | func (ut *UsageTracker) GetCurrentCost(period string) float64 { 204 | ut.UsageMu.Lock() 205 | defer ut.UsageMu.Unlock() 206 | 207 | today := time.Now().Format("2006-01-02") 208 | var cost float64 209 | var err error 210 | 211 | switch period { 212 | case "daily": 213 | cost = calculateCostForDay(ut.Usage.UsageHistory.ChatCost, today) 214 | case "monthly": 215 | cost, err = calculateCostForMonth(ut.Usage.UsageHistory.ChatCost, today) 216 | if err != nil { 217 | log.Printf("Error calculating monthly cost for user %s: %v", ut.UserID, err) 218 | return 0.0 // Или другое значение по умолчанию 219 | } 220 | case "total": 221 | cost = calculateTotalCost(ut.Usage.UsageHistory.ChatCost) 222 | default: 223 | log.Printf("Invalid period: %s. Valid periods are 'daily', 'monthly', 'total'.", period) 224 | return 0.0 225 | } 226 | 227 | return cost 228 | } 229 | 230 | // calculateCostForDay calculates the cost for a specific day from usage history 231 | func calculateCostForDay(chatCost map[string]float64, day string) float64 { 232 | if cost, ok := chatCost[day]; ok { 233 | return cost 234 | } 235 | return 0.0 236 | } 237 | 238 | // calculateCostForMonth calculates the cost for the current month from usage history 239 | func calculateCostForMonth(chatCost map[string]float64, today string) (float64, error) { 240 | cost := 0.0 241 | month := today[:7] // Получаем год и месяц в формате "YYYY-MM" 242 | 243 | for date, dailyCost := range chatCost { 244 | if strings.HasPrefix(date, month) { 245 | cost += dailyCost 246 | } 247 | } 248 | 249 | return cost, nil 250 | } 251 | 252 | // calculateTotalCost calculates the total cost from usage history 253 | func calculateTotalCost(chatCost map[string]float64) float64 { 254 | totalCost := 0.0 255 | for _, cost := range chatCost { 256 | totalCost += cost 257 | } 258 | return totalCost 259 | } 260 | 261 | // GetUsageFromApi Get cost of current generation 262 | func (ut *UsageTracker) GetUsageFromApi(id string, conf *config.Config) error { 263 | url := fmt.Sprintf("https://openrouter.ai/api/v1/generation?id=%s", id) 264 | 265 | req, err := http.NewRequest("GET", url, nil) 266 | if err != nil { 267 | log.Printf("Error creating request for user %s: %v", ut.UserID, err) 268 | return fmt.Errorf("error creating request: %w", err) 269 | } 270 | 271 | bearer := fmt.Sprintf("Bearer %s", conf.OpenAIApiKey) 272 | req.Header.Add("Authorization", bearer) 273 | 274 | client := &http.Client{} 275 | resp, err := client.Do(req) 276 | if err != nil { 277 | log.Printf("Error sending request for user %s: %v", ut.UserID, err) 278 | return fmt.Errorf("error sending request: %w", err) 279 | } 280 | defer resp.Body.Close() 281 | 282 | var generationResponse GenerationResponse 283 | err = json.NewDecoder(resp.Body).Decode(&generationResponse) 284 | if err != nil { 285 | log.Printf("Error decoding response for user %s: %v", ut.UserID, err) 286 | return fmt.Errorf("error decoding response: %w", err) 287 | } 288 | 289 | fmt.Printf("Total Cost for user %s: %.6f\n", ut.UserID, generationResponse.Data.TotalCost) 290 | ut.AddCost(generationResponse.Data.TotalCost) 291 | return nil 292 | } 293 | --------------------------------------------------------------------------------