From a714c2eabb3c624f8bd5820d384e91405a35fb8b Mon Sep 17 00:00:00 2001 From: Gergely Radics Date: Thu, 21 Aug 2025 12:33:53 +0200 Subject: [PATCH 1/4] feat: added telegram client with image handling --- cmd/client-telegram/main.go | 131 ++++++++++++++++++++++++++++++++++++ go.mod | 3 +- go.sum | 2 + internal/ai/gemini/logic.go | 5 +- pkg/httpBotter/logic.go | 92 +++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 cmd/client-telegram/main.go create mode 100644 pkg/httpBotter/logic.go diff --git a/cmd/client-telegram/main.go b/cmd/client-telegram/main.go new file mode 100644 index 0000000..2131d56 --- /dev/null +++ b/cmd/client-telegram/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "fmt" + "hairy-botter/pkg/httpBotter" + "io" + "net/http" + "os" + "os/signal" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// Send any text message to the bot after the bot has been started + +func main() { + token := os.Getenv("BOT_TOKEN") + if token == "" { + fmt.Println("BOT_TOKEN must be set") + + os.Exit(1) + return + } + + aiSrv := os.Getenv("AI_SERVICE") + if aiSrv == "" { + aiSrv = "http://127.0.0.1:8080" + } + + l := New(aiSrv) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + opts := []bot.Option{ + bot.WithDefaultHandler(l.Handler), + } + + b, err := bot.New(token, opts...) + if err != nil { + fmt.Println(err) + + os.Exit(1) + return + } + + b.Start(ctx) +} + +type Logic struct { + httpB *httpBotter.Logic +} + +func New(baseURL string) *Logic { + return &Logic{ + httpB: httpBotter.New(baseURL), + } +} + +// Handler . +func (l *Logic) Handler(ctx context.Context, b *bot.Bot, update *models.Update) { + var payload []byte + msg := update.Message.Text + + if len(update.Message.Photo) > 0 { + highResImg := biggestImage(update.Message.Photo) + fmt.Println("photo file ID:", highResImg.FileID) + fmt.Printf("photo info: W: %d, H: %d, Size: %d\n", highResImg.Width, highResImg.Height, highResImg.FileSize) + fmt.Println("caption:", update.Message.Caption) + f, err := b.GetFile(ctx, &bot.GetFileParams{ + FileID: highResImg.FileID, + }) + if err != nil { + fmt.Println("error getting file:", err) + return + } + + // Download the file + dlURL := b.FileDownloadLink(f) + resp, err := http.Get(dlURL) + if err != nil { + fmt.Println("error downloading file:", err) + return + } + defer func() { _ = resp.Body.Close() }() + + payload, err = io.ReadAll(resp.Body) + if err != nil { + fmt.Println("error reading file:", err) + return + } + + if update.Message.Caption != "" { + msg = update.Message.Caption + } + } + + fmt.Println("Sending message to AI service:", msg) + res, err := l.httpB.Send(fmt.Sprintf("tg-%d", update.Message.Chat.ID), msg, payload) + if err != nil { + fmt.Println("error sending message to AI service:", err) + return + } + + fmt.Println("AI service response:", res) + _, err = b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: res, + }) + if err != nil { + fmt.Println("error sending response back to Telegram:", err) + return + } + +} + +func biggestImage(photos []models.PhotoSize) models.PhotoSize { + if len(photos) == 0 { + return models.PhotoSize{} + } + + biggest := photos[0] + for _, photo := range photos { + if photo.FileSize > biggest.FileSize { + biggest = photo + } + } + + return biggest +} diff --git a/go.mod b/go.mod index a48c06f..dfb6ed4 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.24.3 require ( github.com/briandowns/spinner v1.23.2 github.com/go-chi/chi/v5 v5.2.1 + github.com/go-telegram/bot v1.17.0 github.com/mark3labs/mcp-go v0.29.1-0.20250521213157-f99e5472f312 + github.com/philippgille/chromem-go v0.7.0 google.golang.org/genai v1.5.0 ) @@ -22,7 +24,6 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.8 // indirect - github.com/philippgille/chromem-go v0.7.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/go.sum b/go.sum index e913f21..51c6905 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-telegram/bot v1.17.0 h1:Hs0kGxSj97QFqOQP0zxduY/4tSx8QDzvNI9uVRS+zmY= +github.com/go-telegram/bot v1.17.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= diff --git a/internal/ai/gemini/logic.go b/internal/ai/gemini/logic.go index 5077b6c..bab11e7 100644 --- a/internal/ai/gemini/logic.go +++ b/internal/ai/gemini/logic.go @@ -204,7 +204,10 @@ func (l *Logic) HandleMessage(ctx context.Context, sessionID string, req domain. } logger.Info("sending message") - promptParts = append(promptParts, genai.Part{Text: fmt.Sprintf("%s%s", userMessagePrefix, req.Message)}) + text := fmt.Sprintf("%s%s", userMessagePrefix, req.Message) + if text != "" { + promptParts = append(promptParts, genai.Part{Text: text}) + } if req.InlineData != nil { promptParts = append(promptParts, *genai.NewPartFromBytes(req.InlineData.Data, req.InlineData.MimeType)) } diff --git a/pkg/httpBotter/logic.go b/pkg/httpBotter/logic.go new file mode 100644 index 0000000..f5ee56b --- /dev/null +++ b/pkg/httpBotter/logic.go @@ -0,0 +1,92 @@ +package httpBotter + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" +) + +// Logic . +type Logic struct { + baseURL string + httpClient *http.Client +} + +// New . +func New(baseURL string) *Logic { + return &Logic{ + baseURL: baseURL, + httpClient: http.DefaultClient, + } +} + +func (l *Logic) Send(userID string, msg string, payload []byte) (string, error) { + var buff bytes.Buffer + + // Build multipart form data + mpw := multipart.NewWriter(&buff) + // Add message + err := mpw.WriteField("message", msg) + if err != nil { + return "", fmt.Errorf("failed to write message filed: %w", err) + } + // Add file if exists + if len(payload) > 0 { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", multipart.FileContentDisposition("payload", "payload.data")) + h.Set("Content-Type", http.DetectContentType(payload)) + part, err := mpw.CreatePart(h) + if err != nil { + return "", fmt.Errorf("failed to create payload file: %w", err) + } + _, err = io.Copy(part, bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("failed to write payload file: %w", err) + } + } + + // Close the writer to finalize the multipart form + err = mpw.Close() + if err != nil { + return "", fmt.Errorf("failed to close multipart writer: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/message", l.baseURL), &buff) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", mpw.FormDataContentType()) + req.Header.Set("X-User-ID", userID) + + resp, err := l.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var response struct { + Response string `json:"response"` + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + // Debug the response body + fmt.Println(string(bytes.TrimSpace(b))) + } + + //err = json.NewDecoder(resp.Body).Decode(&response) + err = json.NewDecoder(bytes.NewReader(b)).Decode(&response) + if err != nil { + return "", fmt.Errorf("failed to decode response body: %w", err) + } + + return response.Response, nil +} From eaa9dfea0ff096bc9b69e247c75aaa1fdb435fc5 Mon Sep 17 00:00:00 2001 From: Gergely Radics Date: Thu, 21 Aug 2025 12:49:19 +0200 Subject: [PATCH 2/4] fix: added markdown formatter --- cmd/client-telegram/main.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/client-telegram/main.go b/cmd/client-telegram/main.go index 2131d56..5994643 100644 --- a/cmd/client-telegram/main.go +++ b/cmd/client-telegram/main.go @@ -105,8 +105,9 @@ func (l *Logic) Handler(ctx context.Context, b *bot.Bot, update *models.Update) fmt.Println("AI service response:", res) _, err = b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: update.Message.Chat.ID, - Text: res, + ParseMode: models.ParseModeMarkdown, + ChatID: update.Message.Chat.ID, + Text: bot.EscapeMarkdownUnescaped(res), }) if err != nil { fmt.Println("error sending response back to Telegram:", err) From 8ee0fc186617956401e1415f65aa9fdfffd8d859 Mon Sep 17 00:00:00 2001 From: Gergely Radics Date: Thu, 21 Aug 2025 12:55:16 +0200 Subject: [PATCH 3/4] chore: added telegram client doc --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 7683cc3..50402da 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,23 @@ Feel free to use the generated binary in the release as well. ![cli-client](examples/client-cli-demo.svg) +#### Telegram client + +To use this you only need to create a new Bot in Telegram and get the token. + +Then you can run the server with the following way: +``` +BOT_TOKEN= go run cmd/client-telegram/main.go +``` + +You can set the AI service base url with the `AI_SERVICE` env variable. + +If you add a caption to an image it will be used as the question. + +Env variables: +- `BOT_TOKEN` - The token of the bot you created in Telegram +- `AI_SERVICE` - AI service (the server-bot's) address (Default: `http://127.0.0.1:8080`) + #### Facebook messenger client From a24bcea258b02e3789b71bfae87034f051114b00 Mon Sep 17 00:00:00 2001 From: Gergely Radics Date: Thu, 21 Aug 2025 12:57:44 +0200 Subject: [PATCH 4/4] chore: updated go version --- .github/workflows/go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 09a5b81..bf8a1d1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.24' + go-version: '1.25' - name: Build run: go build -v ./...