├── 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 |
--------------------------------------------------------------------------------