alpha version
This commit is contained in:
158
workers/claude.go
Normal file
158
workers/claude.go
Normal file
@ -0,0 +1,158 @@
|
||||
package workers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/alex-savin/go-receipt-tracker/bus"
|
||||
"github.com/alex-savin/go-receipt-tracker/config"
|
||||
"github.com/alex-savin/go-receipt-tracker/utils"
|
||||
"github.com/liushuangls/go-anthropic/v2"
|
||||
)
|
||||
|
||||
type Claude struct {
|
||||
id string
|
||||
model string
|
||||
opts *config.Parser
|
||||
client *anthropic.Client
|
||||
subscriptions map[string]chan bus.Event //
|
||||
bus *bus.Bus
|
||||
log *slog.Logger
|
||||
cancel context.CancelFunc //
|
||||
end uint32 // ensure the close methods are only called once
|
||||
}
|
||||
|
||||
func NewClaude(cfg *config.Parser, b *bus.Bus) *Claude {
|
||||
|
||||
ai := &Claude{
|
||||
id: cfg.Type, // "claude",
|
||||
model: cfg.Model, // "gpt-4o-mini"
|
||||
opts: cfg,
|
||||
subscriptions: make(map[string]chan bus.Event),
|
||||
bus: b,
|
||||
}
|
||||
|
||||
return ai
|
||||
}
|
||||
|
||||
func (ai *Claude) ID() string {
|
||||
return ai.id
|
||||
}
|
||||
|
||||
func (ai *Claude) Type() string {
|
||||
return "parser"
|
||||
}
|
||||
|
||||
func (ai *Claude) Init(log *slog.Logger) error {
|
||||
ai.client = anthropic.NewClient(ai.opts.ApiKey)
|
||||
ai.log = log
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *Claude) Serve() {
|
||||
if atomic.LoadUint32(&ai.end) == 1 {
|
||||
return
|
||||
}
|
||||
|
||||
ai.subscribe("parser:" + ai.ID())
|
||||
ai.subscribe("parser:*")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ai.cancel = cancel
|
||||
|
||||
go ai.eventLoop(ctx)
|
||||
}
|
||||
|
||||
func (ai *Claude) OneTime() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *Claude) Stop() {
|
||||
}
|
||||
|
||||
func (ai *Claude) Close() {
|
||||
}
|
||||
|
||||
func (ai *Claude) subscribe(chn string) error {
|
||||
s, err := ai.bus.Subscribe(chn, ai.Type()+":"+ai.ID())
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
ai.subscriptions[chn] = s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop loops forever
|
||||
func (ai *Claude) eventLoop(ctx context.Context) {
|
||||
ai.log.Debug(ai.ID() + " communication event loop started")
|
||||
defer ai.log.Debug(ai.ID() + " communication event loop halted")
|
||||
|
||||
for {
|
||||
for chn, ch := range ai.subscriptions {
|
||||
select {
|
||||
case event := <-ch:
|
||||
switch event.Payload.(type) {
|
||||
case *bus.Message:
|
||||
ai.log.Debug("got a new message to a channel", "channel", chn, "worker type", ai.Type(), "worker id", ai.ID())
|
||||
|
||||
msg := event.Payload.(*bus.Message)
|
||||
res, err := ai.recognize(event.Payload.(*bus.Message).Image.Base64, event.Payload.(*bus.Message).Image.Type)
|
||||
if err != nil {
|
||||
ai.log.Error("got an error from parser ("+ai.ID()+")", "error", err)
|
||||
}
|
||||
|
||||
msg.Image.Parsed[ai.ID()] = res
|
||||
err = ai.bus.Publish("processor:receipt_add", msg)
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't publish to a channel", "channel", "processor:receipt_add", "error", err.Error())
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
ai.log.Info("stopping " + ai.ID() + " communication event loop")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *Claude) recognize(img, imgType string) (res string, err error) {
|
||||
resp, err := ai.client.CreateMessages(context.Background(), anthropic.MessagesRequest{
|
||||
Model: anthropic.ModelClaude3Opus20240229,
|
||||
Messages: []anthropic.Message{
|
||||
{
|
||||
Role: anthropic.RoleUser,
|
||||
Content: []anthropic.MessageContent{
|
||||
anthropic.NewImageMessageContent(anthropic.MessageContentSource{
|
||||
Type: "base64",
|
||||
MediaType: imgType,
|
||||
Data: img,
|
||||
}),
|
||||
anthropic.NewTextMessageContent(config.Request),
|
||||
},
|
||||
},
|
||||
},
|
||||
MaxTokens: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
var e *anthropic.APIError
|
||||
if errors.As(err, &e) {
|
||||
ai.log.Error("cannot recognize a receipt", "type", e.Type, "message", e.Message)
|
||||
} else {
|
||||
ai.log.Error("cannot recognize a receipt", "Messages error: %v\n", err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if !utils.IsJSON(*resp.Content[0].Text) {
|
||||
ai.log.Error("Claude returned not valid JSON", "%+v", resp.Content[0].Text)
|
||||
return "", err
|
||||
}
|
||||
|
||||
ai.log.Debug("recognition result", "worker", ai.ID(), "output", resp.Content[0].Text)
|
||||
|
||||
return *resp.Content[0].Text, nil
|
||||
}
|
178
workers/gemini.go
Normal file
178
workers/gemini.go
Normal file
@ -0,0 +1,178 @@
|
||||
package workers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/alex-savin/go-receipt-tracker/bus"
|
||||
"github.com/alex-savin/go-receipt-tracker/config"
|
||||
"github.com/alex-savin/go-receipt-tracker/utils"
|
||||
"github.com/google/generative-ai-go/genai"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type Gemini struct {
|
||||
id string
|
||||
model string
|
||||
opts *config.Parser
|
||||
client *genai.Client
|
||||
subscriptions map[string]chan bus.Event //
|
||||
bus *bus.Bus
|
||||
log *slog.Logger
|
||||
cancel context.CancelFunc //
|
||||
end uint32 // ensure the close methods are only called once
|
||||
}
|
||||
|
||||
func NewGemini(cfg *config.Parser, b *bus.Bus) *Gemini {
|
||||
ai := &Gemini{
|
||||
id: cfg.Type, // "gemini",
|
||||
model: cfg.Model, // "gemini-1.5-flash"
|
||||
opts: cfg,
|
||||
subscriptions: make(map[string]chan bus.Event),
|
||||
bus: b,
|
||||
}
|
||||
// model := client.GenerativeModel("gemini-1.5-flash")
|
||||
|
||||
return ai
|
||||
}
|
||||
|
||||
func (ai *Gemini) ID() string {
|
||||
return ai.id
|
||||
}
|
||||
|
||||
func (ai *Gemini) Type() string {
|
||||
return "parser"
|
||||
}
|
||||
|
||||
func (ai *Gemini) Init(log *slog.Logger) error {
|
||||
ai.log = log
|
||||
|
||||
ctx := context.Background()
|
||||
client, err := genai.NewClient(ctx, option.WithAPIKey(ai.opts.ApiKey))
|
||||
if err != nil {
|
||||
log.Error("cannot", "error", err)
|
||||
}
|
||||
ai.client = client
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *Gemini) Serve() {
|
||||
if atomic.LoadUint32(&ai.end) == 1 {
|
||||
return
|
||||
}
|
||||
|
||||
ai.subscribe("parser:" + ai.ID())
|
||||
// ai.subscribe("parser:*")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ai.cancel = cancel
|
||||
|
||||
go ai.eventLoop(ctx)
|
||||
}
|
||||
|
||||
func (ai *Gemini) OneTime() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *Gemini) Stop() {
|
||||
ai.client.Close()
|
||||
}
|
||||
|
||||
func (ai *Gemini) Close() {
|
||||
}
|
||||
|
||||
func (ai *Gemini) subscribe(chn string) error {
|
||||
s, err := ai.bus.Subscribe(chn, ai.Type()+":"+ai.ID())
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
ai.subscriptions[chn] = s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop loops forever
|
||||
func (ai *Gemini) eventLoop(ctx context.Context) {
|
||||
ai.log.Debug(ai.ID() + " communication event loop started")
|
||||
defer ai.log.Debug(ai.ID() + " communication event loop halted")
|
||||
|
||||
for {
|
||||
for chn, ch := range ai.subscriptions {
|
||||
select {
|
||||
case event := <-ch:
|
||||
switch event.Payload.(type) {
|
||||
case *bus.Message:
|
||||
ai.log.Debug("got a new message to a channel", "channel", chn, "worker type", ai.Type(), "worker id", ai.ID())
|
||||
|
||||
msg := event.Payload.(*bus.Message)
|
||||
res, err := ai.recognize(event.Payload.(*bus.Message).Image.Filename)
|
||||
if err != nil {
|
||||
ai.log.Error("got an error from parser ("+ai.ID()+")", "error", err)
|
||||
}
|
||||
|
||||
msg.Image.Parsed[ai.ID()] = res
|
||||
err = ai.bus.Publish("processor:receipt_add", msg)
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't publish to a channel", "channel", "processor:receipt_add", "error", err.Error())
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
ai.log.Info("stopping " + ai.ID() + " communication event loop")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *Gemini) recognize(img string) (res string, err error) {
|
||||
ctx := context.Background()
|
||||
|
||||
r := regexp.MustCompile(`^\x60{3}(?:json)\n([\s\S]*?)\n*\x60{3}\n*$`)
|
||||
|
||||
file, err := ai.client.UploadFileFromPath(ctx, img, nil)
|
||||
if err != nil {
|
||||
ai.log.Error("cannot upload a receipt file", "Messages error: %v\n", err)
|
||||
return "", err
|
||||
}
|
||||
defer ai.client.DeleteFile(ctx, file.Name)
|
||||
|
||||
gotFile, err := ai.client.GetFile(ctx, file.Name)
|
||||
if err != nil {
|
||||
ai.log.Error("cannot get uploaded file name", "error", err)
|
||||
}
|
||||
ai.log.Debug("successfully uploaded file", "filename", gotFile.Name)
|
||||
|
||||
model := ai.client.GenerativeModel("gemini-1.5-flash")
|
||||
resp, err := model.GenerateContent(ctx,
|
||||
genai.FileData{URI: file.URI},
|
||||
genai.Text(config.Request))
|
||||
if err != nil {
|
||||
ai.log.Error("cannot recognize a receipt", "Messages error: %v\n", err)
|
||||
}
|
||||
|
||||
for _, cand := range resp.Candidates {
|
||||
if cand.Content != nil {
|
||||
for _, part := range cand.Content.Parts {
|
||||
res += fmt.Sprint(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.MatchString(res) {
|
||||
res = r.FindStringSubmatch(res)[1]
|
||||
}
|
||||
|
||||
if !utils.IsJSON(res) {
|
||||
ai.log.Error("Gemini returned not valid JSON", "%+v", res)
|
||||
return "", err
|
||||
}
|
||||
|
||||
ai.log.Debug("recognition result", "worker", ai.ID(), "output", res)
|
||||
|
||||
return res, nil
|
||||
}
|
160
workers/openai.go
Normal file
160
workers/openai.go
Normal file
@ -0,0 +1,160 @@
|
||||
package workers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/alex-savin/go-receipt-tracker/bus"
|
||||
"github.com/alex-savin/go-receipt-tracker/config"
|
||||
"github.com/alex-savin/go-receipt-tracker/utils"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type OpenAi struct {
|
||||
id string
|
||||
model string
|
||||
opts *config.Parser
|
||||
client *openai.Client
|
||||
subscriptions map[string]chan bus.Event //
|
||||
bus *bus.Bus
|
||||
log *slog.Logger
|
||||
cancel context.CancelFunc //
|
||||
end uint32 // ensure the close methods are only called once
|
||||
}
|
||||
|
||||
func NewOpenAi(cfg *config.Parser, b *bus.Bus) *OpenAi {
|
||||
|
||||
ai := &OpenAi{
|
||||
id: cfg.Type, // "openai",
|
||||
model: cfg.Model, // "gpt-4o-mini"
|
||||
opts: cfg,
|
||||
subscriptions: make(map[string]chan bus.Event),
|
||||
bus: b,
|
||||
}
|
||||
return ai
|
||||
}
|
||||
|
||||
func (ai *OpenAi) ID() string {
|
||||
return ai.id
|
||||
}
|
||||
|
||||
func (ai *OpenAi) Type() string {
|
||||
return "parser"
|
||||
}
|
||||
|
||||
func (ai *OpenAi) Init(log *slog.Logger) error {
|
||||
ai.client = openai.NewClient(ai.opts.ApiKey)
|
||||
ai.log = log
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *OpenAi) Serve() {
|
||||
if atomic.LoadUint32(&ai.end) == 1 {
|
||||
return
|
||||
}
|
||||
|
||||
ai.subscribe("parser:" + ai.ID())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ai.cancel = cancel
|
||||
|
||||
go ai.eventLoop(ctx)
|
||||
}
|
||||
|
||||
func (ai *OpenAi) OneTime() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *OpenAi) Stop() {
|
||||
}
|
||||
|
||||
func (ai *OpenAi) Close() {
|
||||
ai.cancel()
|
||||
}
|
||||
|
||||
func (ai *OpenAi) subscribe(chn string) error {
|
||||
s, err := ai.bus.Subscribe(chn, ai.Type()+":"+ai.ID())
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
ai.subscriptions[chn] = s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop loops forever
|
||||
func (ai *OpenAi) eventLoop(ctx context.Context) {
|
||||
ai.log.Debug(ai.ID() + " communication event loop is started")
|
||||
defer ai.log.Debug(ai.ID() + " communication event loop halted")
|
||||
|
||||
for {
|
||||
ai.log.Debug("looping over the subscriptions", "channel", "parser:"+ai.ID(), "worker", ai.ID())
|
||||
|
||||
select {
|
||||
case event := <-ai.subscriptions["parser:"+ai.ID()]:
|
||||
ai.log.Debug("worker got a new message from a channel", "channel", "parser:"+ai.ID(), "type", ai.Type(), "id", ai.ID())
|
||||
|
||||
switch event.Payload.(type) {
|
||||
case *bus.Message:
|
||||
msg := event.Payload.(*bus.Message)
|
||||
res, err := ai.recognize("data:" + event.Payload.(*bus.Message).Image.Type + ";base64," + event.Payload.(*bus.Message).Image.Base64)
|
||||
if err != nil {
|
||||
ai.log.Error("got an error from parser ("+ai.ID()+")", "error", err)
|
||||
}
|
||||
|
||||
msg.Image.Parsed[ai.ID()] = res
|
||||
err = ai.bus.Publish("processor:receipt_add", msg)
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't publish to a channel", "channel", "processor:receipt_add", "error", err.Error())
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
ai.log.Info("stopping " + ai.ID() + " communication event loop")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *OpenAi) recognize(img string) (res string, err error) {
|
||||
resp, err := ai.client.CreateChatCompletion(
|
||||
context.Background(),
|
||||
openai.ChatCompletionRequest{
|
||||
Model: openai.GPT4oMini,
|
||||
MaxTokens: 1000,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: openai.ChatMessageRoleUser,
|
||||
MultiContent: []openai.ChatMessagePart{
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
Text: config.Request,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: img,
|
||||
Detail: openai.ImageURLDetailHigh,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
ai.log.Error("error during recognition process", "%+v", resp.Choices[0].Message.Content)
|
||||
return "", err
|
||||
}
|
||||
if !utils.IsJSON(resp.Choices[0].Message.Content) {
|
||||
ai.log.Error("returned not valid JSON", "%+v", resp.Choices[0].Message.Content)
|
||||
return "", err
|
||||
}
|
||||
|
||||
ai.log.Debug("recognition result", "worker", ai.ID(), "output", resp.Choices[0].Message.Content)
|
||||
|
||||
return resp.Choices[0].Message.Content, nil
|
||||
}
|
153
workers/parser/claude.go
Normal file
153
workers/parser/claude.go
Normal file
@ -0,0 +1,153 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/alex-savin/go-receipt-tracker/bus"
|
||||
"github.com/alex-savin/go-receipt-tracker/config"
|
||||
"github.com/alex-savin/go-receipt-tracker/utils"
|
||||
"github.com/liushuangls/go-anthropic/v2"
|
||||
)
|
||||
|
||||
type Claude struct {
|
||||
id string
|
||||
model string
|
||||
opts *config.Parser
|
||||
client *anthropic.Client
|
||||
subscriptions map[string]chan bus.Event //
|
||||
bus *bus.Bus
|
||||
log *slog.Logger
|
||||
cancel context.CancelFunc //
|
||||
end uint32 // ensure the close methods are only called once
|
||||
}
|
||||
|
||||
func NewClaude(cfg *config.Parser, bus *bus.Bus) *Claude {
|
||||
|
||||
ai := &Claude{
|
||||
id: cfg.Type, // "claude",
|
||||
model: cfg.Model, // "gpt-4o-mini"
|
||||
opts: cfg,
|
||||
bus: bus,
|
||||
}
|
||||
|
||||
return ai
|
||||
}
|
||||
|
||||
func (ai *Claude) ID() string {
|
||||
return ai.id
|
||||
}
|
||||
|
||||
func (ai *Claude) Type() string {
|
||||
return "parser"
|
||||
}
|
||||
|
||||
func (ai *Claude) Init(log *slog.Logger) error {
|
||||
ai.client = anthropic.NewClient(ai.opts.ApiKey)
|
||||
ai.log = log
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *Claude) Serve() {
|
||||
if atomic.LoadUint32(&ai.end) == 1 {
|
||||
return
|
||||
}
|
||||
|
||||
ai.subscribe("parser:" + ai.ID())
|
||||
ai.subscribe("parser:*")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ai.cancel = cancel
|
||||
|
||||
go ai.eventLoop(ctx)
|
||||
}
|
||||
|
||||
func (ai *Claude) Stop() {
|
||||
}
|
||||
|
||||
func (ai *Claude) subscribe(chn string) error {
|
||||
s, err := ai.bus.Subscribe(chn, ai.Type()+":"+ai.ID())
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
ai.subscriptions[chn] = s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop loops forever
|
||||
func (ai *Claude) eventLoop(ctx context.Context) {
|
||||
ai.log.Debug(ai.ID() + " communication event loop started")
|
||||
defer ai.log.Debug(ai.ID() + " communication event loop halted")
|
||||
|
||||
for {
|
||||
for chn, ch := range ai.subscriptions {
|
||||
select {
|
||||
case event := <-ch:
|
||||
ai.log.Debug("got a new message to a channel", "channel", chn)
|
||||
|
||||
for _, msg := range event.Payload.([]*bus.Message) {
|
||||
ai.log.Debug("publishing mqtt message", "message", msg)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
ai.log.Info("stopping " + ai.ID() + " communication event loop")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *Claude) Recognize(img string) (res string, err error) {
|
||||
image := "./" + img + ".jpg"
|
||||
|
||||
imageMediaType := "image/jpeg"
|
||||
imageFile, err := os.Open(image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
imageData, err := io.ReadAll(imageFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := ai.client.CreateMessages(context.Background(), anthropic.MessagesRequest{
|
||||
Model: anthropic.ModelClaude3Opus20240229,
|
||||
Messages: []anthropic.Message{
|
||||
{
|
||||
Role: anthropic.RoleUser,
|
||||
Content: []anthropic.MessageContent{
|
||||
anthropic.NewImageMessageContent(anthropic.MessageContentSource{
|
||||
Type: "base64",
|
||||
MediaType: imageMediaType,
|
||||
Data: imageData,
|
||||
}),
|
||||
anthropic.NewTextMessageContent(config.Request),
|
||||
},
|
||||
},
|
||||
},
|
||||
MaxTokens: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
var e *anthropic.APIError
|
||||
if errors.As(err, &e) {
|
||||
ai.log.Error("cannot recognize a receipt", "type", e.Type, "message", e.Message)
|
||||
} else {
|
||||
ai.log.Error("cannot recognize a receipt", "Messages error: %v\n", err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if !utils.IsJSON(*resp.Content[0].Text) {
|
||||
ai.log.Error("Claude returned not valid JSON", "%+v", resp.Content[0].Text)
|
||||
return "", err
|
||||
}
|
||||
|
||||
ai.log.Debug("recognition output", "%+v", resp.Content[0].Text)
|
||||
|
||||
return *resp.Content[0].Text, nil
|
||||
}
|
153
workers/parser/gemini.go
Normal file
153
workers/parser/gemini.go
Normal file
@ -0,0 +1,153 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/alex-savin/go-receipt-tracker/bus"
|
||||
"github.com/alex-savin/go-receipt-tracker/config"
|
||||
"github.com/google/generative-ai-go/genai"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type Gemini struct {
|
||||
id string
|
||||
model string
|
||||
opts *config.Parser
|
||||
client *genai.Client
|
||||
subscriptions map[string]chan bus.Event //
|
||||
bus *bus.Bus
|
||||
log *slog.Logger
|
||||
cancel context.CancelFunc //
|
||||
end uint32 // ensure the close methods are only called once
|
||||
}
|
||||
|
||||
func NewGemini(cfg *config.Parser, bus *bus.Bus) *Gemini {
|
||||
ai := &Gemini{
|
||||
id: cfg.Type, // "gemini",
|
||||
model: cfg.Model, // "gemini-1.5-flash"
|
||||
opts: cfg,
|
||||
bus: bus,
|
||||
}
|
||||
// model := client.GenerativeModel("gemini-1.5-flash")
|
||||
|
||||
return ai
|
||||
}
|
||||
|
||||
func (ai *Gemini) ID() string {
|
||||
return ai.id
|
||||
}
|
||||
|
||||
func (ai *Gemini) Type() string {
|
||||
return "parser"
|
||||
}
|
||||
|
||||
func (ai *Gemini) Init(log *slog.Logger) error {
|
||||
ai.log = log
|
||||
|
||||
ctx := context.Background()
|
||||
client, err := genai.NewClient(ctx, option.WithAPIKey(ai.opts.ApiKey))
|
||||
if err != nil {
|
||||
log.Error("cannot", "error", err)
|
||||
}
|
||||
ai.client = client
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *Gemini) Serve() {
|
||||
if atomic.LoadUint32(&ai.end) == 1 {
|
||||
return
|
||||
}
|
||||
|
||||
ai.subscribe("parser:" + ai.ID())
|
||||
ai.subscribe("parser:*")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ai.cancel = cancel
|
||||
|
||||
go ai.eventLoop(ctx)
|
||||
}
|
||||
|
||||
func (ai *Gemini) Stop() {
|
||||
ai.client.Close()
|
||||
}
|
||||
|
||||
func (ai *Gemini) subscribe(chn string) error {
|
||||
s, err := ai.bus.Subscribe(chn, ai.Type()+":"+ai.ID())
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
ai.subscriptions[chn] = s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop loops forever
|
||||
func (ai *Gemini) eventLoop(ctx context.Context) {
|
||||
ai.log.Debug(ai.ID() + " communication event loop started")
|
||||
defer ai.log.Debug(ai.ID() + " communication event loop halted")
|
||||
|
||||
for {
|
||||
for chn, ch := range ai.subscriptions {
|
||||
select {
|
||||
case event := <-ch:
|
||||
ai.log.Debug("got a new message to a channel", "channel", chn)
|
||||
|
||||
for _, msg := range event.Payload.([]*bus.Message) {
|
||||
ai.log.Debug("publishing mqtt message", "message", msg)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
ai.log.Info("stopping " + ai.ID() + " communication event loop")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *Gemini) Recognize(img string) (res string, err error) {
|
||||
ctx := context.Background()
|
||||
|
||||
image := "./" + img + ".jpg"
|
||||
file, err := ai.client.UploadFileFromPath(ctx, image, nil)
|
||||
if err != nil {
|
||||
ai.log.Error("cannot upload a receipt file", "Messages error: %v\n", err)
|
||||
}
|
||||
defer ai.client.DeleteFile(ctx, file.Name)
|
||||
|
||||
gotFile, err := ai.client.GetFile(ctx, file.Name)
|
||||
if err != nil {
|
||||
ai.log.Error("cannot get uploaded file name", err)
|
||||
}
|
||||
fmt.Println("Got file:", gotFile.Name)
|
||||
|
||||
model := ai.client.GenerativeModel("gemini-1.5-flash")
|
||||
resp, err := model.GenerateContent(ctx,
|
||||
genai.FileData{URI: file.URI},
|
||||
genai.Text(config.Request))
|
||||
if err != nil {
|
||||
ai.log.Error("cannot recognize a receipt", "Messages error: %v\n", err)
|
||||
}
|
||||
|
||||
printResponse(resp)
|
||||
// if !utils.IsJSON(resp.Choices[0].Message.Content) {
|
||||
// ai.log.Error("OpenAI returned not valid JSON", "%+v", resp.Choices[0].Message.Content)
|
||||
// return "", err
|
||||
// }
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func printResponse(resp *genai.GenerateContentResponse) {
|
||||
for _, cand := range resp.Candidates {
|
||||
if cand.Content != nil {
|
||||
for _, part := range cand.Content.Parts {
|
||||
fmt.Println(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("---")
|
||||
}
|
146
workers/parser/openai.go
Normal file
146
workers/parser/openai.go
Normal file
@ -0,0 +1,146 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/alex-savin/go-receipt-tracker/bus"
|
||||
"github.com/alex-savin/go-receipt-tracker/config"
|
||||
"github.com/alex-savin/go-receipt-tracker/utils"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type OpenAi struct {
|
||||
id string
|
||||
model string
|
||||
opts *config.Parser
|
||||
client *openai.Client
|
||||
subscriptions map[string]chan bus.Event //
|
||||
bus *bus.Bus
|
||||
log *slog.Logger
|
||||
cancel context.CancelFunc //
|
||||
end uint32 // ensure the close methods are only called once
|
||||
}
|
||||
|
||||
func NewOpenAi(cfg *config.Parser, bus *bus.Bus) *OpenAi {
|
||||
|
||||
ai := &OpenAi{
|
||||
id: cfg.Type, // "openai",
|
||||
model: cfg.Model, // "gpt-4o-mini"
|
||||
opts: cfg,
|
||||
bus: bus,
|
||||
}
|
||||
return ai
|
||||
}
|
||||
|
||||
func (ai *OpenAi) ID() string {
|
||||
return ai.id
|
||||
}
|
||||
|
||||
func (ai *OpenAi) Type() string {
|
||||
return "parser"
|
||||
}
|
||||
|
||||
func (ai *OpenAi) Init(log *slog.Logger) error {
|
||||
ai.client = openai.NewClient(ai.opts.ApiKey)
|
||||
ai.log = log
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *OpenAi) Serve() {
|
||||
if atomic.LoadUint32(&ai.end) == 1 {
|
||||
return
|
||||
}
|
||||
|
||||
ai.subscribe("parser:" + ai.ID())
|
||||
ai.subscribe("parser:*")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ai.cancel = cancel
|
||||
|
||||
go ai.eventLoop(ctx)
|
||||
}
|
||||
|
||||
func (ai *OpenAi) subscribe(chn string) error {
|
||||
s, err := ai.bus.Subscribe(chn, ai.Type()+":"+ai.ID())
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
ai.subscriptions[chn] = s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop loops forever
|
||||
func (ai *OpenAi) eventLoop(ctx context.Context) {
|
||||
ai.log.Debug(ai.ID() + " communication event loop started")
|
||||
defer ai.log.Debug(ai.ID() + " communication event loop halted")
|
||||
|
||||
for {
|
||||
for chn, ch := range ai.subscriptions {
|
||||
select {
|
||||
case event := <-ch:
|
||||
switch event.Payload.(type) {
|
||||
case bus.Image:
|
||||
ai.log.Debug("got a new message to a channel", "channel", chn)
|
||||
|
||||
res, err := ai.recognize(event.Payload.(bus.Image).Base64)
|
||||
if err != nil {
|
||||
ai.log.Error("got an error from parser ("+ai.ID()+")", "error", err)
|
||||
}
|
||||
err = ai.bus.Publish("telegram:publish", res)
|
||||
if err != nil {
|
||||
ai.log.Error("couldn't publish to a channel", "channel", "telegram:publish", "error", err.Error())
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
ai.log.Info("stopping " + ai.ID() + " communication event loop")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *OpenAi) recognize(img string) (res string, err error) {
|
||||
resp, err := ai.client.CreateChatCompletion(
|
||||
context.Background(),
|
||||
openai.ChatCompletionRequest{
|
||||
Model: openai.GPT4oMini,
|
||||
MaxTokens: 1000,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: openai.ChatMessageRoleUser,
|
||||
MultiContent: []openai.ChatMessagePart{
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
Text: config.Request,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: img,
|
||||
Detail: openai.ImageURLDetailHigh,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
ai.log.Error("error during recognition process", "%+v", resp.Choices[0].Message.Content)
|
||||
return "", err
|
||||
}
|
||||
if !utils.IsJSON(resp.Choices[0].Message.Content) {
|
||||
ai.log.Error("OpenAI returned not valid JSON", "%+v", resp.Choices[0].Message.Content)
|
||||
return "", err
|
||||
}
|
||||
|
||||
ai.log.Debug("recognition output", "%+v", resp.Choices[0].Message.Content)
|
||||
|
||||
return resp.Choices[0].Message.Content, nil
|
||||
}
|
6
workers/parser/parser.go
Normal file
6
workers/parser/parser.go
Normal file
@ -0,0 +1,6 @@
|
||||
package parser
|
||||
|
||||
type Parser interface {
|
||||
Recognize()
|
||||
Transcribe()
|
||||
}
|
341
workers/telegram.go
Normal file
341
workers/telegram.go
Normal file
@ -0,0 +1,341 @@
|
||||
package workers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/alex-savin/go-receipt-tracker/bus"
|
||||
"github.com/alex-savin/go-receipt-tracker/config"
|
||||
"github.com/alex-savin/go-receipt-tracker/models"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
id string //
|
||||
opts *config.Telegram //
|
||||
client *tele.Bot //
|
||||
subscriptions map[string]chan bus.Event //
|
||||
bus *bus.Bus //
|
||||
log *slog.Logger //
|
||||
cancel context.CancelFunc //
|
||||
end uint32 // ensure the close methods are only called once
|
||||
}
|
||||
|
||||
func NewTelegramBot(cfg *config.Telegram, b *bus.Bus) *Bot {
|
||||
|
||||
bot := &Bot{
|
||||
id: "telegram",
|
||||
opts: cfg,
|
||||
subscriptions: make(map[string]chan bus.Event),
|
||||
bus: b,
|
||||
}
|
||||
|
||||
return bot
|
||||
}
|
||||
|
||||
func (b *Bot) ID() string {
|
||||
return b.id
|
||||
}
|
||||
|
||||
func (b *Bot) Type() string {
|
||||
return "worker"
|
||||
}
|
||||
|
||||
func (b *Bot) Init(log *slog.Logger) error {
|
||||
var err error
|
||||
b.log = log
|
||||
|
||||
opts := tele.Settings{
|
||||
Token: b.opts.ApiKey,
|
||||
Poller: &tele.LongPoller{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
b.client, err = tele.NewBot(opts)
|
||||
if err != nil {
|
||||
b.log.Debug("cannot create telegram bot", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
b.client.Handle(tele.OnText, func(c tele.Context) error {
|
||||
b.log.Debug("received a new text message", "chat", c.Chat().ID, "message", c.Message().Text)
|
||||
|
||||
b.client.Notify(c.Chat(), tele.ChatAction("typing"))
|
||||
|
||||
err = b.bus.Publish("processor:text", c.Message().Text)
|
||||
if err != nil {
|
||||
b.log.Error("couldn't publish to a channel", "channel", "processor:text", "error", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
b.client.Handle(tele.OnPhoto, func(c tele.Context) error {
|
||||
b.client.Notify(c.Chat(), tele.ChatAction("typing"))
|
||||
|
||||
photo := c.Message().Photo
|
||||
b.client.Download(&photo.File, b.opts.StoragePath+"/"+photo.UniqueID+".jpg")
|
||||
|
||||
msg := &bus.Message{
|
||||
Image: &bus.Image{
|
||||
ID: photo.UniqueID,
|
||||
Filename: b.opts.StoragePath + "/" + photo.UniqueID + ".jpg",
|
||||
},
|
||||
ReplyType: "send",
|
||||
TbContext: c,
|
||||
}
|
||||
err := b.bus.Publish("processor:image", msg)
|
||||
if err != nil {
|
||||
b.log.Error("couldn't publish to the channel", "channel", "processor:image", "error", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
b.client.Handle(tele.OnCallback, func(c tele.Context) error {
|
||||
tbc := new(models.TelegramBotCommand)
|
||||
err := tbc.Unmarshal(c.Callback().Data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
switch tbc.Cmd {
|
||||
case models.BtnCmd["user_add"]:
|
||||
return nil
|
||||
case models.BtnCmd["user_edit"]:
|
||||
msg := &bus.Message{
|
||||
TBCmd: tbc,
|
||||
TbContext: c,
|
||||
ReplyType: "send",
|
||||
}
|
||||
err := b.bus.Publish("processor:user_edit", msg)
|
||||
if err != nil {
|
||||
b.log.Error("couldn't publish to the channel", "channel", "processor:user_edit", "error", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
case models.BtnCmd["user_delete"]:
|
||||
// b.client.Notify(c.Chat(), tele.ChatAction("typing"))
|
||||
// msg.Text = "Welcome back, " + event.Payload.(*bus.Message).TbContext.Sender().FirstName + " " + event.Payload.(*bus.Message).TbContext.Sender().LastName
|
||||
// msg.InlineKeyboard = &tele.ReplyMarkup{
|
||||
// InlineKeyboard: [][]tele.InlineButton{
|
||||
// {
|
||||
// {Text: "Yes, Delete", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["user_delete"], ID: msg.TbContext.Sender().ID})},
|
||||
// {Text: "No, Cancel", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["user_delete"], ID: msg.TbContext.Sender().ID})},
|
||||
// },
|
||||
// {
|
||||
// {Text: "« Back to Receipt", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["user_edit"], ID: msg.TbContext.Sender().ID})},
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
|
||||
return nil
|
||||
case models.BtnCmd["receipt_add"]:
|
||||
return nil
|
||||
case models.BtnCmd["receipt_edit"]:
|
||||
return nil
|
||||
case models.BtnCmd["receipts_list"]:
|
||||
return nil
|
||||
case models.BtnCmd["items_list"]:
|
||||
cbr := &tele.CallbackResponse{
|
||||
CallbackID: c.Callback().ID,
|
||||
Text: "You clicked the button!",
|
||||
ShowAlert: false,
|
||||
}
|
||||
c.Respond(cbr)
|
||||
msg := &bus.Message{
|
||||
TBCmd: tbc,
|
||||
TbContext: c,
|
||||
ReplyType: "send",
|
||||
}
|
||||
err := b.bus.Publish("processor:items_list", msg)
|
||||
if err != nil {
|
||||
b.log.Error("couldn't publish to the channel", "channel", "processor:items_list", "error", err.Error())
|
||||
}
|
||||
// r := new(Receipt)
|
||||
// err = db.NewSelect().Model(r).Where("receipt_id = ?", tbc.ID).Scan(ctx)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
|
||||
// var items []Item
|
||||
// _, err := db.NewSelect().Model(&items).Where("receipt_id = ?", tbc.ID).Limit(10).ScanAndCount(ctx)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
|
||||
// message := "<code>List of the Receipt Items:\n" + strings.Repeat("-", 32) + "\n"
|
||||
|
||||
// merchant := truncateText(r.Merchant, 16)
|
||||
// spaces := 32 - (len("Merchant:") + len(merchant))
|
||||
// message += "Merchant:" + strings.Repeat(" ", spaces) + merchant + "\n"
|
||||
// spaces = 32 - (len("Date/Time:") + len(r.PaidAt))
|
||||
// message += "Date/Time:" + strings.Repeat(" ", spaces) + r.PaidAt + "\n" + strings.Repeat("-", 32) + "\n"
|
||||
|
||||
// keyboard := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{}}
|
||||
|
||||
// for _, item := range items {
|
||||
// // b := tele.InlineButton{Text: fmt.Sprintf("%s (%.2f) $%.2f", strings.Title(strings.ToLower(item.Title)), item.Quantity, item.Price), Data: String(&TelegramBotCommand{Cmd: btnCmd["item_edit"], ID: item.ItemID})}
|
||||
// // keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, []tele.InlineButton{b})
|
||||
// // if item.Quantity == math.Trunc(item.Quantity) {
|
||||
// // fmt.Sprintf("%d", math.Trunc(item.Quantity))
|
||||
// // } else {
|
||||
// // fmt.Sprintf("%.2f", item.Quantity)
|
||||
// // }
|
||||
// title := truncateText(item.Title, 16)
|
||||
// spaces := 32 - (len(title) + len("x"+fmt.Sprintf("%+v", item.Quantity)) + len(fmt.Sprintf("$%.2f", item.Price)) + 3)
|
||||
// message += title + strings.Repeat(".", spaces) + "x" + fmt.Sprintf("%+v", item.Quantity) + "..." + fmt.Sprintf("$%.2f", item.Price) + "\n"
|
||||
// log.Printf("ITEM: %s // %.2f // %.2f", strings.Title(strings.ToLower(item.Title)), item.Quantity, item.Price)
|
||||
// }
|
||||
// // keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, []tele.InlineButton{{Text: "«", Data: String(&TelegramBotCommand{Cmd: btnPage["prev"], ID: tbc.ID})}, {Text: "»", Data: String(&TelegramBotCommand{Cmd: btnPage["next"], ID: tbc.ID})}})
|
||||
// keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, []tele.InlineButton{{Text: "« Back to Receipt", Data: String(&TelegramBotCommand{Cmd: btnCmd["receipt_edit"], ID: tbc.ID})}})
|
||||
|
||||
// message += strings.Repeat("-", 32) + "\n"
|
||||
// spaces = 32 - (len("Tax:") + len(fmt.Sprintf("$%.2f", r.Tax)))
|
||||
// message += "Tax:" + strings.Repeat(" ", spaces) + fmt.Sprintf("$%.2f", r.Tax) + "\n"
|
||||
// if r.CCFee > 0 {
|
||||
// spaces = 32 - (len("CC Fee:") + len(fmt.Sprintf("$%.2f", r.CCFee)))
|
||||
// message += "CC Fee:" + strings.Repeat(" ", spaces) + fmt.Sprintf("$%.2f", r.CCFee) + "\n"
|
||||
// }
|
||||
// spaces = 32 - (len("Total:") + len(fmt.Sprintf("$%.2f", r.Total)))
|
||||
// message += "Total:" + strings.Repeat(" ", spaces) + fmt.Sprintf("<b>$%.2f</b>", r.Total) + "\n</code>"
|
||||
|
||||
// opts := &tele.SendOptions{ParseMode: "HTML", ReplyMarkup: keyboard}
|
||||
|
||||
// return c.EditCaption(message, opts)
|
||||
return nil
|
||||
}
|
||||
// cbr := &tele.CallbackResponse{
|
||||
// CallbackID: c.Callback().ID,
|
||||
// Text: "No unique",
|
||||
// ShowAlert: false,
|
||||
// }
|
||||
// return c.Respond(cbr)
|
||||
return nil
|
||||
})
|
||||
|
||||
b.client.Handle("/start", func(c tele.Context) error {
|
||||
b.client.Notify(c.Chat(), tele.ChatAction("typing"))
|
||||
msg := &bus.Message{
|
||||
ReplyType: "send",
|
||||
TbContext: c,
|
||||
}
|
||||
|
||||
err = b.bus.Publish("processor:user_add", msg)
|
||||
if err != nil {
|
||||
b.log.Error("couldn't publish to a channel", "channel", "processor:user_add", "error", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
b.client.Handle("/receipts", func(c tele.Context) error {
|
||||
b.log.Debug("publish message to a channel", "channel", "processor:receipts_list", "message", "Test Message")
|
||||
b.client.Notify(c.Chat(), tele.ChatAction("typing"))
|
||||
|
||||
err = b.bus.Publish("processor:receipts_list", "Test Message")
|
||||
if err != nil {
|
||||
b.log.Error("couldn't publish to a channel", "channel", "processor:receipts_list", "error", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
b.client.Handle("/reports", func(c tele.Context) error {
|
||||
b.log.Debug("publish message to a channel", "channel", "processor:reports_list", "message", "Test Message")
|
||||
b.client.Notify(c.Chat(), tele.ChatAction("typing"))
|
||||
|
||||
err = b.bus.Publish("processor:reports_list", "Test Message")
|
||||
if err != nil {
|
||||
b.log.Error("couldn't publish to a channel", "channel", "processor:reports_list", "error", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) OneTime() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) Serve() {
|
||||
if atomic.LoadUint32(&b.end) == 1 {
|
||||
return
|
||||
}
|
||||
|
||||
b.subscribe("telegram:send")
|
||||
b.subscribe("telegram:reply")
|
||||
b.subscribe("telegram:edit_caption")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
b.cancel = cancel
|
||||
|
||||
go b.eventLoop(ctx)
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
go b.client.Start()
|
||||
|
||||
// if atomic.LoadUint32(&b.end) == 0 {
|
||||
// go func() {
|
||||
// if !w.mqtt.IsConnected() {
|
||||
// b.bus.Publish("app:connected", &bus.ConnectionStatus{WorkerID: b.ID(), WorkerType: b.Type(), IsConnected: false})
|
||||
// b.log.Warn(b.ID() + " client is disconnected")
|
||||
// }
|
||||
// }()
|
||||
// }
|
||||
}
|
||||
|
||||
func (b *Bot) Stop() {
|
||||
b.client.Stop()
|
||||
}
|
||||
|
||||
func (b *Bot) Close() {
|
||||
b.cancel()
|
||||
}
|
||||
|
||||
func (b *Bot) subscribe(chn string) error {
|
||||
s, err := b.bus.Subscribe(chn, b.Type()+":"+b.ID())
|
||||
if err != nil {
|
||||
b.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
b.subscriptions[chn] = s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop loops forever
|
||||
func (b *Bot) eventLoop(ctx context.Context) {
|
||||
b.log.Debug(b.ID() + " communication event loop started")
|
||||
defer b.log.Debug(b.ID() + " communication event loop halted")
|
||||
|
||||
for {
|
||||
select {
|
||||
|
||||
case event := <-b.subscriptions["telegram:send"]:
|
||||
b.log.Debug("worker got a new message from a channel", "channel", event.ChannelName, "type", b.Type(), "id", b.ID())
|
||||
msg := event.Payload.(*bus.Message)
|
||||
b.client.Send(msg.TbContext.Sender(), msg.Text, msg.InlineKeyboard)
|
||||
|
||||
case event := <-b.subscriptions["telegram:reply"]:
|
||||
b.log.Debug("worker got a new message from a channel", "channel", event.ChannelName, "type", b.Type(), "id", b.ID())
|
||||
msg := event.Payload.(*bus.Message)
|
||||
b.client.Reply(msg.TbContext.Message(), msg.Text, msg.InlineKeyboard)
|
||||
|
||||
case event := <-b.subscriptions["telegram:edit_caption"]:
|
||||
b.log.Debug("worker got a new message from a channel", "channel", event.ChannelName, "type", b.Type(), "id", b.ID())
|
||||
msg := event.Payload.(*bus.Message)
|
||||
b.client.EditCaption(msg.TbContext.Message(), msg.Text, msg.InlineKeyboard)
|
||||
|
||||
case <-ctx.Done():
|
||||
b.log.Info("stopping " + b.ID() + " communication event loop")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
92
workers/telegram/commands.go
Normal file
92
workers/telegram/commands.go
Normal file
@ -0,0 +1,92 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var btnCmd = map[string]int64{
|
||||
"user_add": 1,
|
||||
"user_edit": 2,
|
||||
"user_detete": 3,
|
||||
"users_list": 4,
|
||||
"group_add": 11,
|
||||
"group_edit": 12,
|
||||
"group_delete": 13,
|
||||
"groups_list": 14,
|
||||
"receipt_add": 21,
|
||||
"receipt_edit": 22,
|
||||
"receipt_delete": 23,
|
||||
"receipts_list": 24,
|
||||
"item_add": 31,
|
||||
"item_edit": 32,
|
||||
"item_delete": 33,
|
||||
"items_list": 34,
|
||||
"merchant": 96,
|
||||
"category": 97,
|
||||
"tax": 98,
|
||||
"total": 99,
|
||||
}
|
||||
|
||||
var btnPage = map[string]int64{
|
||||
"prev": 1,
|
||||
"next": 2,
|
||||
}
|
||||
|
||||
// TelegramBotCommand .
|
||||
type TelegramBotCommand struct {
|
||||
Cmd int64 // Internal Command ( user_add | user_edit | user_delete )
|
||||
ID int64 // Internal UserID
|
||||
RefCmd int64 // Internal Reference Command
|
||||
RefID int64 // Internal Reference ID
|
||||
TelegramID int64 // Telegram UserID
|
||||
Pagination int64 // Pagination
|
||||
Extra string // Internal String filed to pass Telegram FileID
|
||||
}
|
||||
|
||||
// Unmarshal .
|
||||
func (t *TelegramBotCommand) Unmarshal(c string) error {
|
||||
r := regexp.MustCompile(`^(?P<Cmd>.*)\|(?P<ID>.*)\|(?P<RefCmd>.*)\|(?P<RefID>.*)\|(?P<TelegramID>.*)\|(?P<Pagination>.*)\|(?P<Extra>.*)$`)
|
||||
m := r.FindStringSubmatch(c)
|
||||
|
||||
res := make(map[string]string)
|
||||
for i, name := range r.SubexpNames() {
|
||||
if i != 0 && name != "" {
|
||||
res[name] = m[i]
|
||||
}
|
||||
}
|
||||
|
||||
for k, val := range res {
|
||||
v := reflect.ValueOf(&t).Elem()
|
||||
f := reflect.Indirect(v).FieldByName(k)
|
||||
|
||||
if !f.IsValid() {
|
||||
continue
|
||||
}
|
||||
if f.Type().Kind() == reflect.Int64 {
|
||||
age, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f.SetInt(int64(age))
|
||||
} else {
|
||||
f.SetString(val)
|
||||
}
|
||||
}
|
||||
log.Printf("CALLBACK (UNMARSHAL) %d|%d|%d|%d|%d|%d|%s", t.Cmd, t.ID, t.RefCmd, t.RefID, t.TelegramID, t.Pagination, t.Extra)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Marshal .
|
||||
func (t TelegramBotCommand) Marshal() string {
|
||||
return fmt.Sprintf("%d|%d|%d|%d|%d|%d|%s", t.Cmd, t.ID, t.RefCmd, t.RefID, t.TelegramID, t.Pagination, t.Extra)
|
||||
}
|
||||
|
||||
// String .
|
||||
func String(t *TelegramBotCommand) string {
|
||||
return t.Marshal()
|
||||
}
|
213
workers/telegram/telegram.go
Normal file
213
workers/telegram/telegram.go
Normal file
@ -0,0 +1,213 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/alex-savin/go-receipt-tracker/bus"
|
||||
"github.com/alex-savin/go-receipt-tracker/config"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
id string //
|
||||
opts *config.Telegram //
|
||||
client *tele.Bot //
|
||||
subscriptions map[string]chan bus.Event //
|
||||
bus *bus.Bus //
|
||||
log *slog.Logger //
|
||||
cancel context.CancelFunc //
|
||||
end uint32 // ensure the close methods are only called once
|
||||
}
|
||||
|
||||
func NewTelegramBot(cfg *config.Telegram, bus *bus.Bus) (*Bot, error) {
|
||||
|
||||
bot := &Bot{
|
||||
id: "telegram",
|
||||
opts: cfg,
|
||||
bus: bus,
|
||||
}
|
||||
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
func (b *Bot) ID() string {
|
||||
return b.id
|
||||
}
|
||||
|
||||
func (b *Bot) Type() string {
|
||||
return "telegram"
|
||||
}
|
||||
|
||||
func (b *Bot) Init(log *slog.Logger) error {
|
||||
var err error
|
||||
b.log = log
|
||||
|
||||
pref := tele.Settings{
|
||||
Token: b.opts.ApiKey,
|
||||
Poller: &tele.LongPoller{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
b.client, err = tele.NewBot(pref)
|
||||
if err != nil {
|
||||
b.log.Debug("cannot create telegram bot", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
b.client.Handle(tele.OnText, func(c tele.Context) error {
|
||||
return c.Send("")
|
||||
})
|
||||
|
||||
b.client.Handle(tele.OnPhoto, func(c tele.Context) error {
|
||||
|
||||
photo := c.Message().Photo
|
||||
b.client.Download(&photo.File, "./"+photo.UniqueID+".jpg")
|
||||
|
||||
msg := &bus.Message{
|
||||
Image: &bus.Image{
|
||||
ID: photo.UniqueID,
|
||||
Filename: b.opts.StoragePath + "/" + photo.UniqueID + ".jpg",
|
||||
},
|
||||
ReplyType: "send",
|
||||
TbContext: c,
|
||||
}
|
||||
err := b.bus.Publish("processor:image", msg)
|
||||
if err != nil {
|
||||
b.log.Error("couldn't publish to the channel", "channel", "processor:image", "error", err.Error())
|
||||
}
|
||||
|
||||
return c.Send("")
|
||||
})
|
||||
|
||||
b.client.Handle(tele.OnCallback, func(c tele.Context) error {
|
||||
b.log.Debug("CALLBACK (BEFORE) %s // %s", c.Callback().ID, c.Callback().Data)
|
||||
|
||||
tbc := new(TelegramBotCommand)
|
||||
err := tbc.Unmarshal(c.Callback().Data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
b.log.Debug("CALLBACK (AFTER) %d|%d|%d|%d|%d|%d|%s", tbc.Cmd, tbc.ID, tbc.RefCmd, tbc.RefID, tbc.TelegramID, tbc.Pagination, tbc.Extra)
|
||||
|
||||
switch tbc.Cmd {
|
||||
case btnCmd["user_add"]:
|
||||
return c.Send("")
|
||||
case btnCmd["receipt_add"]:
|
||||
|
||||
return c.Send("")
|
||||
case btnCmd["receipt_edit"]:
|
||||
return c.Send("")
|
||||
case btnCmd["receipts_list"]:
|
||||
case btnCmd["items_list"]:
|
||||
}
|
||||
cbr := &tele.CallbackResponse{
|
||||
CallbackID: c.Callback().ID,
|
||||
Text: "No unique",
|
||||
ShowAlert: false,
|
||||
}
|
||||
return c.Respond(cbr)
|
||||
})
|
||||
|
||||
b.client.Handle("/start", func(c tele.Context) error {
|
||||
err = b.bus.Publish("processor:start", nil)
|
||||
if err != nil {
|
||||
b.log.Error("couldn't publish to a channel", "channel", "telegram:publish", "error", err.Error())
|
||||
}
|
||||
|
||||
return c.Send("")
|
||||
})
|
||||
|
||||
b.client.Handle("/receipts", func(c tele.Context) error {
|
||||
return c.Send("")
|
||||
})
|
||||
|
||||
b.client.Handle("/reports", func(c tele.Context) error {
|
||||
return c.Send("")
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) Serve() {
|
||||
if atomic.LoadUint32(&b.end) == 1 {
|
||||
return
|
||||
}
|
||||
|
||||
b.subscribe("messenger:" + b.ID())
|
||||
|
||||
b.client.Start()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
b.cancel = cancel
|
||||
|
||||
go b.eventLoop(ctx)
|
||||
|
||||
// if atomic.LoadUint32(&b.end) == 0 {
|
||||
// go func() {
|
||||
// if !w.mqtt.IsConnected() {
|
||||
// b.bus.Publish("app:connected", &bus.ConnectionStatus{WorkerID: b.ID(), WorkerType: b.Type(), IsConnected: false})
|
||||
// b.log.Warn(b.ID() + " client is disconnected")
|
||||
// }
|
||||
// }()
|
||||
// }
|
||||
}
|
||||
|
||||
func (b *Bot) Stop() {
|
||||
b.client.Stop()
|
||||
}
|
||||
|
||||
func (b *Bot) subscribe(chn string) error {
|
||||
s, err := b.bus.Subscribe(chn, b.Type()+":"+b.ID())
|
||||
if err != nil {
|
||||
b.log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
|
||||
return err
|
||||
}
|
||||
b.subscriptions[chn] = s
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop loops forever
|
||||
func (b *Bot) eventLoop(ctx context.Context) {
|
||||
b.log.Debug(b.ID() + " communication event loop started")
|
||||
defer b.log.Debug(b.ID() + " communication event loop halted")
|
||||
|
||||
for {
|
||||
for chn, ch := range b.subscriptions {
|
||||
select {
|
||||
case event := <-ch:
|
||||
b.log.Debug("got a new message to a channel", "channel", chn)
|
||||
for _, msg := range event.Payload.([]*bus.Message) {
|
||||
b.log.Debug("publishing telegram message", "topic", msg)
|
||||
// w.mqtt.Publish(message.Topic, message.QOS, message.Retained, message.Payload)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
b.log.Info("stopping " + b.ID() + " communication event loop")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// for {
|
||||
// select {
|
||||
// case event := <-chMQTTPublishStatus:
|
||||
// for _, message := range event.Data.([]*bus.Message) {
|
||||
// b.log.Debug("publishing mqtt message", "topic", message.Topic, "qos", message.QOS, "retained", message.Retained, "payload", message.Payload)
|
||||
// // w.mqtt.Publish(message.Topic, message.QOS, message.Retained, message.Payload)
|
||||
// }
|
||||
// case event := <-chMQTTSubscribeCommand:
|
||||
// for _, message := range event.Data.([]*bus.Message) {
|
||||
// b.log.Debug("subscribing to a topic", "topic", message.Topic, "qos", message.QOS)
|
||||
// // w.mqtt.SubscribeMultiple()
|
||||
// // b.mqtt.Subscribe(message.Topic, message.QOS, w.mqttHandlers.publish)
|
||||
// }
|
||||
// case <-ctx.Done():
|
||||
// b.log.Info("stopping mqtt communication event loop")
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
}
|
124
workers/workers.go
Normal file
124
workers/workers.go
Normal file
@ -0,0 +1,124 @@
|
||||
package workers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// TLSConfig is a tls.Config configuration to be used with the listener.
|
||||
// See examples folder for basic and mutual-tls use.
|
||||
Test string
|
||||
}
|
||||
|
||||
// EstablishFn is a callback function for establishing new clients.
|
||||
type EstablishFn func(id string) error
|
||||
|
||||
// CloseFn is a callback function for closing all listener clients.
|
||||
type CloseFn func(id string)
|
||||
|
||||
// Worker .
|
||||
type Worker interface {
|
||||
ID() string // returns ID in string format
|
||||
Type() string // returns the type of the worker
|
||||
Init(*slog.Logger) error //
|
||||
OneTime() error //
|
||||
Serve() // starting actively listening for new connections
|
||||
Close() // stop and close the worker
|
||||
}
|
||||
|
||||
// Workers contains the network workers for the app.
|
||||
type Workers struct {
|
||||
ClientsWg sync.WaitGroup // a waitgroup that waits for all clients in all workers to finish.
|
||||
internal map[string]Worker // a map of active workers.
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
// New returns a new instance of Workers.
|
||||
func New() *Workers {
|
||||
return &Workers{
|
||||
internal: map[string]Worker{},
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a new worker to the workers map, keyed on id.
|
||||
func (w *Workers) Add(val Worker) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
w.internal[val.ID()] = val
|
||||
}
|
||||
|
||||
// Get returns the value of a worker if it exists.
|
||||
func (w *Workers) Get(id string) (Worker, bool) {
|
||||
w.RLock()
|
||||
defer w.RUnlock()
|
||||
val, ok := w.internal[id]
|
||||
return val, ok
|
||||
}
|
||||
|
||||
// Len returns the length of the workers map.
|
||||
func (w *Workers) Len() int {
|
||||
w.RLock()
|
||||
defer w.RUnlock()
|
||||
return len(w.internal)
|
||||
}
|
||||
|
||||
// Delete removes a worker from the internal map.
|
||||
func (w *Workers) Delete(id string) {
|
||||
w.Lock()
|
||||
defer w.Unlock()
|
||||
delete(w.internal, id)
|
||||
}
|
||||
|
||||
// Serve starts a worker serving from the internal map.
|
||||
func (w *Workers) Serve(id string) {
|
||||
w.RLock()
|
||||
defer w.RUnlock()
|
||||
worker := w.internal[id]
|
||||
|
||||
go func() {
|
||||
worker.Serve()
|
||||
worker.OneTime()
|
||||
}()
|
||||
}
|
||||
|
||||
// ServeAll starts all workers serving from the internal map.
|
||||
func (w *Workers) ServeAll() {
|
||||
w.RLock()
|
||||
i := 0
|
||||
ids := make([]string, len(w.internal))
|
||||
for id := range w.internal {
|
||||
ids[i] = id
|
||||
i++
|
||||
}
|
||||
w.RUnlock()
|
||||
|
||||
for _, id := range ids {
|
||||
w.Serve(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops a worker from the internal map.
|
||||
func (w *Workers) Close(id string) {
|
||||
w.RLock()
|
||||
defer w.RUnlock()
|
||||
if worker, ok := w.internal[id]; ok {
|
||||
worker.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// CloseAll iterates and closes all registered workere.
|
||||
func (w *Workers) CloseAll() {
|
||||
w.RLock()
|
||||
i := 0
|
||||
ids := make([]string, len(w.internal))
|
||||
for id := range w.internal {
|
||||
ids[i] = id
|
||||
i++
|
||||
}
|
||||
w.RUnlock()
|
||||
|
||||
for _, id := range ids {
|
||||
w.Close(id)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user