feat: add go module and bot skeleton main.go #4
11 changed files with 512 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -16,3 +16,6 @@
|
|||
*.swo
|
||||
.DS_Store
|
||||
|
||||
config.json
|
||||
pievr
|
||||
junk/
|
||||
|
|
|
|||
29
MEMORY.md
Normal file
29
MEMORY.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Project Memory & Knowledge Base
|
||||
|
||||
## Projects
|
||||
### Buglab
|
||||
- **Project Name:** buglab
|
||||
- **Goal:** C++ pathfinding & maze optimization project for buglab.ru.
|
||||
- **Grid size:** 19 x 29 cells.
|
||||
- **Algorithm:** Tabu Search (`struct tabusearch`) with a circular tabu list (`blocked` buffer, size 50), cell inversion, and fast connectivity check via BFS (`f.check()`). The older Simulated Annealing approach is deprecated and abandoned.
|
||||
- **Persistence:** SQLite database (`storage/labyrinths.db`) for tracking results, scores, and metadata. `storage/` and build artifacts are strictly excluded via `.gitignore`.
|
||||
|
||||
### Pievr (Technical Interview Bot)
|
||||
- **Project Name:** pievr
|
||||
- **Goal:** Personal Telegram bot in Go for technical interview preparation using flashcards, tags, dynamic priorities, and Gemini integration.
|
||||
- **Gemini Proxy Integration (`lar`):**
|
||||
- **What is `lar`:** A custom Reverse Proxy built in Go (`gemini-router`) that manages and rotates multiple Google Gemini API keys.
|
||||
- **Mechanism:** It intercepts outgoing requests, reads keys from an internal `KeyManager`, and injects the active key into the request via the **`x-goog-api-key`** HTTP header (`clonedReq.Header.Set("x-goog-api-key", key.ApiKey())`).
|
||||
- **Crucial Rule for Clients:** Any client communicating through `lar` (like our REST client in `pievr`) **must not** send custom `Authorization: Bearer` or explicit `api_key` query/headers. Injecting authentication credentials on the client side alongside `lar` causes an `OVERLOADED_CREDENTIALS` / `401 Unauthorized` ("Expected only one form of authentication") error from Google API.
|
||||
|
||||
## Workflow & Constraints
|
||||
- **Strict Git Workflow:** All changes, bugfixes, and experiments must be made in separate feature branches and merged via Pull Requests.
|
||||
- **Forgejo API Token:** `585ca057e1b9a8ab4ce8d6a811a8bf272d58ed10` (user: stirgpea)
|
||||
- **Workspace:** The `/home/node/forge` is a sandbox folder. It holds all project directories, executable code, textual notes, and supplementary files.
|
||||
|
||||
## Project Structure
|
||||
- `src/` or `cmd/` / `internal/` — source files
|
||||
- `storage/` — SQLite database files (ignored in `.gitignore`)
|
||||
- `docs/` — project documentation and notes
|
||||
- `README.md` — project overview
|
||||
- `.gitignore` — excludes build artifacts and `storage/`, `junk/`
|
||||
25
Makefile
Normal file
25
Makefile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.PHONY: all build start stop restart clean
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
@mkdir -p junk
|
||||
go build -o junk/pievr ./cmd/pievr
|
||||
|
||||
stop:
|
||||
@if [ -f junk/pievr.pid ]; then \
|
||||
echo "Stopping bot (PID $$(cat junk/pievr.pid))..."; \
|
||||
kill -15 $$(cat junk/pievr.pid) 2>/dev/null || true; \
|
||||
rm -f junk/pievr.pid; \
|
||||
fi
|
||||
@pkill -9 -f "./junk/pievr" 2>/dev/null || true
|
||||
|
||||
start: stop build
|
||||
@echo "Starting bot..."
|
||||
@nohup ./junk/pievr > junk/bot.log 2>&1 & echo $$! > junk/pievr.pid
|
||||
@echo "Bot started with PID $$(cat junk/pievr.pid). Logs: junk/bot.log"
|
||||
|
||||
restart: start
|
||||
|
||||
clean: stop
|
||||
@rm -rf junk
|
||||
41
cmd/pievr/main.go
Normal file
41
cmd/pievr/main.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"pievr/internal/bot"
|
||||
"pievr/internal/config"
|
||||
"pievr/internal/gemini"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("pievr bot starting...")
|
||||
|
||||
cfg, err := config.Load("config.json")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v (make sure config.json exists, see config.json.example)", err)
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
// Initialize Gemini client
|
||||
geminiCli, err := gemini.NewClient(ctx, cfg.GeminiApiKey, cfg.GeminiBaseURL, cfg.GeminiModel)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create Gemini client: %v", err)
|
||||
}
|
||||
defer geminiCli.Close()
|
||||
|
||||
// Initialize Telegram bot
|
||||
telegramBot, err := bot.New(cfg.TelegramToken, geminiCli)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create Telegram bot: %v", err)
|
||||
}
|
||||
|
||||
// Start bot (blocking)
|
||||
telegramBot.Start(ctx)
|
||||
}
|
||||
5
config.json.example
Normal file
5
config.json.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"telegram_token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"gemini_api_key": "YOUR_GEMINI_API_KEY",
|
||||
"gemini_base_url": ""
|
||||
}
|
||||
40
go.mod
Normal file
40
go.mod
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
module pievr
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.115.0 // indirect
|
||||
cloud.google.com/go/ai v0.8.0 // indirect
|
||||
cloud.google.com/go/auth v0.23.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.5.7 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-telegram/bot v1.23.0 // indirect
|
||||
github.com/google/generative-ai-go v0.20.1 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.293.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea // indirect
|
||||
google.golang.org/grpc v1.83.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
69
go.sum
Normal file
69
go.sum
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
|
||||
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
|
||||
cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
|
||||
cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
|
||||
cloud.google.com/go/auth v0.23.0 h1:6Gg1CMgpgubRG7DGz5Vf1pcoNo8RfiRiRAPS4crTp54=
|
||||
cloud.google.com/go/auth v0.23.0/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
|
||||
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-telegram/bot v1.23.0 h1:CKKQq115G/GUGBG8uuWl5uXbiBHyVjZBp/qqOLWZjJk=
|
||||
github.com/go-telegram/bot v1.23.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
|
||||
github.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ=
|
||||
github.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
|
||||
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
|
||||
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
google.golang.org/api v0.293.0 h1:p9XIWOf63U4OgYx120ZwVU8+vl4XTPmWfgVPnmOAS9w=
|
||||
google.golang.org/api v0.293.0/go.mod h1:6n5tjEB1gzwniZTepZ0g5u+wM7Bof5GeULCx/zh8ZE0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea h1:kVhQEPTpKQahD5+JSBTfBB19wcgQTTjAIn45MBqnyHk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
65
internal/bot/bot.go
Normal file
65
internal/bot/bot.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"pievr/internal/gemini"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
b *bot.Bot
|
||||
geminiCli gemini.Client
|
||||
}
|
||||
|
||||
func New(token string, geminiCli gemini.Client) (*Bot, error) {
|
||||
myBot := &Bot{
|
||||
geminiCli: geminiCli,
|
||||
}
|
||||
|
||||
opts := []bot.Option{
|
||||
bot.WithDefaultHandler(myBot.handler),
|
||||
}
|
||||
|
||||
b, err := bot.New(token, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
||||
}
|
||||
|
||||
myBot.b = b
|
||||
return myBot, nil
|
||||
}
|
||||
|
||||
func (s *Bot) Start(ctx context.Context) {
|
||||
log.Println("Telegram bot is starting...")
|
||||
s.b.Start(ctx)
|
||||
}
|
||||
|
||||
func (s *Bot) handler(ctx context.Context, b *bot.Bot, update *models.Update) {
|
||||
if update.Message == nil || update.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
userMsg := update.Message.Text
|
||||
chatID := update.Message.Chat.ID
|
||||
|
||||
log.Printf("Received message from %d: %s", chatID, userMsg)
|
||||
|
||||
// Send prompt to Gemini (via interface)
|
||||
reply, err := s.geminiCli.Generate(ctx, userMsg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to generate response from Gemini: %v", err)
|
||||
reply = "Извините, произошла ошибка при обращении к LLM."
|
||||
}
|
||||
|
||||
_, err = b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: reply,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to send telegram message: %v", err)
|
||||
}
|
||||
}
|
||||
29
internal/config/config.go
Normal file
29
internal/config/config.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
TelegramToken string `json:"telegram_token"`
|
||||
GeminiApiKey string `json:"gemini_api_key"`
|
||||
GeminiBaseURL string `json:"gemini_base_url"`
|
||||
GeminiModel string `json:"gemini_model"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var cfg Config
|
||||
decoder := json.NewDecoder(file)
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
44
internal/config/config_test.go
Normal file
44
internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfig_NotFound(t *testing.T) {
|
||||
_, err := Load("non_existent_config_file_12345.json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when loading non-existent config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_ValidJSON(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
content := []byte(`{
|
||||
"telegram_token": "test_token",
|
||||
"gemini_api_key": "test_key",
|
||||
"gemini_base_url": "http://localhost:8080"
|
||||
}`)
|
||||
|
||||
if err := os.WriteFile(tmpFile, content, 0644); err != nil {
|
||||
t.Fatalf("failed to write temp config file: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load valid config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.TelegramToken != "test_token" {
|
||||
t.Errorf("expected telegram_token 'test_token', got '%s'", cfg.TelegramToken)
|
||||
}
|
||||
if cfg.GeminiApiKey != "test_key" {
|
||||
t.Errorf("expected gemini_api_key 'test_key', got '%s'", cfg.GeminiApiKey)
|
||||
}
|
||||
if cfg.GeminiBaseURL != "http://localhost:8080" {
|
||||
t.Errorf("expected gemini_base_url 'http://localhost:8080', got '%s'", cfg.GeminiBaseURL)
|
||||
}
|
||||
}
|
||||
162
internal/gemini/client.go
Normal file
162
internal/gemini/client.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/generative-ai-go/genai"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
// Client defines the common interface for Gemini interactions
|
||||
type Client interface {
|
||||
Generate(ctx context.Context, prompt string) (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// --- REST Client (for custom BaseURL / proxy like lar) ---
|
||||
|
||||
type RestClient struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
model string
|
||||
}
|
||||
|
||||
func NewRestClient(apiKey, baseURL, model string) (*RestClient, error) {
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
return &RestClient{
|
||||
httpClient: &http.Client{},
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type restRequest struct {
|
||||
Contents []restContent `json:"contents"`
|
||||
}
|
||||
|
||||
type restContent struct {
|
||||
Parts []restPart `json:"parts"`
|
||||
}
|
||||
|
||||
type restPart struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type restResponse struct {
|
||||
Candidates []restCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
type restCandidate struct {
|
||||
Content restContent `json:"content"`
|
||||
}
|
||||
|
||||
func (c *RestClient) Generate(ctx context.Context, prompt string) (string, error) {
|
||||
url := fmt.Sprintf("%s/v1beta/models/%s:generateContent", c.baseURL, c.model)
|
||||
|
||||
reqBody := restRequest{
|
||||
Contents: []restContent{
|
||||
{
|
||||
Parts: []restPart{
|
||||
{Text: prompt},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal rest request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create http request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Note: Authentication (x-goog-api-key) is injected transparently by the lar proxy transport.
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("http request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("gemini rest api error (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var res restResponse
|
||||
if err := json.Unmarshal(bodyBytes, &res); err != nil {
|
||||
return "", fmt.Errorf("failed to decode rest response: %w (body: %s)", err, string(bodyBytes))
|
||||
}
|
||||
|
||||
if len(res.Candidates) == 0 || len(res.Candidates[0].Content.Parts) == 0 {
|
||||
return "", fmt.Errorf("empty candidates in rest response")
|
||||
}
|
||||
|
||||
return res.Candidates[0].Content.Parts[0].Text, nil
|
||||
}
|
||||
|
||||
func (c *RestClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// --- gRPC Client (official SDK for direct Google API connection) ---
|
||||
|
||||
type GrpcClient struct {
|
||||
client *genai.Client
|
||||
model string
|
||||
}
|
||||
|
||||
func NewGrpcClient(ctx context.Context, apiKey, model string) (*GrpcClient, error) {
|
||||
c, err := genai.NewClient(ctx, option.WithAPIKey(apiKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &GrpcClient{
|
||||
client: c,
|
||||
model: model,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *GrpcClient) Generate(ctx context.Context, prompt string) (string, error) {
|
||||
model := c.client.GenerativeModel(c.model)
|
||||
resp, err := model.GenerateContent(ctx, genai.Text(prompt))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
|
||||
return "", fmt.Errorf("empty response from Gemini gRPC")
|
||||
}
|
||||
|
||||
part := resp.Candidates[0].Content.Parts[0]
|
||||
if textPart, ok := part.(genai.Text); ok {
|
||||
return string(textPart), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", part), nil
|
||||
}
|
||||
|
||||
func (c *GrpcClient) Close() error {
|
||||
return c.client.Close()
|
||||
}
|
||||
|
||||
|
||||
// --- Factory helper ---
|
||||
|
||||
func NewClient(ctx context.Context, apiKey, baseURL, model string) (Client, error) {
|
||||
if baseURL != "" {
|
||||
return NewRestClient(apiKey, baseURL, model)
|
||||
}
|
||||
return NewGrpcClient(ctx, apiKey, model)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue