Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<generated_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

Expand Down
132 changes: 132 additions & 0 deletions cmd/client-telegram/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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{
ParseMode: models.ParseModeMarkdown,
ChatID: update.Message.Chat.ID,
Text: bot.EscapeMarkdownUnescaped(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
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
5 changes: 4 additions & 1 deletion internal/ai/gemini/logic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
92 changes: 92 additions & 0 deletions pkg/httpBotter/logic.go
Original file line number Diff line number Diff line change
@@ -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
}