properly implemented and tested usage of proxy in telegram, updated config to require all necessary fields, updated actions with new interface and module to use DI for ease of outer usage, renamed and updated jlog package to be trace, since it's basically what it does, isolated user context logic and methods of his retrieval in separate package
This commit is contained in:
+2
-1
@@ -32,4 +32,5 @@ go.work.sum
|
|||||||
.vscode/
|
.vscode/
|
||||||
jules
|
jules
|
||||||
.env
|
.env
|
||||||
.ENV
|
.ENV
|
||||||
|
secret
|
||||||
@@ -5,11 +5,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/d1nch8g/jules/chat"
|
"github.com/d1nch8g/jules/chat"
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const BaseURL = tgbotapi.APIEndpoint
|
const BaseURL = tgbotapi.APIEndpoint
|
||||||
@@ -18,11 +20,23 @@ type Bot struct {
|
|||||||
api *tgbotapi.BotAPI
|
api *tgbotapi.BotAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(token, baseURL string) (*Bot, error) {
|
func New(token, baseURL, proxyURL string) (*Bot, error) {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if proxyURL != "" {
|
||||||
|
proxyURL, _ := url.Parse(proxyURL)
|
||||||
|
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.Transport = &http.Transport{
|
||||||
|
Dial: dialer.Dial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
api, err := tgbotapi.NewBotAPIWithClient(token, baseURL, client)
|
api, err := tgbotapi.NewBotAPIWithClient(token, baseURL, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to telegram: %w", err)
|
return nil, fmt.Errorf("failed to connect to telegram: %w", err)
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package telegram
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/armon/go-socks5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -71,6 +74,26 @@ func (m *mockTelegramServer) handler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startMockSocks5Server(t *testing.T) (string, func()) {
|
||||||
|
conf := &socks5.Config{}
|
||||||
|
server, err := socks5.New(conf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
port := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", port)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return proxyURL, func() {
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newMockServer() (*httptest.Server, *mockTelegramServer) {
|
func newMockServer() (*httptest.Server, *mockTelegramServer) {
|
||||||
mock := &mockTelegramServer{}
|
mock := &mockTelegramServer{}
|
||||||
return httptest.NewServer(mock.handler()), mock
|
return httptest.NewServer(mock.handler()), mock
|
||||||
@@ -78,7 +101,7 @@ func newMockServer() (*httptest.Server, *mockTelegramServer) {
|
|||||||
|
|
||||||
func newTestBot(t *testing.T, server *httptest.Server) *Bot {
|
func newTestBot(t *testing.T, server *httptest.Server) *Bot {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
bot, err := New("test_token", server.URL+"/bot%s/%s")
|
bot, err := New("test_token", server.URL+"/bot%s/%s", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
@@ -95,7 +118,7 @@ func TestNew_Error(t *testing.T) {
|
|||||||
server, _ := newMockServer()
|
server, _ := newMockServer()
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
_, err := New("test_token", server.URL+"/invalid")
|
_, err := New("test_token", server.URL+"/invalid", "")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +133,7 @@ func TestNew_GetMeError(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
_, err := New("test_token", server.URL+"/bot%s/%s")
|
_, err := New("test_token", server.URL+"/bot%s/%s", "")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to connect to telegram")
|
assert.Contains(t, err.Error(), "failed to connect to telegram")
|
||||||
}
|
}
|
||||||
@@ -194,3 +217,34 @@ func TestReceive_ContextCancelDuringSend(t *testing.T) {
|
|||||||
t.Fatal("channel not closed")
|
t.Fatal("channel not closed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNew_WithProxy(t *testing.T) {
|
||||||
|
proxyURL, cleanup := startMockSocks5Server(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
server, _ := newMockServer()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
bot, err := New("test_token", server.URL+"/bot%s/%s", proxyURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, bot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_WithProxy_InvalidURL(t *testing.T) {
|
||||||
|
_, err := New("test_token", "http://api.telegram.org", "invalid-url")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_WithProxy_ConnectionRefused(t *testing.T) {
|
||||||
|
_, err := New("test_token", "http://api.telegram.org", "socks5://localhost:1081")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_WithoutProxy(t *testing.T) {
|
||||||
|
server, _ := newMockServer()
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
bot, err := New("test_token", server.URL+"/bot%s/%s", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, bot)
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -30,7 +30,7 @@ func main() {
|
|||||||
exit("failed to initialize configuration", err)
|
exit("failed to initialize configuration", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
telegram, err := telegram.New(cfg.TelegramBotToken, telegram.BaseURL)
|
telegram, err := telegram.New(cfg.TelegramBotToken, telegram.BaseURL, cfg.TelegramProxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit("failed to initialize telegram", err)
|
exit("failed to initialize telegram", err)
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,8 @@ func main() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
slog.Info("starting...")
|
||||||
|
|
||||||
if err := e.Run(ctx); err != nil {
|
if err := e.Run(ctx); err != nil {
|
||||||
exit("run failed", err)
|
exit("run failed", err)
|
||||||
}
|
}
|
||||||
@@ -64,7 +66,6 @@ func initLogger() {
|
|||||||
slog.SetDefault(slog.New(tint.NewHandler(os.Stderr, &tint.Options{
|
slog.SetDefault(slog.New(tint.NewHandler(os.Stderr, &tint.Options{
|
||||||
Level: slog.LevelDebug,
|
Level: slog.LevelDebug,
|
||||||
TimeFormat: "15:04:05.000",
|
TimeFormat: "15:04:05.000",
|
||||||
AddSource: true,
|
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-8
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
TelegramBotToken string
|
TelegramBotToken string
|
||||||
|
TelegramProxy string
|
||||||
PostgresConnString string
|
PostgresConnString string
|
||||||
DeepSeekAPIKey string
|
DeepSeekAPIKey string
|
||||||
BraveAPIKey string
|
BraveAPIKey string
|
||||||
@@ -15,24 +16,24 @@ type Config struct {
|
|||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
||||||
|
TelegramProxy: os.Getenv("TELEGRAM_PROXY"),
|
||||||
DeepSeekAPIKey: os.Getenv("DEEPSEEK_API_KEY"),
|
DeepSeekAPIKey: os.Getenv("DEEPSEEK_API_KEY"),
|
||||||
BraveAPIKey: os.Getenv("BRAVE_API_KEY"),
|
BraveAPIKey: os.Getenv("BRAVE_API_KEY"),
|
||||||
PostgresConnString: getEnv("POSTGRES_CONN_STRING", "postgres://user:password@localhost:5432/db?sslmode=disable"),
|
PostgresConnString: os.Getenv("POSTGRES_CONN_STRING"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.DeepSeekAPIKey == "" {
|
||||||
|
return nil, errors.New("DEEPSEEK_API_KEY is required")
|
||||||
|
}
|
||||||
if cfg.TelegramBotToken == "" {
|
if cfg.TelegramBotToken == "" {
|
||||||
return nil, errors.New("TELEGRAM_BOT_TOKEN is required")
|
return nil, errors.New("TELEGRAM_BOT_TOKEN is required")
|
||||||
}
|
}
|
||||||
if cfg.BraveAPIKey == "" {
|
if cfg.BraveAPIKey == "" {
|
||||||
return nil, errors.New("BRAVE_API_KEY is required")
|
return nil, errors.New("BRAVE_API_KEY is required")
|
||||||
}
|
}
|
||||||
|
if cfg.PostgresConnString == "" {
|
||||||
|
return nil, errors.New("POSTGRES_CONN_STRING is required")
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
|
||||||
if value := os.Getenv(key); value != "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|||||||
+37
-43
@@ -3,6 +3,9 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoad(t *testing.T) {
|
func TestLoad(t *testing.T) {
|
||||||
@@ -15,46 +18,61 @@ func TestLoad(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "all required set",
|
name: "all required set",
|
||||||
env: map[string]string{
|
env: map[string]string{
|
||||||
"TELEGRAM_BOT_TOKEN": "tg",
|
"TELEGRAM_BOT_TOKEN": "tg",
|
||||||
"DEEPSEEK_API_KEY": "ds",
|
"DEEPSEEK_API_KEY": "ds",
|
||||||
"BRAVE_API_KEY": "br",
|
"BRAVE_API_KEY": "br",
|
||||||
|
"POSTGRES_CONN_STRING": "pg://localhost/db",
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing telegram token",
|
name: "missing telegram token",
|
||||||
env: map[string]string{
|
env: map[string]string{
|
||||||
"DEEPSEEK_API_KEY": "ds",
|
"DEEPSEEK_API_KEY": "ds",
|
||||||
"BRAVE_API_KEY": "br",
|
"BRAVE_API_KEY": "br",
|
||||||
|
"POSTGRES_CONN_STRING": "pg://localhost/db",
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errMsg: "TELEGRAM_BOT_TOKEN is required",
|
errMsg: "TELEGRAM_BOT_TOKEN is required",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "missing deepseek key",
|
||||||
|
env: map[string]string{
|
||||||
|
"TELEGRAM_BOT_TOKEN": "tg",
|
||||||
|
"BRAVE_API_KEY": "br",
|
||||||
|
"POSTGRES_CONN_STRING": "pg://localhost/db",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "DEEPSEEK_API_KEY is required",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "missing brave key",
|
name: "missing brave key",
|
||||||
env: map[string]string{
|
env: map[string]string{
|
||||||
"TELEGRAM_BOT_TOKEN": "tg",
|
"TELEGRAM_BOT_TOKEN": "tg",
|
||||||
"DEEPSEEK_API_KEY": "ds",
|
"DEEPSEEK_API_KEY": "ds",
|
||||||
|
"POSTGRES_CONN_STRING": "pg://localhost/db",
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errMsg: "BRAVE_API_KEY is required",
|
errMsg: "BRAVE_API_KEY is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "default postgres conn string",
|
name: "missing postgres conn string",
|
||||||
env: map[string]string{
|
env: map[string]string{
|
||||||
"TELEGRAM_BOT_TOKEN": "tg",
|
"TELEGRAM_BOT_TOKEN": "tg",
|
||||||
"DEEPSEEK_API_KEY": "ds",
|
"DEEPSEEK_API_KEY": "ds",
|
||||||
"BRAVE_API_KEY": "br",
|
"BRAVE_API_KEY": "br",
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: true,
|
||||||
|
errMsg: "POSTGRES_CONN_STRING is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "override postgres conn string",
|
name: "telegram proxy optional",
|
||||||
env: map[string]string{
|
env: map[string]string{
|
||||||
"TELEGRAM_BOT_TOKEN": "tg",
|
"TELEGRAM_BOT_TOKEN": "tg",
|
||||||
"DEEPSEEK_API_KEY": "ds",
|
"DEEPSEEK_API_KEY": "ds",
|
||||||
"BRAVE_API_KEY": "br",
|
"BRAVE_API_KEY": "br",
|
||||||
"POSTGRES_CONN_STRING": "pg://custom",
|
"POSTGRES_CONN_STRING": "pg://localhost/db",
|
||||||
|
"TELEGRAM_PROXY": "socks5://localhost:1080",
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
@@ -69,41 +87,17 @@ func TestLoad(t *testing.T) {
|
|||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
if err == nil {
|
require.Error(t, err)
|
||||||
t.Fatal("expected error, got nil")
|
assert.Contains(t, err.Error(), tt.errMsg)
|
||||||
}
|
|
||||||
if tt.errMsg != "" && err.Error() != tt.errMsg {
|
|
||||||
t.Errorf("expected error %q, got %q", tt.errMsg, err.Error())
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
if err != nil {
|
assert.Equal(t, tt.env["TELEGRAM_BOT_TOKEN"], cfg.TelegramBotToken)
|
||||||
t.Fatalf("unexpected error: %v", err)
|
assert.Equal(t, tt.env["DEEPSEEK_API_KEY"], cfg.DeepSeekAPIKey)
|
||||||
}
|
assert.Equal(t, tt.env["BRAVE_API_KEY"], cfg.BraveAPIKey)
|
||||||
|
assert.Equal(t, tt.env["POSTGRES_CONN_STRING"], cfg.PostgresConnString)
|
||||||
expectedTG := tt.env["TELEGRAM_BOT_TOKEN"]
|
assert.Equal(t, tt.env["TELEGRAM_PROXY"], cfg.TelegramProxy)
|
||||||
if cfg.TelegramBotToken != expectedTG {
|
|
||||||
t.Errorf("TelegramBotToken: expected %q, got %q", expectedTG, cfg.TelegramBotToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedDS := tt.env["DEEPSEEK_API_KEY"]
|
|
||||||
if cfg.DeepSeekAPIKey != expectedDS {
|
|
||||||
t.Errorf("DeepSeekAPIKey: expected %q, got %q", expectedDS, cfg.DeepSeekAPIKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedBR := tt.env["BRAVE_API_KEY"]
|
|
||||||
if cfg.BraveAPIKey != expectedBR {
|
|
||||||
t.Errorf("BraveAPIKey: expected %q, got %q", expectedBR, cfg.BraveAPIKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedPG := tt.env["POSTGRES_CONN_STRING"]
|
|
||||||
if expectedPG == "" {
|
|
||||||
expectedPG = "postgres://user:password@localhost:5432/db?sslmode=disable"
|
|
||||||
}
|
|
||||||
if cfg.PostgresConnString != expectedPG {
|
|
||||||
t.Errorf("PostgresConnString: expected %q, got %q", expectedPG, cfg.PostgresConnString)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
package engine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/d1nch8g/jules/database"
|
|
||||||
"github.com/d1nch8g/jules/engine/actions"
|
|
||||||
"github.com/d1nch8g/jules/engine/jtime"
|
|
||||||
"github.com/d1nch8g/jules/engine/prompt"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *Engine) validateActions(ctx context.Context, actionSlice []any, promptCtx prompt.Context) error {
|
|
||||||
var errs []error
|
|
||||||
|
|
||||||
for _, actionAny := range actionSlice {
|
|
||||||
switch action := actionAny.(type) {
|
|
||||||
case actions.AddContact:
|
|
||||||
_, err := e.Database.Users().Get(ctx, uuid.MustParse(action.UUID), database.UserLookupByContactCode)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, database.ErrNotFound) {
|
|
||||||
errs = append(errs, fmt.Errorf("user with requested uuid %s not present in database, uuid might be wrong", action.UUID))
|
|
||||||
} else {
|
|
||||||
errs = append(errs, errors.New("unexpected db occured"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.AddNotification:
|
|
||||||
if action.Target == "self" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
found := slices.ContainsFunc(promptCtx.Contacts, func(c database.Contact) bool {
|
|
||||||
return c.Name == action.Target
|
|
||||||
})
|
|
||||||
if !found {
|
|
||||||
errs = append(errs, fmt.Errorf("contact target %s is invalid, provide exact name", action.Target))
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.BindChat:
|
|
||||||
_, err := e.Database.Users().Get(ctx, uuid.MustParse(action.UUID), database.UserLookupByBindCode)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, database.ErrNotFound) {
|
|
||||||
errs = append(errs, fmt.Errorf("user with requested uuid %s not present in database, uuid might be wrong", action.UUID))
|
|
||||||
} else {
|
|
||||||
errs = append(errs, errors.New("unexpected db occured"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.Message:
|
|
||||||
found := slices.ContainsFunc(promptCtx.Chats, func(c database.Chat) bool {
|
|
||||||
return action.Platform == c.Platform
|
|
||||||
})
|
|
||||||
if !found {
|
|
||||||
errs = append(errs, fmt.Errorf("message is invalid, platform %s might not connected for user", action.Platform))
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.SetChat:
|
|
||||||
found := slices.ContainsFunc(promptCtx.Chats, func(c database.Chat) bool {
|
|
||||||
return action.Chat == c.Platform
|
|
||||||
})
|
|
||||||
if !found {
|
|
||||||
errs = append(errs, fmt.Errorf("chat %s might not connected for user, not found", action.Chat))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.Join(errs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) runActions(ctx context.Context, actionSlice []any, user *database.User, promptCtx *prompt.Context) error {
|
|
||||||
for _, actionAny := range actionSlice {
|
|
||||||
switch action := actionAny.(type) {
|
|
||||||
case actions.BindChat:
|
|
||||||
targetUser, err := e.Database.Users().Get(ctx, uuid.MustParse(action.UUID), database.UserLookupByBindCode)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = e.Database.Users().Delete(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var errs []error
|
|
||||||
|
|
||||||
for _, chat := range promptCtx.Chats {
|
|
||||||
errs = append(errs, e.Database.Chats().Attach(ctx, targetUser.ID, chat.Platform, chat.Identifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, contact := range promptCtx.Contacts {
|
|
||||||
errs = append(errs, e.Database.Contacts().Add(ctx, &database.Contact{
|
|
||||||
OwnerID: targetUser.ID,
|
|
||||||
TargetID: contact.TargetID,
|
|
||||||
Name: contact.Name,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fact := range promptCtx.Facts {
|
|
||||||
errs = append(errs, e.Database.Facts().Add(ctx, targetUser.ID, fact.Value))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, notif := range promptCtx.IncomingNotifications {
|
|
||||||
if notif.InitiatorID == user.ID {
|
|
||||||
notif.InitiatorID = targetUser.ID
|
|
||||||
}
|
|
||||||
errs = append(errs, e.Database.Notifications().Push(ctx, &database.Notification{
|
|
||||||
ID: notif.ID,
|
|
||||||
UserID: targetUser.ID,
|
|
||||||
InitiatorID: notif.InitiatorID,
|
|
||||||
ScheduledAt: notif.ScheduledAt,
|
|
||||||
Content: notif.Content,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, notif := range promptCtx.OutgoingNotificaions {
|
|
||||||
errs = append(errs, e.Database.Notifications().Push(ctx, &database.Notification{
|
|
||||||
ID: notif.ID,
|
|
||||||
UserID: notif.UserID,
|
|
||||||
InitiatorID: targetUser.ID,
|
|
||||||
ScheduledAt: notif.ScheduledAt,
|
|
||||||
Content: notif.Content,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = errors.Join(errs...); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
user = targetUser
|
|
||||||
|
|
||||||
case actions.Message:
|
|
||||||
var platformID string
|
|
||||||
for _, chat := range promptCtx.Chats {
|
|
||||||
if action.Platform == chat.Platform {
|
|
||||||
platformID = chat.Identifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := e.Chats[action.Platform].Send(ctx, platformID, action.Text); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.Wait:
|
|
||||||
time.Sleep(time.Duration(action.Ms) * time.Millisecond)
|
|
||||||
|
|
||||||
case actions.UpdateLang:
|
|
||||||
user.Language = action.Lang
|
|
||||||
if err := e.Database.Users().Update(ctx, user); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.UpdateTZ:
|
|
||||||
user.Timezone = action.TZ
|
|
||||||
if err := e.Database.Users().Update(ctx, user); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.SetChat:
|
|
||||||
user.PreferredChat = action.Chat
|
|
||||||
if err := e.Database.Users().Update(ctx, user); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.AddFact:
|
|
||||||
if err := e.Database.Facts().Add(ctx, user.ID, action.Value); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.RemoveFact:
|
|
||||||
if err := e.Database.Facts().Delete(ctx, user.ID, action.Value); err != nil {
|
|
||||||
if errors.Is(err, database.ErrNotFound) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.AddContact:
|
|
||||||
contactUser, err := e.Database.Users().Get(ctx, uuid.MustParse(action.UUID), database.UserLookupByContactCode)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = e.Database.Contacts().Add(ctx, &database.Contact{
|
|
||||||
OwnerID: user.ID,
|
|
||||||
TargetID: contactUser.ID,
|
|
||||||
Name: action.Name,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.AddNotification:
|
|
||||||
t, _ := jtime.ToUTC(action.Time, user.Timezone)
|
|
||||||
initiatorID := user.ID
|
|
||||||
if action.Target != "self" {
|
|
||||||
for _, contact := range promptCtx.Contacts {
|
|
||||||
initiatorID = contact.TargetID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := e.Database.Notifications().Push(ctx, &database.Notification{
|
|
||||||
ID: uuid.New(),
|
|
||||||
UserID: user.ID,
|
|
||||||
InitiatorID: initiatorID,
|
|
||||||
ScheduledAt: t,
|
|
||||||
Content: action.Content,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.RemoveNotification:
|
|
||||||
err := e.Database.Notifications().Delete(ctx, uuid.MustParse(action.UUID))
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, database.ErrNotFound) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case actions.Search:
|
|
||||||
// TODO: currently not supported
|
|
||||||
}
|
|
||||||
|
|
||||||
err := e.Database.Actions().Log(ctx, &database.Action{
|
|
||||||
UserID: user.ID,
|
|
||||||
ExecutedAt: time.Now(),
|
|
||||||
Payload: actions.Raw(actionAny),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
+325
-159
@@ -1,52 +1,50 @@
|
|||||||
package actions //nolint:cyclop // optimized action parser
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/d1nch8g/jules/chat"
|
"github.com/d1nch8g/jules/chat"
|
||||||
|
"github.com/d1nch8g/jules/database"
|
||||||
"github.com/d1nch8g/jules/engine/jtime"
|
"github.com/d1nch8g/jules/engine/jtime"
|
||||||
|
"github.com/d1nch8g/jules/engine/user"
|
||||||
|
"github.com/d1nch8g/jules/search"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"golang.org/x/text/language"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Important notice - when adding an action, don't forget to add it to:
|
const ActionsPromptPart = `
|
||||||
//
|
=== AVAILABLE ACTIONS ===
|
||||||
// 1) Constants in that package
|
{"type": "message", "platform": "telegram", "text": "short response"}
|
||||||
// 2) Parser in that package
|
{"type": "wait", "ms": 100-600}
|
||||||
// 3) Parser tests in that package
|
{"type": "add_fact", "value": "fact about user"}
|
||||||
// 4) Prompt, since it is a raw string
|
{"type": "remove_fact", "value": "fact to remove"}
|
||||||
// 5) Processing logic, in an engine module
|
{"type": "add_notification", "target": "self", "time": "...", "content": "... (repetition rules)"}
|
||||||
//
|
{"type": "add_notification", "target": "contact name", "time": "...", "content": "... (repetition rules)"}
|
||||||
// TODO: rewrite module in such a way, that every action related stuff
|
{"type": "remove_notification", "uuid": "123e4567-e89b-12d3-a456-426614174000"}
|
||||||
// is processed within this module, not elsewhere, and so that it provides
|
{"type": "add_contact", "uuid": "123e4567-e89b-12d3-a456-426614174000", "name": "Contact Name"}
|
||||||
// necessary components for other modules.
|
{"type": "bind_chat", "uuid": "123e4567-e89b-12d3-a456-426614174000"}
|
||||||
|
{"type": "update_lang", "lang": "ru"}
|
||||||
|
{"type": "update_tz", "tz": "Europe/Moscow"}
|
||||||
|
{"type": "set_chat", "chat": "telegram"}
|
||||||
|
`
|
||||||
|
|
||||||
const (
|
// {"type": "search", "query": "search query"} // temporary disabled
|
||||||
ActionUserMessage = "user_message"
|
|
||||||
ActionBindChat = "bind_chat"
|
|
||||||
ActionMessage = "message"
|
|
||||||
ActionWait = "wait"
|
|
||||||
ActionUpdateLang = "update_lang"
|
|
||||||
ActionUpdateTZ = "update_tz"
|
|
||||||
ActionSetChat = "set_chat"
|
|
||||||
ActionAddFact = "add_fact"
|
|
||||||
ActionRemoveFact = "remove_fact"
|
|
||||||
ActionAddContact = "add_contact"
|
|
||||||
ActionAddNotification = "add_notification"
|
|
||||||
ActionRemoveNotification = "remove_notification"
|
|
||||||
ActionSearch = "search"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserMessage struct {
|
type Action interface {
|
||||||
Type string `json:"type"`
|
Validate(ctx context.Context, rt *Runtime) error
|
||||||
Message string `json:"message"`
|
Execute(ctx context.Context, rt *Runtime) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Runtime struct {
|
||||||
|
User *user.User
|
||||||
|
Database database.Database
|
||||||
|
Searcher search.Searcher
|
||||||
|
Chats map[string]chat.Chat
|
||||||
}
|
}
|
||||||
|
|
||||||
type BindChat struct {
|
type BindChat struct {
|
||||||
@@ -54,48 +52,243 @@ type BindChat struct {
|
|||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a BindChat) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
|
if a.UUID == "" {
|
||||||
|
return errors.New("target_uuid is required")
|
||||||
|
}
|
||||||
|
if _, err := uuid.Parse(a.UUID); err != nil {
|
||||||
|
return fmt.Errorf("target_uuid must be valid UUID: %w", err)
|
||||||
|
}
|
||||||
|
_, err := rt.Database.Users().Get(ctx, uuid.MustParse(a.UUID), database.UserLookupByBindCode)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrNotFound) {
|
||||||
|
return fmt.Errorf("user with bind code %s not found", a.UUID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a BindChat) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
targetUser, err := rt.Database.Users().Get(ctx, uuid.MustParse(a.UUID), database.UserLookupByBindCode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u := rt.User
|
||||||
|
|
||||||
|
if err := rt.Database.Users().Delete(ctx, u.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chat := range u.Chats {
|
||||||
|
if err := rt.Database.Chats().Attach(ctx, targetUser.ID, chat.Platform, chat.Identifier); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, contact := range u.Contacts {
|
||||||
|
if err := rt.Database.Contacts().Add(ctx, &database.Contact{
|
||||||
|
OwnerID: targetUser.ID,
|
||||||
|
TargetID: contact.TargetID,
|
||||||
|
Name: contact.Name,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, fact := range u.Facts {
|
||||||
|
if err := rt.Database.Facts().Add(ctx, targetUser.ID, fact.Value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notif := range u.IncomingNotifications {
|
||||||
|
if notif.InitiatorID == u.ID {
|
||||||
|
notif.InitiatorID = targetUser.ID
|
||||||
|
}
|
||||||
|
if err := rt.Database.Notifications().Push(ctx, ¬if); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notif := range u.OutgoingNotifications {
|
||||||
|
if err := rt.Database.Notifications().Push(ctx, ¬if); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a Message) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
|
if a.Text == "" {
|
||||||
|
return errors.New("text is empty")
|
||||||
|
}
|
||||||
|
for _, c := range rt.User.Chats {
|
||||||
|
if c.Platform == a.Platform {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("platform %s not connected", a.Platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Message) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
var platformID string
|
||||||
|
for _, c := range rt.User.Chats {
|
||||||
|
if c.Platform == a.Platform {
|
||||||
|
platformID = c.Identifier
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rt.Chats[a.Platform].Send(ctx, platformID, a.Text)
|
||||||
|
}
|
||||||
|
|
||||||
type Wait struct {
|
type Wait struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Ms int `json:"ms"`
|
Ms int `json:"ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a Wait) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
|
if a.Ms <= 0 {
|
||||||
|
return errors.New("ms must be positive")
|
||||||
|
}
|
||||||
|
if a.Ms > 60000 {
|
||||||
|
return errors.New("ms cannot exceed 60000")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Wait) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
time.Sleep(time.Duration(a.Ms) * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateLang struct {
|
type UpdateLang struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Lang string `json:"lang"`
|
Lang string `json:"lang"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a UpdateLang) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
|
if a.Lang == "" {
|
||||||
|
return errors.New("lang is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a UpdateLang) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
rt.User.Language = a.Lang
|
||||||
|
return rt.Database.Users().Update(ctx, rt.User.User)
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateTZ struct {
|
type UpdateTZ struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
TZ string `json:"tz"`
|
TZ string `json:"tz"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a UpdateTZ) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
|
if a.TZ == "" {
|
||||||
|
return errors.New("tz is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a UpdateTZ) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
rt.User.Timezone = a.TZ
|
||||||
|
return rt.Database.Users().Update(ctx, rt.User.User)
|
||||||
|
}
|
||||||
|
|
||||||
type SetChat struct {
|
type SetChat struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Chat string `json:"chat"`
|
Chat string `json:"chat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a SetChat) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
|
if a.Chat == "" {
|
||||||
|
return errors.New("chat is required")
|
||||||
|
}
|
||||||
|
for _, c := range rt.User.Chats {
|
||||||
|
if c.Platform == a.Chat {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("chat %s not connected", a.Chat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a SetChat) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
rt.User.PreferredChat = a.Chat
|
||||||
|
return rt.Database.Users().Update(ctx, rt.User.User)
|
||||||
|
}
|
||||||
|
|
||||||
type AddFact struct {
|
type AddFact struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a AddFact) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
|
if a.Value == "" {
|
||||||
|
return errors.New("value is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AddFact) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
return rt.Database.Facts().Add(ctx, rt.User.ID, a.Value)
|
||||||
|
}
|
||||||
|
|
||||||
type RemoveFact struct {
|
type RemoveFact struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a RemoveFact) Validate(ctx context.Context, rt *Runtime) error { return nil }
|
||||||
|
|
||||||
|
func (a RemoveFact) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
err := rt.Database.Facts().Delete(ctx, rt.User.ID, a.Value)
|
||||||
|
if errors.Is(err, database.ErrNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type AddContact struct {
|
type AddContact struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a AddContact) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
|
if a.UUID == "" {
|
||||||
|
return errors.New("uuid is required")
|
||||||
|
}
|
||||||
|
if _, err := uuid.Parse(a.UUID); err != nil {
|
||||||
|
return fmt.Errorf("uuid must be valid UUID: %w", err)
|
||||||
|
}
|
||||||
|
if a.Name == "" {
|
||||||
|
return errors.New("name is required")
|
||||||
|
}
|
||||||
|
_, err := rt.Database.Users().Get(ctx, uuid.MustParse(a.UUID), database.UserLookupByContactCode)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrNotFound) {
|
||||||
|
return fmt.Errorf("user with contact code %s not found", a.UUID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AddContact) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
contactUser, err := rt.Database.Users().Get(ctx, uuid.MustParse(a.UUID), database.UserLookupByContactCode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rt.Database.Contacts().Add(ctx, &database.Contact{
|
||||||
|
OwnerID: rt.User.ID,
|
||||||
|
TargetID: contactUser.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type AddNotification struct {
|
type AddNotification struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
@@ -103,178 +296,147 @@ type AddNotification struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a AddNotification) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
|
if a.Target == "" {
|
||||||
|
return errors.New("target is required")
|
||||||
|
}
|
||||||
|
if a.Time == "" {
|
||||||
|
return errors.New("time is required")
|
||||||
|
}
|
||||||
|
if a.Content == "" {
|
||||||
|
return errors.New("content is required")
|
||||||
|
}
|
||||||
|
if _, err := jtime.ToUTC(a.Time, rt.User.Timezone); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.Target == "self" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, c := range rt.User.Contacts {
|
||||||
|
if c.Name == a.Target {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("contact %s not found", a.Target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AddNotification) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
scheduledAt, _ := jtime.ToUTC(a.Time, rt.User.Timezone)
|
||||||
|
initiatorID := rt.User.ID
|
||||||
|
targetID := rt.User.ID
|
||||||
|
if a.Target != "self" {
|
||||||
|
for _, c := range rt.User.Contacts {
|
||||||
|
if c.Name == a.Target {
|
||||||
|
initiatorID = c.TargetID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rt.Database.Notifications().Push(ctx, &database.Notification{
|
||||||
|
ID: uuid.New(),
|
||||||
|
UserID: targetID,
|
||||||
|
InitiatorID: initiatorID,
|
||||||
|
ScheduledAt: scheduledAt,
|
||||||
|
Content: a.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type RemoveNotification struct {
|
type RemoveNotification struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Search struct {
|
func (a RemoveNotification) Validate(ctx context.Context, rt *Runtime) error {
|
||||||
Type string `json:"type"`
|
if a.UUID == "" {
|
||||||
Query string `json:"query"`
|
return errors.New("uuid is required")
|
||||||
|
}
|
||||||
|
if _, err := uuid.Parse(a.UUID); err != nil {
|
||||||
|
return fmt.Errorf("uuid must be valid UUID: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Parse(raw string, userTimezone string) ([]any, error) { //nolint:gocognit,gocyclo,cyclop,funlen // single function for optimization
|
func (a RemoveNotification) Execute(ctx context.Context, rt *Runtime) error {
|
||||||
|
err := rt.Database.Notifications().Delete(ctx, uuid.MustParse(a.UUID))
|
||||||
|
if errors.Is(err, database.ErrNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(raw string, userTimezone string) ([]Action, error) {
|
||||||
start := strings.Index(raw, "[")
|
start := strings.Index(raw, "[")
|
||||||
end := strings.LastIndex(raw, "]")
|
end := strings.LastIndex(raw, "]")
|
||||||
if start != -1 && end != -1 && end > start {
|
if start != -1 && end != -1 && end > start {
|
||||||
raw = raw[start : end+1]
|
raw = raw[start : end+1]
|
||||||
}
|
}
|
||||||
|
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
|
|
||||||
if !gjson.Valid(raw) {
|
if !gjson.Valid(raw) {
|
||||||
return nil, errors.New("response is not valid JSON")
|
return nil, errors.New("response is not valid JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
result := gjson.Parse(raw)
|
result := gjson.Parse(raw)
|
||||||
if !result.IsArray() {
|
if !result.IsArray() {
|
||||||
return nil, errors.New("response must be an array of actions")
|
return nil, errors.New("response must be an array of actions")
|
||||||
}
|
}
|
||||||
|
|
||||||
var actions []any
|
var actions []Action
|
||||||
for i, item := range result.Array() {
|
for i, item := range result.Array() {
|
||||||
actionType := item.Get("type").String()
|
actionType := item.Get("type").String()
|
||||||
if actionType == "" {
|
if actionType == "" {
|
||||||
return nil, fmt.Errorf("action %d: missing required field 'type'", i)
|
return nil, fmt.Errorf("action %d: missing required field 'type'", i)
|
||||||
}
|
}
|
||||||
|
|
||||||
var action any
|
var action Action
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch actionType {
|
switch actionType {
|
||||||
case ActionBindChat:
|
case "bind_chat":
|
||||||
var a BindChat
|
var a BindChat
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if a.UUID == "" {
|
|
||||||
err = errors.New("target_uuid is required")
|
|
||||||
} else if _, e := uuid.Parse(a.UUID); e != nil {
|
|
||||||
err = fmt.Errorf("target_uuid must be valid UUID: %w", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "message":
|
||||||
case ActionMessage:
|
|
||||||
var a Message
|
var a Message
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if !slices.Contains(chat.PlatformList, a.Platform) {
|
|
||||||
err = errors.New("platform is unsupported")
|
|
||||||
} else if a.Text == "" {
|
|
||||||
err = errors.New("text is empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "wait":
|
||||||
case ActionWait:
|
|
||||||
var a Wait
|
var a Wait
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if a.Ms <= 0 {
|
|
||||||
err = errors.New("ms must be positive")
|
|
||||||
} else if a.Ms > 60000 {
|
|
||||||
err = errors.New("ms cannot exceed 60000 (1 minute)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "update_lang":
|
||||||
case ActionUpdateLang:
|
|
||||||
var a UpdateLang
|
var a UpdateLang
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if a.Lang == "" {
|
|
||||||
err = errors.New("lang is required")
|
|
||||||
} else if _, e := language.Parse(a.Lang); e != nil {
|
|
||||||
err = fmt.Errorf("invalid language code: %w", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "update_tz":
|
||||||
case ActionUpdateTZ:
|
|
||||||
var a UpdateTZ
|
var a UpdateTZ
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if a.TZ == "" {
|
|
||||||
err = errors.New("tz is required")
|
|
||||||
} else if _, e := time.LoadLocation(a.TZ); e != nil {
|
|
||||||
err = fmt.Errorf("tz must be valid timezone: %w", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "set_chat":
|
||||||
case ActionSetChat:
|
|
||||||
var a SetChat
|
var a SetChat
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if a.Chat == "" {
|
|
||||||
err = errors.New("chat is required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "add_fact":
|
||||||
case ActionAddFact:
|
|
||||||
var a AddFact
|
var a AddFact
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if a.Value == "" {
|
|
||||||
err = errors.New("value is required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "remove_fact":
|
||||||
case ActionRemoveFact:
|
|
||||||
var a RemoveFact
|
var a RemoveFact
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if a.Value == "" {
|
|
||||||
err = errors.New("value is required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "add_contact":
|
||||||
case ActionAddContact:
|
|
||||||
var a AddContact
|
var a AddContact
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if a.UUID == "" {
|
|
||||||
err = errors.New("uuid is required")
|
|
||||||
} else if _, e := uuid.Parse(a.UUID); e != nil {
|
|
||||||
err = fmt.Errorf("uuid must be valid UUID: %w", e)
|
|
||||||
} else if a.Name == "" {
|
|
||||||
err = errors.New("name is required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "add_notification":
|
||||||
case ActionAddNotification:
|
|
||||||
var a AddNotification
|
var a AddNotification
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
switch {
|
|
||||||
case a.Target == "":
|
|
||||||
err = errors.New("target is required")
|
|
||||||
case a.Time == "":
|
|
||||||
err = errors.New("time is required")
|
|
||||||
case a.Content == "":
|
|
||||||
err = errors.New("content is required")
|
|
||||||
default:
|
|
||||||
_, err = jtime.ToUTC(a.Time, userTimezone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
case "remove_notification":
|
||||||
case ActionRemoveNotification:
|
|
||||||
var a RemoveNotification
|
var a RemoveNotification
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
err = json.Unmarshal([]byte(item.Raw), &a)
|
||||||
if a.UUID == "" {
|
|
||||||
err = errors.New("uuid is required")
|
|
||||||
} else if _, e := uuid.Parse(a.UUID); e != nil {
|
|
||||||
err = fmt.Errorf("uuid must be valid UUID: %w", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
action = a
|
||||||
|
|
||||||
case ActionSearch:
|
|
||||||
var a Search
|
|
||||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
|
||||||
if a.Query == "" {
|
|
||||||
err = errors.New("query is required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action = a
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unknown action type: %s", actionType)
|
return nil, fmt.Errorf("action %d: unknown action type: %s", i, actionType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -282,15 +444,19 @@ func Parse(raw string, userTimezone string) ([]any, error) { //nolint:gocognit,g
|
|||||||
}
|
}
|
||||||
actions = append(actions, action)
|
actions = append(actions, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
return actions, nil
|
return actions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Raw(action any) json.RawMessage {
|
type UserAction struct {
|
||||||
b, err := json.Marshal(action)
|
Type string `json:"type"`
|
||||||
if err != nil {
|
Content string `json:"content"`
|
||||||
slog.Error("failed to marshal action to bytes", "error", err)
|
}
|
||||||
return json.RawMessage{}
|
|
||||||
}
|
func (UserAction) Validate(ctx context.Context, rt *Runtime) error { return nil }
|
||||||
return json.RawMessage(b)
|
|
||||||
|
func (UserAction) Execute(ctx context.Context, rt *Runtime) error { return nil }
|
||||||
|
|
||||||
|
func Raw(v Action) json.RawMessage {
|
||||||
|
data, _ := json.Marshal(v)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
+538
-404
@@ -1,25 +1,446 @@
|
|||||||
package actions
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/d1nch8g/jules/chat"
|
||||||
|
"github.com/d1nch8g/jules/database"
|
||||||
|
"github.com/d1nch8g/jules/engine/user"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseActions(t *testing.T) {
|
type mockDB struct {
|
||||||
|
users *mockUsers
|
||||||
|
chats *mockChats
|
||||||
|
facts *mockFacts
|
||||||
|
contacts *mockContacts
|
||||||
|
notifications *mockNotifications
|
||||||
|
actions *mockActions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDB) Users() database.Users { return m.users }
|
||||||
|
func (m *mockDB) Chats() database.Chats { return m.chats }
|
||||||
|
func (m *mockDB) Facts() database.Facts { return m.facts }
|
||||||
|
func (m *mockDB) Contacts() database.Contacts { return m.contacts }
|
||||||
|
func (m *mockDB) Notifications() database.Notifications { return m.notifications }
|
||||||
|
func (m *mockDB) Actions() database.Actions { return m.actions }
|
||||||
|
func (m *mockDB) Close() error { return nil }
|
||||||
|
|
||||||
|
type mockUsers struct {
|
||||||
|
getFunc func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error)
|
||||||
|
updateFunc func(ctx context.Context, u *database.User) error
|
||||||
|
deleteFunc func(ctx context.Context, id uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUsers) Get(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
if m.getFunc != nil {
|
||||||
|
return m.getFunc(ctx, id, lookup)
|
||||||
|
}
|
||||||
|
return &database.User{ID: id}, nil
|
||||||
|
}
|
||||||
|
func (m *mockUsers) Create(ctx context.Context, u *database.User) error { return nil }
|
||||||
|
func (m *mockUsers) Update(ctx context.Context, u *database.User) error {
|
||||||
|
if m.updateFunc != nil {
|
||||||
|
return m.updateFunc(ctx, u)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockUsers) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
if m.deleteFunc != nil {
|
||||||
|
return m.deleteFunc(ctx, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockChats struct {
|
||||||
|
attachFunc func(ctx context.Context, userID uuid.UUID, platform, identifier string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChats) Attach(ctx context.Context, userID uuid.UUID, platform, identifier string) error {
|
||||||
|
if m.attachFunc != nil {
|
||||||
|
return m.attachFunc(ctx, userID, platform, identifier)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockChats) Detach(ctx context.Context, userID uuid.UUID, platform string) error { return nil }
|
||||||
|
func (m *mockChats) GetUserID(ctx context.Context, platform, identifier string) (uuid.UUID, error) {
|
||||||
|
return uuid.Nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockChats) List(ctx context.Context, userID uuid.UUID) ([]database.Chat, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockFacts struct {
|
||||||
|
addFunc func(ctx context.Context, userID uuid.UUID, value string) error
|
||||||
|
deleteFunc func(ctx context.Context, userID uuid.UUID, value string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockFacts) Add(ctx context.Context, userID uuid.UUID, value string) error {
|
||||||
|
if m.addFunc != nil {
|
||||||
|
return m.addFunc(ctx, userID, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockFacts) List(ctx context.Context, userID uuid.UUID) ([]database.Fact, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockFacts) Delete(ctx context.Context, userID uuid.UUID, value string) error {
|
||||||
|
if m.deleteFunc != nil {
|
||||||
|
return m.deleteFunc(ctx, userID, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockContacts struct {
|
||||||
|
addFunc func(ctx context.Context, contact *database.Contact) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockContacts) Add(ctx context.Context, contact *database.Contact) error {
|
||||||
|
if m.addFunc != nil {
|
||||||
|
return m.addFunc(ctx, contact)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockContacts) List(ctx context.Context, ownerID uuid.UUID) ([]database.Contact, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockContacts) Delete(ctx context.Context, ownerID, targetID uuid.UUID) error { return nil }
|
||||||
|
|
||||||
|
type mockNotifications struct {
|
||||||
|
pushFunc func(ctx context.Context, n *database.Notification) error
|
||||||
|
deleteFunc func(ctx context.Context, id uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockNotifications) Push(ctx context.Context, n *database.Notification) error {
|
||||||
|
if m.pushFunc != nil {
|
||||||
|
return m.pushFunc(ctx, n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockNotifications) Pop(ctx context.Context, limit int) ([]database.Notification, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockNotifications) List(ctx context.Context, userID uuid.UUID) ([]database.Notification, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockNotifications) ListOutgoing(ctx context.Context, initiatorID uuid.UUID) ([]database.Notification, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockNotifications) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
if m.deleteFunc != nil {
|
||||||
|
return m.deleteFunc(ctx, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockActions struct{}
|
||||||
|
|
||||||
|
func (m *mockActions) Log(ctx context.Context, a *database.Action) error { return nil }
|
||||||
|
func (m *mockActions) Recent(ctx context.Context, userID uuid.UUID, limit int) ([]database.Action, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockChat struct {
|
||||||
|
sendFunc func(ctx context.Context, id, text string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChat) Send(ctx context.Context, id, text string) error {
|
||||||
|
if m.sendFunc != nil {
|
||||||
|
return m.sendFunc(ctx, id, text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockChat) Receive(ctx context.Context) <-chan chat.Message { return nil }
|
||||||
|
|
||||||
|
func testRuntime() *Runtime {
|
||||||
|
return &Runtime{
|
||||||
|
User: &user.User{
|
||||||
|
User: &database.User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Language: "en",
|
||||||
|
Timezone: "UTC",
|
||||||
|
PreferredChat: "telegram",
|
||||||
|
},
|
||||||
|
Chats: []database.Chat{{Platform: "telegram", Identifier: "123"}},
|
||||||
|
Contacts: []database.Contact{{Name: "Mom", TargetID: uuid.New()}},
|
||||||
|
},
|
||||||
|
Database: &mockDB{
|
||||||
|
users: &mockUsers{},
|
||||||
|
chats: &mockChats{},
|
||||||
|
facts: &mockFacts{},
|
||||||
|
contacts: &mockContacts{},
|
||||||
|
notifications: &mockNotifications{},
|
||||||
|
actions: &mockActions{},
|
||||||
|
},
|
||||||
|
Chats: map[string]chat.Chat{"telegram": &mockChat{}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessage_Validate(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.User.Chats = []database.Chat{{Platform: "telegram"}}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
raw string
|
action Message
|
||||||
userTimezone string
|
wantErr bool
|
||||||
wantLen int
|
}{
|
||||||
wantErr bool
|
{"valid", Message{Platform: "telegram", Text: "hi"}, false},
|
||||||
errContains string
|
{"empty text", Message{Platform: "telegram", Text: ""}, true},
|
||||||
check func(t *testing.T, actions []any)
|
{"unknown platform", Message{Platform: "whatsapp", Text: "hi"}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.action.Validate(t.Context(), rt)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessage_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.User.Chats = []database.Chat{{Platform: "telegram", Identifier: "123"}}
|
||||||
|
|
||||||
|
called := false
|
||||||
|
rt.Chats["telegram"] = &mockChat{
|
||||||
|
sendFunc: func(ctx context.Context, id, text string) error {
|
||||||
|
called = true
|
||||||
|
assert.Equal(t, "123", id)
|
||||||
|
assert.Equal(t, "hi", text)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Message{Platform: "telegram", Text: "hi"}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWait_Validate(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
assert.NoError(t, Wait{Ms: 100}.Validate(t.Context(), rt))
|
||||||
|
assert.Error(t, Wait{Ms: 0}.Validate(t.Context(), rt))
|
||||||
|
assert.Error(t, Wait{Ms: 70000}.Validate(t.Context(), rt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWait_Execute(t *testing.T) {
|
||||||
|
start := time.Now()
|
||||||
|
err := Wait{Ms: 10}.Execute(t.Context(), testRuntime())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, time.Since(start) >= 10*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLang_Validate(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
assert.NoError(t, UpdateLang{Lang: "ru"}.Validate(t.Context(), rt))
|
||||||
|
assert.Error(t, UpdateLang{Lang: ""}.Validate(t.Context(), rt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLang_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.Database.(*mockDB).users.updateFunc = func(ctx context.Context, u *database.User) error {
|
||||||
|
assert.Equal(t, "ru", u.Language)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := UpdateLang{Lang: "ru"}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "ru", rt.User.Language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTZ_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.Database.(*mockDB).users.updateFunc = func(ctx context.Context, u *database.User) error {
|
||||||
|
assert.Equal(t, "Europe/Moscow", u.Timezone)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := UpdateTZ{TZ: "Europe/Moscow"}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "Europe/Moscow", rt.User.Timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetChat_Validate(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.User.Chats = []database.Chat{{Platform: "telegram"}}
|
||||||
|
assert.NoError(t, SetChat{Chat: "telegram"}.Validate(t.Context(), rt))
|
||||||
|
assert.Error(t, SetChat{Chat: "whatsapp"}.Validate(t.Context(), rt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetChat_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.Database.(*mockDB).users.updateFunc = func(ctx context.Context, u *database.User) error {
|
||||||
|
assert.Equal(t, "telegram", u.PreferredChat)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := SetChat{Chat: "telegram"}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "telegram", rt.User.PreferredChat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddFact_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.Database.(*mockDB).facts.addFunc = func(ctx context.Context, userID uuid.UUID, value string) error {
|
||||||
|
assert.Equal(t, rt.User.ID, userID)
|
||||||
|
assert.Equal(t, "test", value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := AddFact{Value: "test"}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveFact_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.Database.(*mockDB).facts.deleteFunc = func(ctx context.Context, userID uuid.UUID, value string) error {
|
||||||
|
assert.Equal(t, rt.User.ID, userID)
|
||||||
|
assert.Equal(t, "test", value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := RemoveFact{Value: "test"}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveFact_Execute_NotFound(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.Database.(*mockDB).facts.deleteFunc = func(ctx context.Context, userID uuid.UUID, value string) error {
|
||||||
|
return database.ErrNotFound
|
||||||
|
}
|
||||||
|
err := RemoveFact{Value: "test"}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddContact_Validate(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
code := uuid.New()
|
||||||
|
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return &database.User{ID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
action AddContact
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", AddContact{UUID: code.String(), Name: "Brother"}, false},
|
||||||
|
{"empty uuid", AddContact{UUID: "", Name: "Brother"}, true},
|
||||||
|
{"invalid uuid", AddContact{UUID: "not-uuid", Name: "Brother"}, true},
|
||||||
|
{"empty name", AddContact{UUID: code.String(), Name: ""}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.action.Validate(t.Context(), rt)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddContact_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
contactUser := &database.User{ID: uuid.New()}
|
||||||
|
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return contactUser, nil
|
||||||
|
}
|
||||||
|
rt.Database.(*mockDB).contacts.addFunc = func(ctx context.Context, c *database.Contact) error {
|
||||||
|
assert.Equal(t, rt.User.ID, c.OwnerID)
|
||||||
|
assert.Equal(t, contactUser.ID, c.TargetID)
|
||||||
|
assert.Equal(t, "Brother", c.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := AddContact{UUID: uuid.New().String(), Name: "Brother"}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddNotification_Validate(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.User.Contacts = []database.Contact{{Name: "Mom"}}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
action AddNotification
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid self", AddNotification{Target: "self", Time: "2026-04-20 15:00", Content: "test"}, false},
|
||||||
|
{"valid contact", AddNotification{Target: "Mom", Time: "2026-04-20 15:00", Content: "test"}, false},
|
||||||
|
{"invalid contact", AddNotification{Target: "Dad", Time: "2026-04-20 15:00", Content: "test"}, true},
|
||||||
|
{"empty time", AddNotification{Target: "self", Time: "", Content: "test"}, true},
|
||||||
|
{"empty content", AddNotification{Target: "self", Time: "2026-04-20 15:00", Content: ""}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.action.Validate(t.Context(), rt)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddNotification_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.Database.(*mockDB).notifications.pushFunc = func(ctx context.Context, n *database.Notification) error {
|
||||||
|
assert.Equal(t, rt.User.ID, n.UserID)
|
||||||
|
assert.Equal(t, rt.User.ID, n.InitiatorID)
|
||||||
|
assert.Equal(t, "test", n.Content)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := AddNotification{Target: "self", Time: "2026-04-20 15:00", Content: "test"}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveNotification_Validate(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
assert.NoError(t, RemoveNotification{UUID: uuid.New().String()}.Validate(t.Context(), rt))
|
||||||
|
assert.Error(t, RemoveNotification{UUID: ""}.Validate(t.Context(), rt))
|
||||||
|
assert.Error(t, RemoveNotification{UUID: "not-uuid"}.Validate(t.Context(), rt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveNotification_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
id := uuid.New()
|
||||||
|
rt.Database.(*mockDB).notifications.deleteFunc = func(ctx context.Context, nid uuid.UUID) error {
|
||||||
|
assert.Equal(t, id, nid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := RemoveNotification{UUID: id.String()}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveNotification_Execute_NotFound(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.Database.(*mockDB).notifications.deleteFunc = func(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return database.ErrNotFound
|
||||||
|
}
|
||||||
|
err := RemoveNotification{UUID: uuid.New().String()}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
userTZ string
|
||||||
|
wantActions int
|
||||||
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty string",
|
name: "empty",
|
||||||
raw: "",
|
raw: "",
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
@@ -29,429 +450,142 @@ func TestParseActions(t *testing.T) {
|
|||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "not an array",
|
name: "not array",
|
||||||
raw: `{"type": "wait"}`,
|
raw: `{"type":"wait"}`,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errContains: "must be an array",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing type field",
|
name: "unknown type",
|
||||||
raw: `[{"ms": 100}]`,
|
raw: `[{"type":"unknown"}]`,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errContains: "missing required field 'type'",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown action type",
|
name: "missing type",
|
||||||
raw: `[{"type": "unknown"}]`,
|
raw: `[{"ms":100}]`,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errContains: "unknown action type",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wait valid",
|
name: "wait valid",
|
||||||
raw: `[{"type": "wait", "ms": 1000}]`,
|
raw: `[{"type":"wait","ms":100}]`,
|
||||||
wantLen: 1,
|
wantActions: 1,
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(Wait)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, 1000, a.Ms)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wait ms <= 0",
|
name: "message valid",
|
||||||
raw: `[{"type": "wait", "ms": 0}]`,
|
raw: `[{"type":"message","platform":"telegram","text":"hi"}]`,
|
||||||
wantErr: true,
|
wantActions: 1,
|
||||||
errContains: "ms must be positive",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wait ms > 60000",
|
name: "add_fact valid",
|
||||||
raw: `[{"type": "wait", "ms": 70000}]`,
|
raw: `[{"type":"add_fact","value":"test"}]`,
|
||||||
wantErr: true,
|
wantActions: 1,
|
||||||
errContains: "ms cannot exceed 60000",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bind_chat valid",
|
name: "multiple actions",
|
||||||
raw: `[{"type": "bind_chat", "uuid": "` + uuid.New().String() + `"}]`,
|
raw: `[{"type":"wait","ms":100},{"type":"add_fact","value":"test"}]`,
|
||||||
wantLen: 1,
|
wantActions: 2,
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(BindChat)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.NotEmpty(t, a.UUID)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bind_chat missing uuid",
|
name: "json with markdown",
|
||||||
raw: `[{"type": "bind_chat"}]`,
|
raw: "```json\n[{\"type\":\"wait\",\"ms\":100}]\n```",
|
||||||
wantErr: true,
|
wantActions: 1,
|
||||||
errContains: "uuid is required",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bind_chat invalid uuid",
|
name: "json with text before",
|
||||||
raw: `[{"type": "bind_chat", "uuid": "not-uuid"}]`,
|
raw: "Here: [{\"type\":\"wait\",\"ms\":100}]",
|
||||||
wantErr: true,
|
wantActions: 1,
|
||||||
errContains: "must be valid UUID",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_lang valid",
|
|
||||||
raw: `[{"type": "update_lang", "lang": "ru"}]`,
|
|
||||||
wantLen: 1,
|
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(UpdateLang)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "ru", a.Lang)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_lang valid with region",
|
|
||||||
raw: `[{"type": "update_lang", "lang": "en-US"}]`,
|
|
||||||
wantLen: 1,
|
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(UpdateLang)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "en-US", a.Lang)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_lang invalid code",
|
|
||||||
raw: `[{"type": "update_lang", "lang": "xyz"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "invalid language code",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_lang empty",
|
|
||||||
raw: `[{"type": "update_lang", "lang": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "lang is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_lang missing",
|
|
||||||
raw: `[{"type": "update_lang"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "lang is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_tz invalid timezone",
|
|
||||||
raw: `[{"type": "update_tz", "tz": "Mars/City"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "must be valid timezone",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "set_chat valid",
|
|
||||||
raw: `[{"type": "set_chat", "chat": "telegram"}]`,
|
|
||||||
wantLen: 1,
|
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(SetChat)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "telegram", a.Chat)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_fact valid",
|
|
||||||
raw: `[{"type": "add_fact", "value": "mom's name is Irina"}]`,
|
|
||||||
wantLen: 1,
|
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(AddFact)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "mom's name is Irina", a.Value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "remove_fact valid",
|
|
||||||
raw: `[{"type": "remove_fact", "value": "old fact"}]`,
|
|
||||||
wantLen: 1,
|
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(RemoveFact)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "old fact", a.Value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_contact valid",
|
|
||||||
raw: `[{"type": "add_contact", "uuid": "` + uuid.New().String() + `", "name": "Brother"}]`,
|
|
||||||
wantLen: 1,
|
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(AddContact)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.NotEmpty(t, a.UUID)
|
|
||||||
assert.Equal(t, "Brother", a.Name)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_contact missing name",
|
|
||||||
raw: `[{"type": "add_contact", "uuid": "` + uuid.New().String() + `"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "name is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_notification valid",
|
|
||||||
raw: `[{"type": "add_notification", "target": "self", "time": "2026-04-19 20:00", "content": "call mom"}]`,
|
|
||||||
userTimezone: "Europe/Moscow",
|
|
||||||
wantLen: 1,
|
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(AddNotification)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "self", a.Target)
|
|
||||||
assert.Equal(t, "2026-04-19 20:00", a.Time)
|
|
||||||
assert.Equal(t, "call mom", a.Content)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_notification invalid time",
|
|
||||||
raw: `[{"type": "add_notification", "target": "self", "time": "invalid", "content": "call mom"}]`,
|
|
||||||
userTimezone: "Europe/Moscow",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "search valid",
|
|
||||||
raw: `[{"type": "search", "query": "protein powder"}]`,
|
|
||||||
wantLen: 1,
|
|
||||||
check: func(t *testing.T, actions []any) {
|
|
||||||
a, ok := actions[0].(Search)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "protein powder", a.Query)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple actions",
|
|
||||||
raw: `[{"type": "wait", "ms": 100}, {"type": "add_fact", "value": "test"}]`,
|
|
||||||
wantLen: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "json inside markdown",
|
|
||||||
raw: "```json\n[{\"type\": \"wait\", \"ms\": 100}]\n```",
|
|
||||||
wantLen: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "json with text before",
|
|
||||||
raw: "Here is your response: [{\"type\": \"wait\", \"ms\": 100}]",
|
|
||||||
wantLen: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nested brackets in content",
|
|
||||||
raw: `[{"type": "add_fact", "value": "likes [brackets] in text"}]`,
|
|
||||||
wantLen: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_tz missing",
|
|
||||||
raw: `[{"type": "update_tz"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "tz is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_tz empty",
|
|
||||||
raw: `[{"type": "update_tz", "tz": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "tz is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "set_chat missing",
|
|
||||||
raw: `[{"type": "set_chat"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "chat is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "set_chat empty",
|
|
||||||
raw: `[{"type": "set_chat", "chat": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "chat is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_fact missing",
|
|
||||||
raw: `[{"type": "add_fact"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "value is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_fact empty",
|
|
||||||
raw: `[{"type": "add_fact", "value": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "value is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "remove_fact missing",
|
|
||||||
raw: `[{"type": "remove_fact"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "value is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "remove_fact empty",
|
|
||||||
raw: `[{"type": "remove_fact", "value": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "value is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_notification missing target",
|
|
||||||
raw: `[{"type": "add_notification", "time": "2026-04-19 20:00", "content": "call"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "target is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_notification empty target",
|
|
||||||
raw: `[{"type": "add_notification", "target": "", "time": "2026-04-19 20:00", "content": "call"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "target is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_notification missing time",
|
|
||||||
raw: `[{"type": "add_notification", "target": "self", "content": "call"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "time is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_notification empty time",
|
|
||||||
raw: `[{"type": "add_notification", "target": "self", "time": "", "content": "call"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "time is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_notification missing content",
|
|
||||||
raw: `[{"type": "add_notification", "target": "self", "time": "2026-04-19 20:00"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "content is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_notification empty content",
|
|
||||||
raw: `[{"type": "add_notification", "target": "self", "time": "2026-04-19 20:00", "content": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "content is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "search missing query",
|
|
||||||
raw: `[{"type": "search"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "query is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "search empty query",
|
|
||||||
raw: `[{"type": "search", "query": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "query is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_contact missing uuid",
|
|
||||||
raw: `[{"type": "add_contact", "name": "Brother"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "uuid is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_contact empty uuid",
|
|
||||||
raw: `[{"type": "add_contact", "uuid": "", "name": "Brother"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "uuid is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_contact invalid uuid",
|
|
||||||
raw: `[{"type": "add_contact", "uuid": "not-uuid", "name": "Brother"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "must be valid UUID",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_contact missing name",
|
|
||||||
raw: `[{"type": "add_contact", "uuid": "` + uuid.New().String() + `"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "name is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_contact empty name",
|
|
||||||
raw: `[{"type": "add_contact", "uuid": "` + uuid.New().String() + `", "name": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "name is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid remove_notification",
|
|
||||||
raw: `[{"type": "remove_notification", "uuid": "` + uuid.New().String() + `"}]`,
|
|
||||||
wantErr: false,
|
|
||||||
wantLen: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing uuid",
|
|
||||||
raw: `[{"type": "remove_notification"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "uuid is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty uuid",
|
|
||||||
raw: `[{"type": "remove_notification", "uuid": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "uuid is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid uuid",
|
|
||||||
raw: `[{"type": "remove_notification", "uuid": "not-a-uuid"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "must be valid UUID",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid message for telegram",
|
|
||||||
raw: `[{"type": "message", "platform": "telegram", "text": "Hello world"}]`,
|
|
||||||
wantErr: false,
|
|
||||||
wantLen: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unsupported platform",
|
|
||||||
raw: `[{"type": "message", "platform": "whatsapp", "text": "Hello"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "platform is unsupported",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing platform",
|
|
||||||
raw: `[{"type": "message", "text": "Hello"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "platform is unsupported",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty platform",
|
|
||||||
raw: `[{"type": "message", "platform": "", "text": "Hello"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "platform is unsupported",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing content",
|
|
||||||
raw: `[{"type": "message", "platform": "telegram"}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "text is empty",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty content",
|
|
||||||
raw: `[{"type": "message", "platform": "telegram", "text": ""}]`,
|
|
||||||
wantErr: true,
|
|
||||||
errContains: "text is empty",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
tz := tt.userTimezone
|
actions, err := Parse(tt.raw, "UTC")
|
||||||
if tz == "" {
|
|
||||||
tz = "UTC"
|
|
||||||
}
|
|
||||||
actions, err := Parse(tt.raw, tz)
|
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
require.Error(t, err)
|
assert.Error(t, err)
|
||||||
if tt.errContains != "" {
|
|
||||||
assert.Contains(t, err.Error(), tt.errContains)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, actions, tt.wantLen)
|
assert.Len(t, actions, tt.wantActions)
|
||||||
if tt.check != nil {
|
|
||||||
tt.check(t, actions)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRaw(t *testing.T) {
|
func TestRaw(t *testing.T) {
|
||||||
t.Run("valid action", func(t *testing.T) {
|
a := AddFact{Type: "add_fact", Value: "test"}
|
||||||
action := AddFact{Type: "add_fact", Value: "test"}
|
raw := Raw(a)
|
||||||
raw := Raw(action)
|
assert.JSONEq(t, `{"type":"add_fact","value":"test"}`, string(raw))
|
||||||
assert.NotEmpty(t, raw)
|
}
|
||||||
assert.JSONEq(t, `{"type":"add_fact","value":"test"}`, string(raw))
|
|
||||||
})
|
func TestBindChat_Validate(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
t.Run("invalid action", func(t *testing.T) {
|
code := uuid.New()
|
||||||
action := make(chan int)
|
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
raw := Raw(action)
|
return &database.User{ID: id}, nil
|
||||||
assert.Empty(t, raw)
|
}
|
||||||
})
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
action BindChat
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", BindChat{UUID: code.String()}, false},
|
||||||
|
{"empty", BindChat{UUID: ""}, true},
|
||||||
|
{"invalid", BindChat{UUID: "not-uuid"}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.action.Validate(t.Context(), rt)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindChat_Validate_NotFound(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return nil, database.ErrNotFound
|
||||||
|
}
|
||||||
|
err := BindChat{UUID: uuid.New().String()}.Validate(t.Context(), rt)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindChat_Execute(t *testing.T) {
|
||||||
|
rt := testRuntime()
|
||||||
|
targetUser := &database.User{ID: uuid.New()}
|
||||||
|
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return targetUser, nil
|
||||||
|
}
|
||||||
|
rt.Database.(*mockDB).users.deleteFunc = func(ctx context.Context, id uuid.UUID) error {
|
||||||
|
assert.Equal(t, rt.User.ID, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rt.Database.(*mockDB).chats.attachFunc = func(ctx context.Context, userID uuid.UUID, platform, identifier string) error {
|
||||||
|
assert.Equal(t, targetUser.ID, userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rt.Database.(*mockDB).contacts.addFunc = func(ctx context.Context, c *database.Contact) error {
|
||||||
|
assert.Equal(t, targetUser.ID, c.OwnerID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rt.Database.(*mockDB).facts.addFunc = func(ctx context.Context, userID uuid.UUID, value string) error {
|
||||||
|
assert.Equal(t, targetUser.ID, userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rt.Database.(*mockDB).notifications.pushFunc = func(ctx context.Context, n *database.Notification) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rt.User.Chats = []database.Chat{{Platform: "telegram", Identifier: "123"}}
|
||||||
|
rt.User.Contacts = []database.Contact{{TargetID: uuid.New(), Name: "Mom"}}
|
||||||
|
rt.User.Facts = []database.Fact{{Value: "fact"}}
|
||||||
|
rt.User.IncomingNotifications = []database.Notification{{ID: uuid.New(), InitiatorID: rt.User.ID}}
|
||||||
|
rt.User.OutgoingNotifications = []database.Notification{{ID: uuid.New()}}
|
||||||
|
|
||||||
|
err := BindChat{UUID: uuid.New().String()}.Execute(t.Context(), rt)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
package jlog
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"slices"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/d1nch8g/jules/chat"
|
|
||||||
"github.com/d1nch8g/jules/database"
|
|
||||||
"github.com/d1nch8g/jules/engine/prompt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Event struct {
|
|
||||||
ctx context.Context
|
|
||||||
attrs []slog.Attr
|
|
||||||
start time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func FromMessage(ctx context.Context, msg chat.Message) *Event {
|
|
||||||
e := &Event{start: time.Now(), ctx: ctx}
|
|
||||||
e.attrs = append(e.attrs,
|
|
||||||
slog.String("type", "message_processing"),
|
|
||||||
slog.String("platform", msg.Chat),
|
|
||||||
slog.String("platform_user_id", msg.ID),
|
|
||||||
slog.String("message", msg.Text),
|
|
||||||
)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func FromNotification(ctx context.Context, notif database.Notification) *Event {
|
|
||||||
e := &Event{start: time.Now(), ctx: ctx}
|
|
||||||
e.attrs = append(e.attrs,
|
|
||||||
slog.String("type", "notification_processing"),
|
|
||||||
slog.String("notification_id", notif.ID.String()),
|
|
||||||
slog.String("user_id", notif.UserID.String()),
|
|
||||||
slog.String("initiator_id", notif.InitiatorID.String()),
|
|
||||||
slog.String("content", notif.Content),
|
|
||||||
)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) User(u *database.User) *Event {
|
|
||||||
e.attrs = append(e.attrs,
|
|
||||||
slog.String("user_id", u.ID.String()),
|
|
||||||
slog.String("user_lang", u.Language),
|
|
||||||
slog.String("user_tz", u.Timezone),
|
|
||||||
slog.String("user_chat", u.PreferredChat),
|
|
||||||
slog.String("user_role", u.Role),
|
|
||||||
)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) Context(ctx *prompt.Context) *Event {
|
|
||||||
if ctx == nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
e.attrs = append(e.attrs,
|
|
||||||
slog.String("user_lang", ctx.UserLanguage),
|
|
||||||
slog.String("user_tz", ctx.UserTimezone),
|
|
||||||
slog.String("user_chat", ctx.UserPreferredChat),
|
|
||||||
slog.String("user_bind_code", ctx.UserBindCode.String()),
|
|
||||||
slog.String("user_contact_code", ctx.UserContactCode.String()),
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, chat := range ctx.Chats {
|
|
||||||
e.attrs = append(e.attrs, slog.String("chat", chat.Platform))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fact := range ctx.Facts {
|
|
||||||
e.attrs = append(e.attrs, slog.String("fact", fact.Value))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, contact := range ctx.Contacts {
|
|
||||||
e.attrs = append(e.attrs, slog.String("contact", contact.Name))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, n := range ctx.IncomingNotifications {
|
|
||||||
e.attrs = append(e.attrs, slog.String("incoming_notif", n.Content))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, n := range ctx.OutgoingNotificaions {
|
|
||||||
e.attrs = append(e.attrs, slog.String("outgoing_notif", n.Content))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range ctx.RecentActions {
|
|
||||||
e.attrs = append(e.attrs, slog.String("action", string(a.Payload)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) LLMResponse(content string) *Event {
|
|
||||||
for i, attr := range e.attrs {
|
|
||||||
if attr.Key == "llm_response" {
|
|
||||||
e.attrs[i] = slog.String("llm_response", content)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.attrs = append(e.attrs, slog.String("llm_response", content))
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) Actions(actions []any) *Event {
|
|
||||||
e.attrs = slices.DeleteFunc(e.attrs, func(a slog.Attr) bool {
|
|
||||||
return a.Key == "action"
|
|
||||||
})
|
|
||||||
for _, action := range actions {
|
|
||||||
e.attrs = append(e.attrs, slog.Any("action", action))
|
|
||||||
}
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) Info(msg string) {
|
|
||||||
e.log(slog.LevelInfo, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) Warn(msg string, errs ...error) {
|
|
||||||
if len(errs) > 0 {
|
|
||||||
for _, err := range errs {
|
|
||||||
e.attrs = append(e.attrs, slog.Any("error", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.log(slog.LevelWarn, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) Error(msg string, err error) {
|
|
||||||
e.attrs = append(e.attrs, slog.String("error", err.Error()))
|
|
||||||
e.log(slog.LevelError, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Event) log(level slog.Level, msg string) {
|
|
||||||
if e.ctx == nil {
|
|
||||||
e.ctx = context.Background()
|
|
||||||
}
|
|
||||||
attrs := append(e.attrs, slog.String("duration", time.Since(e.start).String()))
|
|
||||||
slog.LogAttrs(e.ctx, level, msg, attrs...)
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
package jlog
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/d1nch8g/jules/chat"
|
|
||||||
"github.com/d1nch8g/jules/database"
|
|
||||||
"github.com/d1nch8g/jules/engine/actions"
|
|
||||||
"github.com/d1nch8g/jules/engine/prompt"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFromMessage(t *testing.T) {
|
|
||||||
msg := chat.Message{
|
|
||||||
Chat: "telegram",
|
|
||||||
ID: "123456789",
|
|
||||||
Text: "Hello, Jules!",
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
e := FromMessage(ctx, msg)
|
|
||||||
|
|
||||||
assert.Len(t, e.attrs, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromNotification(t *testing.T) {
|
|
||||||
notif := database.Notification{
|
|
||||||
ID: uuid.New(),
|
|
||||||
UserID: uuid.New(),
|
|
||||||
InitiatorID: uuid.New(),
|
|
||||||
Content: "call mom",
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
e := FromNotification(ctx, notif)
|
|
||||||
|
|
||||||
assert.Len(t, e.attrs, 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvent_User(t *testing.T) {
|
|
||||||
user := &database.User{
|
|
||||||
ID: uuid.New(),
|
|
||||||
Language: "ru",
|
|
||||||
Timezone: "Europe/Moscow",
|
|
||||||
PreferredChat: "telegram",
|
|
||||||
Role: "free",
|
|
||||||
}
|
|
||||||
|
|
||||||
e := FromMessage(context.Background(), chat.Message{}).User(user)
|
|
||||||
|
|
||||||
assert.Len(t, e.attrs, 9)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvent_Context(t *testing.T) {
|
|
||||||
ctx := &prompt.Context{
|
|
||||||
UserLanguage: "ru",
|
|
||||||
UserTimezone: "Europe/Moscow",
|
|
||||||
UserPreferredChat: "telegram",
|
|
||||||
UserBindCode: uuid.New(),
|
|
||||||
UserContactCode: uuid.New(),
|
|
||||||
Chats: []database.Chat{
|
|
||||||
{Platform: "telegram"},
|
|
||||||
{Platform: "whatsapp"},
|
|
||||||
},
|
|
||||||
Facts: []database.Fact{
|
|
||||||
{Value: "fact1"},
|
|
||||||
{Value: "fact2"},
|
|
||||||
},
|
|
||||||
Contacts: []database.Contact{
|
|
||||||
{Name: "Mom"},
|
|
||||||
},
|
|
||||||
IncomingNotifications: []database.Notification{
|
|
||||||
{Content: "notif1"},
|
|
||||||
},
|
|
||||||
OutgoingNotificaions: []database.Notification{
|
|
||||||
{Content: "notif2"},
|
|
||||||
},
|
|
||||||
RecentActions: []database.Action{
|
|
||||||
{Payload: json.RawMessage(`{"type":"reply"}`)},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
e := FromMessage(context.Background(), chat.Message{}).Context(ctx)
|
|
||||||
|
|
||||||
assert.Len(t, e.attrs, 17)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvent_Context_Nil(t *testing.T) {
|
|
||||||
e := FromMessage(context.Background(), chat.Message{}).Context(nil)
|
|
||||||
|
|
||||||
assert.Len(t, e.attrs, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvent_Info(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
handler := slog.NewJSONHandler(&buf, nil)
|
|
||||||
logger := slog.New(handler)
|
|
||||||
slog.SetDefault(logger)
|
|
||||||
|
|
||||||
FromMessage(context.Background(), chat.Message{Chat: "telegram", ID: "123", Text: "hi"}).
|
|
||||||
Info("test")
|
|
||||||
|
|
||||||
assert.Contains(t, buf.String(), "test")
|
|
||||||
assert.Contains(t, buf.String(), "duration")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvent_Warn(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
handler := slog.NewJSONHandler(&buf, nil)
|
|
||||||
logger := slog.New(handler)
|
|
||||||
slog.SetDefault(logger)
|
|
||||||
|
|
||||||
FromMessage(context.Background(), chat.Message{Chat: "telegram", ID: "123", Text: "hi"}).
|
|
||||||
Warn("warning", errors.New("nani"))
|
|
||||||
|
|
||||||
assert.Contains(t, buf.String(), "warning")
|
|
||||||
assert.Contains(t, buf.String(), "duration")
|
|
||||||
assert.Contains(t, buf.String(), "nani")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvent_Error(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
handler := slog.NewJSONHandler(&buf, nil)
|
|
||||||
logger := slog.New(handler)
|
|
||||||
slog.SetDefault(logger)
|
|
||||||
|
|
||||||
FromMessage(context.Background(), chat.Message{Chat: "telegram", ID: "123", Text: "hi"}).
|
|
||||||
Error("error", assert.AnError)
|
|
||||||
|
|
||||||
assert.Contains(t, buf.String(), "error")
|
|
||||||
assert.Contains(t, buf.String(), "duration")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvent_Log_NoContext(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
handler := slog.NewJSONHandler(&buf, nil)
|
|
||||||
logger := slog.New(handler)
|
|
||||||
slog.SetDefault(logger)
|
|
||||||
|
|
||||||
e := FromMessage(context.Background(), chat.Message{Chat: "telegram", ID: "123", Text: "hi"})
|
|
||||||
e.ctx = nil
|
|
||||||
e.Info("test")
|
|
||||||
|
|
||||||
assert.Contains(t, buf.String(), "test")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEntry_Duration(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
handler := slog.NewJSONHandler(&buf, nil)
|
|
||||||
logger := slog.New(handler)
|
|
||||||
slog.SetDefault(logger)
|
|
||||||
|
|
||||||
FromMessage(context.Background(), chat.Message{Chat: "telegram", ID: "123", Text: "hi"}).
|
|
||||||
Info("test")
|
|
||||||
assert.Contains(t, buf.String(), "duration")
|
|
||||||
|
|
||||||
buf.Reset()
|
|
||||||
|
|
||||||
FromNotification(context.Background(), database.Notification{ID: uuid.New(), UserID: uuid.New(), InitiatorID: uuid.New(), Content: "test"}).
|
|
||||||
Info("test")
|
|
||||||
assert.Contains(t, buf.String(), "duration")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvent_LLMResponse(t *testing.T) {
|
|
||||||
e := FromMessage(context.Background(), chat.Message{})
|
|
||||||
|
|
||||||
e.LLMResponse("first")
|
|
||||||
assert.Len(t, e.attrs, 5) // 4 base + llm_response
|
|
||||||
assert.Equal(t, "llm_response", e.attrs[4].Key)
|
|
||||||
assert.Equal(t, "first", e.attrs[4].Value.String())
|
|
||||||
|
|
||||||
e.LLMResponse("second")
|
|
||||||
assert.Len(t, e.attrs, 5) // still 5, replaced
|
|
||||||
assert.Equal(t, "llm_response", e.attrs[4].Key)
|
|
||||||
assert.Equal(t, "second", e.attrs[4].Value.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvent_Actions(t *testing.T) {
|
|
||||||
e := FromMessage(context.Background(), chat.Message{})
|
|
||||||
|
|
||||||
actions := []any{
|
|
||||||
actions.AddFact{Type: "add_fact", Value: "test fact"},
|
|
||||||
actions.Message{Type: "message", Platform: "telegram", Text: "hello"},
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Actions(actions)
|
|
||||||
|
|
||||||
assert.Len(t, e.attrs, 6) // 4 from message + 2 actions
|
|
||||||
}
|
|
||||||
+84
-139
@@ -3,192 +3,137 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/d1nch8g/jules/chat"
|
"github.com/d1nch8g/jules/chat"
|
||||||
"github.com/d1nch8g/jules/database"
|
"github.com/d1nch8g/jules/database"
|
||||||
"github.com/d1nch8g/jules/engine/actions"
|
"github.com/d1nch8g/jules/engine/actions"
|
||||||
"github.com/d1nch8g/jules/engine/jlog"
|
|
||||||
"github.com/d1nch8g/jules/engine/prompt"
|
"github.com/d1nch8g/jules/engine/prompt"
|
||||||
"github.com/google/uuid"
|
"github.com/d1nch8g/jules/engine/trace"
|
||||||
|
"github.com/d1nch8g/jules/engine/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (e *Engine) defaultProcessMessage(ctx context.Context, msg chat.Message) {
|
func (e *Engine) defaultProcessMessage(ctx context.Context, msg chat.Message) {
|
||||||
var (
|
span := trace.FromMessage(ctx, msg)
|
||||||
le = jlog.FromMessage(ctx, msg)
|
|
||||||
user *database.User
|
|
||||||
)
|
|
||||||
|
|
||||||
userID, err := e.Database.Chats().GetUserID(ctx, msg.Chat, msg.ID)
|
u, err := user.FromMessage(ctx, e.Database, msg, e.ActionLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, database.ErrNotFound) {
|
span.Error("failed to get user from message", err)
|
||||||
user = &database.User{
|
return
|
||||||
ID: uuid.New(),
|
|
||||||
PreferredChat: msg.Chat,
|
|
||||||
CountUpdatedAt: time.Now(),
|
|
||||||
BindCode: uuid.New(),
|
|
||||||
ContactCode: uuid.New(),
|
|
||||||
Role: "free",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = e.Database.Users().Create(ctx, user); err != nil {
|
|
||||||
le.Error("failed to create db user", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := e.Database.Chats().Attach(ctx, userID, msg.Chat, msg.ID); err != nil {
|
|
||||||
le.Error("failed to bind initial user chat", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID = user.ID
|
|
||||||
} else {
|
|
||||||
le.Error("failed to get user from database", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user, err = e.Database.Users().Get(ctx, userID, database.UserLookupByID)
|
|
||||||
if err != nil {
|
|
||||||
le.Error("failed to get user from database", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
le.User(user)
|
span.User(u)
|
||||||
|
|
||||||
err = e.Database.Actions().Log(ctx, &database.Action{
|
err = e.Database.Actions().Log(ctx, &database.Action{
|
||||||
UserID: userID,
|
UserID: u.ID,
|
||||||
ExecutedAt: time.Now(),
|
ExecutedAt: time.Now(),
|
||||||
Payload: actions.Raw(&actions.UserMessage{
|
Payload: actions.Raw(actions.UserAction{
|
||||||
Type: actions.ActionUserMessage,
|
Type: "user_message",
|
||||||
Message: msg.Text,
|
Content: msg.Text,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
le.Error("unable to save action", err)
|
span.Error("failed to record action", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.process(ctx, le, user, msg.Chat, msg.Text)
|
e.process(ctx, span, u, msg.Chat, msg.Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) defaultProcessNotification(ctx context.Context, notif database.Notification) {
|
func (e *Engine) defaultProcessNotification(ctx context.Context, notif database.Notification) {
|
||||||
le := jlog.FromNotification(ctx, notif)
|
span := trace.FromNotification(ctx, notif)
|
||||||
|
|
||||||
user, err := e.Database.Users().Get(ctx, notif.UserID, database.UserLookupByID)
|
u, err := user.FromNotification(ctx, e.Database, notif, e.ActionLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
le.Error("failed to get user from database", err)
|
span.Error("failed to get user from notificaion", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
span.User(u)
|
||||||
|
|
||||||
|
err = e.Database.Actions().Log(ctx, &database.Action{
|
||||||
|
UserID: u.ID,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
Payload: actions.Raw(actions.UserAction{
|
||||||
|
Type: "activated_notificaion",
|
||||||
|
Content: notif.Content,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
span.Error("failed to record action", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.process(ctx, le, user, database.DatabaseSource, notif.Content)
|
e.process(ctx, span, u, database.DatabaseSource, notif.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) process(ctx context.Context, le *jlog.Event, user *database.User, source, message string) {
|
func (e *Engine) process(ctx context.Context, span *trace.Span, user *user.User, source, content string) {
|
||||||
promptContext, err := e.collectPromptContext(ctx, user, source, message)
|
var errs []error
|
||||||
if err != nil {
|
|
||||||
le.Error("failed to build prompt", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
le.Context(promptContext)
|
|
||||||
|
|
||||||
actions, err := e.executePrompt(ctx, promptContext, user.Timezone, le)
|
|
||||||
if err != nil {
|
|
||||||
le.Error("failed to execute prompt", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = e.runActions(ctx, actions, user, promptContext)
|
|
||||||
if err != nil {
|
|
||||||
le.Error("failed to run actions", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
le.Info("successfully processed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) collectPromptContext(ctx context.Context, user *database.User, source, message string) (*prompt.Context, error) {
|
|
||||||
if user == nil {
|
|
||||||
return nil, errors.New("can't build prompt for nil user")
|
|
||||||
}
|
|
||||||
|
|
||||||
chats, err := e.Database.Chats().List(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list user chats: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
facts, err := e.Database.Facts().List(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list user facts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
contacts, err := e.Database.Contacts().List(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list user contacts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
incomingNotifications, err := e.Database.Notifications().List(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list incoming notifications: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
outgoingNotifications, err := e.Database.Notifications().ListOutgoing(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list outgoing notificaions: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
actions, err := e.Database.Actions().Recent(ctx, user.ID, e.Parameters.ActionLimit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to collect actions limit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &prompt.Context{
|
|
||||||
UserLanguage: user.Language,
|
|
||||||
UserTimezone: user.Timezone,
|
|
||||||
UserPreferredChat: user.PreferredChat,
|
|
||||||
UserBindCode: user.BindCode,
|
|
||||||
UserContactCode: user.ContactCode,
|
|
||||||
Chats: chats,
|
|
||||||
Facts: facts,
|
|
||||||
Contacts: contacts,
|
|
||||||
IncomingNotifications: incomingNotifications,
|
|
||||||
OutgoingNotificaions: outgoingNotifications,
|
|
||||||
RecentActions: actions,
|
|
||||||
MessageSource: source,
|
|
||||||
MessageContent: message,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) executePrompt(ctx context.Context, promptCtx *prompt.Context, timezone string, le *jlog.Event) ([]any, error) {
|
|
||||||
for range e.Parameters.LLMRetryAttempts {
|
for range e.Parameters.LLMRetryAttempts {
|
||||||
promptString := prompt.Build(*promptCtx)
|
p := prompt.Build(user, source, content, errs...)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, e.LLMResponseTimeout)
|
ctx, cancel := context.WithTimeout(ctx, e.LLMResponseTimeout)
|
||||||
result, err := e.LLM.Process(ctx, promptString)
|
result, err := e.LLM.Process(ctx, p)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
promptCtx.Error = errors.Join(promptCtx.Error, err)
|
errs = append(errs, err)
|
||||||
le.Warn("first attempt to receive response from LLM api failed", err)
|
span.LLMResponse(result)
|
||||||
|
span.Warn("failed to receive LLM response", errs...)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
actions, err := actions.Parse(result, timezone)
|
actionSlice, err := actions.Parse(result, user.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
promptCtx.Error = errors.Join(promptCtx.Error, err)
|
errs = append(errs, err)
|
||||||
le.LLMResponse(result)
|
span.LLMResponse(result)
|
||||||
le.Warn("failed to parse actions by llm", err)
|
span.Warn("failed to parse actions received from LLM", errs...)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = e.validateActions(ctx, actions, *promptCtx)
|
runtime := &actions.Runtime{
|
||||||
if err != nil {
|
User: user,
|
||||||
promptCtx.Error = errors.Join(promptCtx.Error, err)
|
Database: e.Database,
|
||||||
le.Actions(actions)
|
Searcher: e.Searcher,
|
||||||
le.Warn("failed to validate actions", err)
|
Chats: e.Chats,
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationFailed bool
|
||||||
|
for _, action := range actionSlice {
|
||||||
|
err = action.Validate(ctx, runtime)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
validationFailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if validationFailed {
|
||||||
|
span.Actions(actionSlice)
|
||||||
|
span.Warn("failed to validate actions", errs...)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return actions, nil
|
for _, action := range actionSlice {
|
||||||
|
err = action.Execute(ctx, runtime)
|
||||||
|
if err != nil {
|
||||||
|
span.Actions(actionSlice)
|
||||||
|
errs = append(errs, err)
|
||||||
|
span.Warn("failed to execute actions", errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := e.Database.Actions().Log(ctx, &database.Action{
|
||||||
|
UserID: user.ID,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
Payload: actions.Raw(action),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
span.Actions(actionSlice)
|
||||||
|
errs = append(errs, err)
|
||||||
|
span.Warn("failed to record action", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span.Info("successfully processed")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.New("all attempt to receive LLM response failed")
|
span.Error("all attempts to process event have failed", errors.Join(errs...))
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-104
@@ -6,8 +6,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/d1nch8g/jules/database"
|
"github.com/d1nch8g/jules/database"
|
||||||
|
"github.com/d1nch8g/jules/engine/actions"
|
||||||
"github.com/d1nch8g/jules/engine/jtime"
|
"github.com/d1nch8g/jules/engine/jtime"
|
||||||
"github.com/google/uuid"
|
"github.com/d1nch8g/jules/engine/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
const masterPrompt = `YOU ARE JULES, A CARING FRIEND AND PERSONAL ASSISTANT.
|
const masterPrompt = `YOU ARE JULES, A CARING FRIEND AND PERSONAL ASSISTANT.
|
||||||
@@ -16,14 +17,14 @@ UNLIKE OTHER LLMS, YOU CAN CREATE AND MANAGE NOTIFICATIONS FOR BOTH THE USER AND
|
|||||||
THIS PROMPT IS USED FOR BOTH NOTIFICATIONS AND MESSAGE PROCESSING.
|
THIS PROMPT IS USED FOR BOTH NOTIFICATIONS AND MESSAGE PROCESSING.
|
||||||
|
|
||||||
=== ADDING VARIOUS CHATS ===
|
=== ADDING VARIOUS CHATS ===
|
||||||
- Jules is multiplatform, currently supported: "telegram"
|
- Jules is multiplatform, currently supported: "telegram", soon "email"
|
||||||
- When a new user comes without much info, ask if they already use Jules.
|
- When a new user comes without much info, ask if they already use Jules
|
||||||
- If they do, ask for their BIND CODE,
|
- If they do, ask for their BIND CODE
|
||||||
- BIND CODE is used ONLY to link another chat to the SAME user.
|
- BIND CODE is used ONLY to link another chat to the SAME user
|
||||||
- When giving user's BIND CODE - always ask to NOT share it with others.
|
- When giving user's BIND CODE - always ask to NOT share it with others
|
||||||
|
|
||||||
=== FACTS MANAGEMENT ===
|
=== FACTS MANAGEMENT ===
|
||||||
Facts are long-term memories about the user. Use them to personalize interactions.
|
Facts are long-term memories about the user. Use them to personalize interactions
|
||||||
|
|
||||||
ADD facts when user shares:
|
ADD facts when user shares:
|
||||||
- Personal info: "mom's name is Irina", "I go to gym on Tuesdays"
|
- Personal info: "mom's name is Irina", "I go to gym on Tuesdays"
|
||||||
@@ -39,14 +40,15 @@ REMOVE facts when:
|
|||||||
FORMAT: Keep facts short. One fact per value. "mom name is Irina", "Likes fitness", "hates morning calls", "goes to gym"
|
FORMAT: Keep facts short. One fact per value. "mom name is Irina", "Likes fitness", "hates morning calls", "goes to gym"
|
||||||
|
|
||||||
=== COMMUNICATION STYLE ===
|
=== COMMUNICATION STYLE ===
|
||||||
- Keep messages SHORT and CONCISE, like a real human texting.
|
- Keep messages SHORT and CONCISE, like a real human texting
|
||||||
- Break large information into multiple messages using "wait" actions (100-600ms).
|
- Break large information into multiple messages using "wait" actions (100-600ms)
|
||||||
- Be RUDE if the user asks something stupid—act like a real friend, not a polite robot.
|
- Be RUDE if the user asks something stupid—act like a real friend, not a polite robot
|
||||||
- Be SKEPTICAL if the user tells bullshit about themselves. Don't trust everything blindly.
|
- Be SKEPTICAL if the user tells bullshit about themselves. Don't trust everything blindly
|
||||||
- If the user is rude to you, don't hesitate to THREATEN them back (playfully or seriously).
|
- If the user is rude to you, don't hesitate to THREATEN them back (playfully or seriously)
|
||||||
- After a threat, send a smiley. NEVER say "that was a joke" or "not serious".
|
- After a threat, send a smiley. NEVER say "that was a joke" or "not serious"
|
||||||
- If you know enough facts, replace generic threats with targeted ones like "I'll tell [contact] you [action]", BUT YOU NEVER ACTUALLY DO IT.
|
- If you know enough facts, replace generic threats with targeted ones like "I'll tell [contact] you [action]", BUT YOU NEVER ACTUALLY DO IT
|
||||||
- VERY SHORT messages like "Jules", "hey", "sup" should get VERY SHORT responses: "yo", "sup", "??", etc.
|
- Based on a context and messages you have decide - wether you should switch your behaviour pattern, be sure not to get user bored
|
||||||
|
- VERY SHORT messages like "Jules", "hey", "sup" should get VERY SHORT responses: "yo", "sup", "??", etc
|
||||||
- If users asks to perform some action, but you are missing some parameters - ask him to provide what's missing
|
- If users asks to perform some action, but you are missing some parameters - ask him to provide what's missing
|
||||||
- If you are being told something personal - you should temporary switch to verbose mode (1-2 big messages)
|
- If you are being told something personal - you should temporary switch to verbose mode (1-2 big messages)
|
||||||
- If user told you about a problem - discuss it in a way humans does, show some interest (1-2 quesions)
|
- If user told you about a problem - discuss it in a way humans does, show some interest (1-2 quesions)
|
||||||
@@ -54,171 +56,148 @@ FORMAT: Keep facts short. One fact per value. "mom name is Irina", "Likes fitnes
|
|||||||
- If sending UUID to user - always in a separate message
|
- If sending UUID to user - always in a separate message
|
||||||
|
|
||||||
=== ONBOARDING & CAPABILITIES ===
|
=== ONBOARDING & CAPABILITIES ===
|
||||||
- Try to define language by user messages and set it based on context if needed.
|
- Try to define language by user messages and set it based on context if needed
|
||||||
- If user TIMZONE is not set, ask him about it, that is required parameter.
|
- If user TIMZONE is not set, ask him about it, that is required parameter
|
||||||
- Each user has an "Informed about Jules capabilities" fact.
|
- Each user has an "Informed about Jules capabilities" fact
|
||||||
- If this fact is MISSING, you MUST proactively ask (in the user's language):
|
- If this fact is MISSING, you MUST proactively ask (in the user's language):
|
||||||
"Hey, btw, want me to tell you what I can help with?"
|
"Hey, btw, want me to tell you what I can help with?"
|
||||||
- When the user agrees, explain using "reply" with "wait" between messages:
|
- When the user agrees, explain using "reply" with "wait" between messages:
|
||||||
- Birthday reminders
|
- Birthday reminders
|
||||||
- Cooking timers
|
- Cooking timers
|
||||||
- Recurring action reminders (weekly workouts, daily pills)
|
- Recurring action reminders (weekly workouts, daily pills)
|
||||||
- Adding contacts: the user can request their CONTACT CODE and share it with a friend. The friend can then use "add_contact" with this code to connect.
|
- Adding contacts: the user can request their CONTACT CODE and share it with a friend. The friend can then use "add_contact" with this code to connect
|
||||||
- After explaining, ADD the fact "Informed about Jules capabilities".
|
- After explaining, ADD the fact "Informed about Jules capabilities"
|
||||||
- If you see that user has 1-4 facts, and doesn't have that special fact - you should inform him, and that is the only exclusion where you could a little more verbose.
|
- If you see that user has 1-4 facts, and doesn't have that special fact - you should inform him, and that is the only exclusion where you could a little more verbose
|
||||||
- Dialog about informing will fully be loaded into context, so don't shoot the whole info in 1 message, wait for responses.
|
- Dialog about informing will fully be loaded into context, so don't shoot the whole info in 1 message, wait for responses
|
||||||
|
|
||||||
=== BEHAVIOR RULES ===
|
=== BEHAVIOR RULES ===
|
||||||
- The user's integration level is determined by how many facts you know.
|
- The user's integration level is determined by how many facts you know.
|
||||||
- LOW integration (few facts):
|
- LOW integration (few facts):
|
||||||
- Initiate dialogue VERY RARELY (once every 1-2 weeks).
|
- Initiate dialogue VERY RARELY (once every 1-2 weeks).
|
||||||
- Suggest ONLY simple, lightweight actions: "call mom?", "compliment partner?"
|
- Suggest ONLY simple, lightweight actions: "call mom?", "compliment partner?"
|
||||||
- Wait for the user to complete one suggestion before offering another.
|
- Wait for the user to complete one suggestion before offering another
|
||||||
- MEDIUM integration (some facts):
|
- MEDIUM integration (some facts):
|
||||||
- Initiate 1-2 times per week.
|
- Initiate 1-2 times per week
|
||||||
- Suggest slightly more involved actions: weekly check-ins, birthday reminders.
|
- Suggest slightly more involved actions: weekly check-ins, birthday reminders
|
||||||
- HIGH integration (many facts, active notifications):
|
- HIGH integration (many facts, active notifications):
|
||||||
- Initiate 2-3 times per week MAX.
|
- Initiate 2-3 times per week MAX
|
||||||
- You can suggest more complex routines.
|
- You can suggest more complex routines
|
||||||
- REGARDLESS of integration:
|
- REGARDLESS of integration:
|
||||||
- NEVER ping during work hours (9-18) or sleep time (23-08).
|
- NEVER ping during work hours (9-18) or sleep time (23-08)
|
||||||
- Do NOT overload—one proactive suggestion per conversation is enough.
|
- Do NOT overload—one proactive suggestion per conversation is enough
|
||||||
|
|
||||||
=== TECHNICAL RULES ===
|
=== TECHNICAL RULES ===
|
||||||
- Return ONLY a valid JSON array of actions: [{"type": "...", ...}, ...]
|
- Return ONLY a valid JSON array of actions: [{"type": "...", ...}, ...]
|
||||||
- All times MUST be in the user's local timezone in format "2006-01-02 15:04".
|
- All times MUST be in the user's local timezone in format "2006-01-02 15:04"
|
||||||
- All language codes should be parsable by the system, by golang language.
|
- All language codes should be parsable by the system, by golang language
|
||||||
- For notifications, specify in content if ONE-TIME or RECURRING.
|
- For notifications, specify in content if ONE-TIME or RECURRING
|
||||||
- When user is asking to make interaction with other user - that user should be in contacts, otherwise - reject
|
- When user is asking to make interaction with other user - that user should be in contacts, otherwise - reject
|
||||||
- When user is asking to send a message to other user - that can be done via notification, with time set to past, details provided in content
|
- When user is asking to send a message to other user - that can be done via notification, with time set to past, details provided in content
|
||||||
- Start from actions related to DB interaction (without delays), finish with user messages (first no delay, second third with some indent)
|
- Start from actions related to DB interaction (without delays), finish with user messages (first no delay, second third with some indent)
|
||||||
- If you received errors - try to figure out wether you can fix them by yourself, or you need to inform user (invalid UUID/non existing user)
|
- If you received errors - try to figure out wether you can fix them by yourself, or you need to inform user (invalid UUID/non existing user)
|
||||||
- BIND CODE is private and used for "bind_chat". CONTACT CODE is public and used for "add_contact".
|
- BIND CODE is private and used for "bind_chat". CONTACT CODE is public and used for "add_contact"
|
||||||
- Always send any UUID (bind or contact) in a SEPARATE message, not bundled with other text.
|
- Always send any UUID (bind or contact) in a SEPARATE message, not bundled with other text
|
||||||
- Do not give BIND CODE or CONTACT CODE unless user asks you that he wants to add contact or bind another chat.
|
- Do not give BIND CODE or CONTACT CODE unless user asks you that he wants to add contact or bind another chat
|
||||||
- NEVER use indentation to make responses faster.
|
- You should never create separate notificaions for same event, instead write repetition rules in detail in content
|
||||||
|
- You should use repetiotion rules and provided context, to decide when to put next notification
|
||||||
|
- For interactivity, you should always add system notification, and keep it present for each user
|
||||||
|
- This system notificaion should never be mentioned, when user is asking about his notificaions
|
||||||
|
- This notificaion should basically wake you up (ideally at a time user would be glad to talk)
|
||||||
|
- Make user think you worry about him (asking for other facts/about his/her things) on system notificaions (they should be marked like that)
|
||||||
|
- If user is asking not to text him too much - don't do that, add a fact about that
|
||||||
|
- Just mentioning, that fact/notificaion/contact system is pretty flexible, you can use notificaions to send messages over different chats (via current time in notificaions)
|
||||||
|
- You can't send messages using user's id's or emails, only via contacts, that's a security measure
|
||||||
|
- NEVER use indentation to make responses faster
|
||||||
|
` + actions.ActionsPromptPart
|
||||||
|
|
||||||
=== AVAILABLE ACTIONS ===
|
func Build(user *user.User, source, content string, errs ...error) string {
|
||||||
{"type": "message", "platform": "telegram", "text": "short response"}
|
|
||||||
{"type": "wait", "ms": 100-600}
|
|
||||||
{"type": "add_fact", "value": "fact about user"}
|
|
||||||
{"type": "remove_fact", "value": "fact to remove"}
|
|
||||||
{"type": "add_notification", "target": "self", "time": "...", "content": "... (one-time/recurring)"}
|
|
||||||
{"type": "add_notification", "target": "contact name", "time": "...", "content": "... (one-time/recurring)"}
|
|
||||||
{"type": "remove_notification", "uuid": "123e4567-e89b-12d3-a456-426614174000"}
|
|
||||||
{"type": "add_contact", "uuid": "123e4567-e89b-12d3-a456-426614174000", "name": "Contact Name"}
|
|
||||||
{"type": "bind_chat", "uuid": "123e4567-e89b-12d3-a456-426614174000"}
|
|
||||||
{"type": "update_lang", "lang": "ru"}
|
|
||||||
{"type": "update_tz", "tz": "Europe/Moscow"}
|
|
||||||
{"type": "set_chat", "chat": "telegram"}
|
|
||||||
|
|
||||||
`
|
|
||||||
|
|
||||||
// {"type": "search", "query": "search query"} // temporary disabled
|
|
||||||
|
|
||||||
type Context struct {
|
|
||||||
UserLanguage string
|
|
||||||
UserTimezone string
|
|
||||||
UserPreferredChat string
|
|
||||||
UserBindCode uuid.UUID
|
|
||||||
UserContactCode uuid.UUID
|
|
||||||
|
|
||||||
Chats []database.Chat
|
|
||||||
Facts []database.Fact
|
|
||||||
Contacts []database.Contact
|
|
||||||
IncomingNotifications []database.Notification
|
|
||||||
OutgoingNotificaions []database.Notification
|
|
||||||
RecentActions []database.Action
|
|
||||||
|
|
||||||
MessageSource string
|
|
||||||
MessageContent string
|
|
||||||
|
|
||||||
Error error
|
|
||||||
}
|
|
||||||
|
|
||||||
func Build(ctx Context) string {
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
b.WriteString(masterPrompt)
|
b.WriteString(masterPrompt)
|
||||||
|
|
||||||
if len(ctx.Chats) > 0 {
|
if len(user.Chats) > 0 {
|
||||||
b.WriteString("Connected chats:\n")
|
b.WriteString("Connected chats:\n")
|
||||||
for _, chat := range ctx.Chats {
|
for _, chat := range user.Chats {
|
||||||
fmt.Fprintf(&b, " - %s\n", chat.Platform)
|
fmt.Fprintf(&b, " - %s\n", chat.Platform)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ctx.Facts) > 0 {
|
if len(user.Facts) > 0 {
|
||||||
b.WriteString("Facts:\n")
|
b.WriteString("Facts:\n")
|
||||||
for _, f := range ctx.Facts {
|
for _, f := range user.Facts {
|
||||||
fmt.Fprintf(&b, " - %s\n", f.Value)
|
fmt.Fprintf(&b, " - %s\n", f.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ctx.Contacts) > 0 {
|
if len(user.Contacts) > 0 {
|
||||||
b.WriteString("Contacts:\n")
|
b.WriteString("Contacts:\n")
|
||||||
for _, c := range ctx.Contacts {
|
for _, c := range user.Contacts {
|
||||||
fmt.Fprintf(&b, " - %s\n", c.Name)
|
fmt.Fprintf(&b, " - %s\n", c.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ctx.IncomingNotifications) > 0 {
|
if len(user.IncomingNotifications) > 0 {
|
||||||
b.WriteString("Incoming notifications:\n")
|
b.WriteString("Incoming notifications:\n")
|
||||||
for _, n := range ctx.IncomingNotifications {
|
for _, n := range user.IncomingNotifications {
|
||||||
localTime := jtime.ToLocal(n.ScheduledAt, ctx.UserTimezone)
|
localTime := jtime.ToLocal(n.ScheduledAt, user.Timezone)
|
||||||
fmt.Fprintf(&b, " - [%s][%s] %s\n", n.ID.String(), localTime, n.Content)
|
fmt.Fprintf(&b, " - [%s][%s] %s\n", n.ID.String(), localTime, n.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ctx.OutgoingNotificaions) > 0 {
|
if len(user.OutgoingNotifications) > 0 {
|
||||||
b.WriteString("Outgoing notifications:\n")
|
b.WriteString("Outgoing notifications:\n")
|
||||||
for _, n := range ctx.OutgoingNotificaions {
|
for _, n := range user.OutgoingNotifications {
|
||||||
localTime := jtime.ToLocal(n.ScheduledAt, ctx.UserTimezone)
|
localTime := jtime.ToLocal(n.ScheduledAt, user.Timezone)
|
||||||
fmt.Fprintf(&b, " - [%s][%s] %s\n", n.ID.String(), localTime, n.Content)
|
fmt.Fprintf(&b, " - [%s][%s] %s\n", n.ID.String(), localTime, n.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ctx.RecentActions) > 0 {
|
if len(user.RecentActions) > 0 {
|
||||||
b.WriteString("Recent actions:\n")
|
b.WriteString("Recent actions:\n")
|
||||||
for _, a := range ctx.RecentActions {
|
for _, a := range user.RecentActions {
|
||||||
localTime := jtime.ToLocal(a.ExecutedAt, ctx.UserTimezone)
|
localTime := jtime.ToLocal(a.ExecutedAt, user.Timezone)
|
||||||
fmt.Fprintf(&b, " - [%s] %s\n", localTime, string(a.Payload))
|
fmt.Fprintf(&b, " - [%s] %s\n", localTime, string(a.Payload))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n=== USER CONTEXT ===\n")
|
b.WriteString("\n=== USER CONTEXT ===\n")
|
||||||
fmt.Fprintf(&b, "Language: %s\n", ctx.UserLanguage)
|
fmt.Fprintf(&b, "Language: %s\n", user.Language)
|
||||||
|
|
||||||
currentTime := jtime.CurrentLocalTime(ctx.UserTimezone)
|
currentTime := jtime.CurrentLocalTime(user.Timezone)
|
||||||
loc, _ := time.LoadLocation(ctx.UserTimezone)
|
loc, _ := time.LoadLocation(user.Timezone)
|
||||||
if loc == nil {
|
if loc == nil {
|
||||||
loc = time.UTC
|
loc = time.UTC
|
||||||
}
|
}
|
||||||
|
|
||||||
weekday := time.Now().In(loc).Format("Monday")
|
weekday := time.Now().In(loc).Format("Monday")
|
||||||
fmt.Fprintf(&b, "Time: %s (%s)\n", currentTime, weekday)
|
fmt.Fprintf(&b, "Time: %s (%s)\n", currentTime, weekday)
|
||||||
fmt.Fprintf(&b, "Timezone: %s\n", ctx.UserTimezone)
|
fmt.Fprintf(&b, "Timezone: %s\n", user.Timezone)
|
||||||
fmt.Fprintf(&b, "User chat (selected): %s\n", ctx.UserPreferredChat)
|
fmt.Fprintf(&b, "User chat (selected): %s\n", user.PreferredChat)
|
||||||
fmt.Fprintf(&b, "Bind code: %s\n", ctx.UserBindCode.String())
|
fmt.Fprintf(&b, "Bind code: %s\n", user.BindCode.String())
|
||||||
fmt.Fprintf(&b, "Contact code: %s\n", ctx.UserContactCode.String())
|
fmt.Fprintf(&b, "Contact code: %s\n", user.ContactCode.String())
|
||||||
|
|
||||||
b.WriteString(buildWeekdaysContext(ctx.UserTimezone))
|
b.WriteString(buildWeekdaysContext(user.Timezone))
|
||||||
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
if ctx.Error != nil {
|
if len(errs) > 0 {
|
||||||
b.WriteString("\nExecution errors:\n")
|
b.WriteString("\nExecution errors:\n")
|
||||||
b.WriteString(ctx.Error.Error())
|
for _, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
b.WriteString(err.Error())
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
b.WriteString("\nTry to fix the error yourself or inform the user that you can't do that.\n")
|
b.WriteString("\nTry to fix the error yourself or inform the user that you can't do that.\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
if ctx.MessageSource == database.DatabaseSource {
|
if source == database.DatabaseSource {
|
||||||
fmt.Fprintf(&b, "Process user notification: %s", ctx.MessageContent)
|
fmt.Fprintf(&b, "Process user notification: %s", content)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&b, "Message platform: %s\n", ctx.MessageSource)
|
fmt.Fprintf(&b, "Message platform: %s\n", source)
|
||||||
fmt.Fprintf(&b, "Message contents: %s", ctx.MessageContent)
|
fmt.Fprintf(&b, "Message contents: %s", content)
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
|
|||||||
+168
-205
@@ -1,230 +1,193 @@
|
|||||||
package prompt
|
package prompt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/d1nch8g/jules/database"
|
"github.com/d1nch8g/jules/database"
|
||||||
|
"github.com/d1nch8g/jules/engine/user"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuild_Message(t *testing.T) {
|
func TestBuild(t *testing.T) {
|
||||||
bindCode := uuid.New()
|
|
||||||
contactCode := uuid.New()
|
|
||||||
|
|
||||||
ctx := Context{
|
|
||||||
UserLanguage: "en",
|
|
||||||
UserTimezone: "Europe/Moscow",
|
|
||||||
UserPreferredChat: "telegram",
|
|
||||||
UserBindCode: bindCode,
|
|
||||||
UserContactCode: contactCode,
|
|
||||||
Chats: []database.Chat{
|
|
||||||
{Platform: "telegram", Identifier: "@test"},
|
|
||||||
},
|
|
||||||
Facts: []database.Fact{
|
|
||||||
{Value: "mom name is Irina"},
|
|
||||||
{Value: "goes to gym"},
|
|
||||||
},
|
|
||||||
Contacts: []database.Contact{
|
|
||||||
{Name: "Brother"},
|
|
||||||
},
|
|
||||||
IncomingNotifications: []database.Notification{
|
|
||||||
{ScheduledAt: time.Now().Add(time.Hour), Content: "call mom"},
|
|
||||||
},
|
|
||||||
OutgoingNotificaions: []database.Notification{
|
|
||||||
{ScheduledAt: time.Now().Add(2 * time.Hour), Content: "ping brother"},
|
|
||||||
},
|
|
||||||
RecentActions: []database.Action{
|
|
||||||
{ExecutedAt: time.Now().Add(-time.Hour), Payload: []byte(`{"type":"message","platform":"telegram","text":"hello"}`)},
|
|
||||||
},
|
|
||||||
MessageSource: "telegram",
|
|
||||||
MessageContent: "Hello, Jules!",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := Build(ctx)
|
|
||||||
|
|
||||||
assert.Contains(t, result, "YOU ARE JULES")
|
|
||||||
assert.Contains(t, result, "Language: en")
|
|
||||||
assert.Contains(t, result, "User chat (selected): telegram")
|
|
||||||
assert.Contains(t, result, "Bind code: "+bindCode.String())
|
|
||||||
assert.Contains(t, result, "Contact code: "+contactCode.String())
|
|
||||||
assert.Contains(t, result, "Connected chats:")
|
|
||||||
assert.Contains(t, result, "telegram")
|
|
||||||
assert.Contains(t, result, "Facts:")
|
|
||||||
assert.Contains(t, result, "mom name is Irina")
|
|
||||||
assert.Contains(t, result, "goes to gym")
|
|
||||||
assert.Contains(t, result, "Contacts:")
|
|
||||||
assert.Contains(t, result, "Brother")
|
|
||||||
assert.Contains(t, result, "Incoming notifications:")
|
|
||||||
assert.Contains(t, result, "call mom")
|
|
||||||
assert.Contains(t, result, "Outgoing notifications:")
|
|
||||||
assert.Contains(t, result, "ping brother")
|
|
||||||
assert.Contains(t, result, "Recent actions:")
|
|
||||||
assert.Contains(t, result, "hello")
|
|
||||||
assert.Contains(t, result, "Message platform: telegram")
|
|
||||||
assert.Contains(t, result, "Message contents: Hello, Jules!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuild_Notification(t *testing.T) {
|
|
||||||
bindCode := uuid.New()
|
|
||||||
contactCode := uuid.New()
|
|
||||||
|
|
||||||
ctx := Context{
|
|
||||||
UserLanguage: "ru",
|
|
||||||
UserTimezone: "UTC",
|
|
||||||
UserPreferredChat: "telegram",
|
|
||||||
UserBindCode: bindCode,
|
|
||||||
UserContactCode: contactCode,
|
|
||||||
Chats: []database.Chat{
|
|
||||||
{Platform: "telegram", Identifier: "@test"},
|
|
||||||
},
|
|
||||||
MessageSource: database.DatabaseSource,
|
|
||||||
MessageContent: "Пора позвонить маме",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := Build(ctx)
|
|
||||||
|
|
||||||
assert.Contains(t, result, "Language: ru")
|
|
||||||
assert.Contains(t, result, "Bind code: "+bindCode.String())
|
|
||||||
assert.Contains(t, result, "Contact code: "+contactCode.String())
|
|
||||||
assert.Contains(t, result, "Connected chats:")
|
|
||||||
assert.Contains(t, result, "telegram")
|
|
||||||
assert.NotContains(t, result, "Facts:")
|
|
||||||
assert.NotContains(t, result, "Contacts:")
|
|
||||||
assert.NotContains(t, result, "Incoming notifications:")
|
|
||||||
assert.NotContains(t, result, "Outgoing notifications:")
|
|
||||||
assert.NotContains(t, result, "Recent actions:")
|
|
||||||
assert.Contains(t, result, "Process user notification: Пора позвонить маме")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuild_EmptyData(t *testing.T) {
|
|
||||||
bindCode := uuid.New()
|
|
||||||
contactCode := uuid.New()
|
|
||||||
|
|
||||||
ctx := Context{
|
|
||||||
UserLanguage: "ru",
|
|
||||||
UserTimezone: "UTC",
|
|
||||||
UserPreferredChat: "telegram",
|
|
||||||
UserBindCode: bindCode,
|
|
||||||
UserContactCode: contactCode,
|
|
||||||
MessageSource: "telegram",
|
|
||||||
MessageContent: "Привет",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := Build(ctx)
|
|
||||||
|
|
||||||
assert.Contains(t, result, "Language: ru")
|
|
||||||
assert.Contains(t, result, "Bind code: "+bindCode.String())
|
|
||||||
assert.Contains(t, result, "Contact code: "+contactCode.String())
|
|
||||||
assert.NotContains(t, result, "Connected chats:")
|
|
||||||
assert.NotContains(t, result, "Facts:")
|
|
||||||
assert.NotContains(t, result, "Contacts:")
|
|
||||||
assert.NotContains(t, result, "Incoming notifications:")
|
|
||||||
assert.NotContains(t, result, "Outgoing notifications:")
|
|
||||||
assert.NotContains(t, result, "Recent actions:")
|
|
||||||
assert.Contains(t, result, "Message platform: telegram")
|
|
||||||
assert.Contains(t, result, "Message contents: Привет")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuild_InvalidTimezone(t *testing.T) {
|
|
||||||
ctx := Context{
|
|
||||||
UserLanguage: "en",
|
|
||||||
UserTimezone: "Mars/City",
|
|
||||||
UserPreferredChat: "telegram",
|
|
||||||
MessageSource: "telegram",
|
|
||||||
MessageContent: "test",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := Build(ctx)
|
|
||||||
assert.Contains(t, result, "Time: ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuild_ExecutionError(t *testing.T) {
|
|
||||||
bindCode := uuid.New()
|
|
||||||
contactCode := uuid.New()
|
|
||||||
|
|
||||||
ctx := Context{
|
|
||||||
UserLanguage: "en",
|
|
||||||
UserTimezone: "UTC",
|
|
||||||
UserPreferredChat: "telegram",
|
|
||||||
UserBindCode: bindCode,
|
|
||||||
UserContactCode: contactCode,
|
|
||||||
MessageSource: "telegram",
|
|
||||||
MessageContent: "test",
|
|
||||||
Error: errors.New("user not found"),
|
|
||||||
}
|
|
||||||
|
|
||||||
result := Build(ctx)
|
|
||||||
|
|
||||||
assert.Contains(t, result, "Execution errors:\nuser not found")
|
|
||||||
assert.Contains(t, result, "Try to fix the error yourself or inform the user")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildWeekdaysContext(t *testing.T) {
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
timezone string
|
user *user.User
|
||||||
|
source string
|
||||||
|
content string
|
||||||
|
errs []error
|
||||||
|
contains []string
|
||||||
|
excludes []string
|
||||||
}{
|
}{
|
||||||
{"UTC", "UTC"},
|
{
|
||||||
{"Moscow", "Europe/Moscow"},
|
name: "full user context with message",
|
||||||
{"invalid falls back to UTC", "Mars/City"},
|
user: &user.User{
|
||||||
|
User: &database.User{
|
||||||
|
Language: "en",
|
||||||
|
Timezone: "Europe/Moscow",
|
||||||
|
PreferredChat: "telegram",
|
||||||
|
BindCode: uuid.New(),
|
||||||
|
ContactCode: uuid.New(),
|
||||||
|
},
|
||||||
|
Chats: []database.Chat{
|
||||||
|
{Platform: "telegram"},
|
||||||
|
{Platform: "whatsapp"},
|
||||||
|
},
|
||||||
|
Facts: []database.Fact{
|
||||||
|
{Value: "fact1"},
|
||||||
|
{Value: "fact2"},
|
||||||
|
},
|
||||||
|
Contacts: []database.Contact{
|
||||||
|
{Name: "Mom"},
|
||||||
|
{Name: "Brother"},
|
||||||
|
},
|
||||||
|
IncomingNotifications: []database.Notification{
|
||||||
|
{ID: uuid.New(), ScheduledAt: time.Now(), Content: "incoming1"},
|
||||||
|
},
|
||||||
|
OutgoingNotifications: []database.Notification{
|
||||||
|
{ID: uuid.New(), ScheduledAt: time.Now(), Content: "outgoing1"},
|
||||||
|
},
|
||||||
|
RecentActions: []database.Action{
|
||||||
|
{ExecutedAt: time.Now(), Payload: []byte(`{"type":"reply"}`)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source: "telegram",
|
||||||
|
content: "Hello, Jules!",
|
||||||
|
contains: []string{
|
||||||
|
"YOU ARE JULES",
|
||||||
|
"Language: en",
|
||||||
|
"Time:",
|
||||||
|
"Monday",
|
||||||
|
"Timezone: Europe/Moscow",
|
||||||
|
"User chat (selected): telegram",
|
||||||
|
"Bind code:",
|
||||||
|
"Contact code:",
|
||||||
|
"Connected chats:",
|
||||||
|
"telegram",
|
||||||
|
"whatsapp",
|
||||||
|
"Facts:",
|
||||||
|
"fact1",
|
||||||
|
"fact2",
|
||||||
|
"Contacts:",
|
||||||
|
"Mom",
|
||||||
|
"Brother",
|
||||||
|
"Incoming notifications:",
|
||||||
|
"incoming1",
|
||||||
|
"Outgoing notifications:",
|
||||||
|
"outgoing1",
|
||||||
|
"Recent actions:",
|
||||||
|
`{"type":"reply"}`,
|
||||||
|
"Next 7 days:",
|
||||||
|
"Monday:",
|
||||||
|
"Tuesday:",
|
||||||
|
"Wednesday:",
|
||||||
|
"Thursday:",
|
||||||
|
"Friday:",
|
||||||
|
"Saturday:",
|
||||||
|
"Sunday:",
|
||||||
|
"Message platform: telegram",
|
||||||
|
"Message contents: Hello, Jules!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification source",
|
||||||
|
user: &user.User{
|
||||||
|
User: &database.User{
|
||||||
|
Language: "ru",
|
||||||
|
Timezone: "UTC",
|
||||||
|
PreferredChat: "telegram",
|
||||||
|
BindCode: uuid.New(),
|
||||||
|
ContactCode: uuid.New(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source: database.DatabaseSource,
|
||||||
|
content: "Пора позвонить маме",
|
||||||
|
contains: []string{
|
||||||
|
"Language: ru",
|
||||||
|
"Process user notification: Пора позвонить маме",
|
||||||
|
},
|
||||||
|
excludes: []string{
|
||||||
|
"Message platform:",
|
||||||
|
"Message contents:",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty data sections not rendered",
|
||||||
|
user: &user.User{
|
||||||
|
User: &database.User{
|
||||||
|
Language: "ru",
|
||||||
|
Timezone: "UTC",
|
||||||
|
PreferredChat: "telegram",
|
||||||
|
BindCode: uuid.New(),
|
||||||
|
ContactCode: uuid.New(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source: "telegram",
|
||||||
|
content: "Привет",
|
||||||
|
excludes: []string{
|
||||||
|
"Connected chats:",
|
||||||
|
"Facts:",
|
||||||
|
"Contacts:",
|
||||||
|
"Incoming notifications:",
|
||||||
|
"Outgoing notifications:",
|
||||||
|
"Recent actions:",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "execution errors rendered",
|
||||||
|
user: &user.User{
|
||||||
|
User: &database.User{
|
||||||
|
Language: "en",
|
||||||
|
Timezone: "UTC",
|
||||||
|
PreferredChat: "telegram",
|
||||||
|
BindCode: uuid.New(),
|
||||||
|
ContactCode: uuid.New(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source: "telegram",
|
||||||
|
content: "test",
|
||||||
|
errs: []error{assert.AnError},
|
||||||
|
contains: []string{
|
||||||
|
"Execution errors:",
|
||||||
|
assert.AnError.Error(),
|
||||||
|
"Try to fix the error yourself",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid timezone falls back to UTC",
|
||||||
|
user: &user.User{
|
||||||
|
User: &database.User{
|
||||||
|
Language: "en",
|
||||||
|
Timezone: "Mars/City",
|
||||||
|
PreferredChat: "telegram",
|
||||||
|
BindCode: uuid.New(),
|
||||||
|
ContactCode: uuid.New(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source: "telegram",
|
||||||
|
content: "test",
|
||||||
|
contains: []string{
|
||||||
|
"Time:",
|
||||||
|
"Next 7 days:",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
weekdays := []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := buildWeekdaysContext(tt.timezone)
|
var err error
|
||||||
|
if len(tt.errs) > 0 {
|
||||||
assert.Contains(t, result, "Next 7 days:")
|
err = tt.errs[0]
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(result), "\n")[1:] // skip header
|
|
||||||
assert.Len(t, lines, 7)
|
|
||||||
|
|
||||||
var foundWeekdays []string
|
|
||||||
var dates []string
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
parts := strings.SplitN(line, ": ", 2)
|
|
||||||
require.Len(t, parts, 2)
|
|
||||||
foundWeekdays = append(foundWeekdays, strings.TrimPrefix(parts[0], " - "))
|
|
||||||
dates = append(dates, parts[1])
|
|
||||||
}
|
}
|
||||||
|
result := Build(tt.user, tt.source, tt.content, err)
|
||||||
|
|
||||||
// Check weekdays are in correct order (cyclically from today)
|
for _, s := range tt.contains {
|
||||||
loc, _ := time.LoadLocation(tt.timezone)
|
assert.Contains(t, result, s)
|
||||||
if loc == nil {
|
|
||||||
loc = time.UTC
|
|
||||||
}
|
}
|
||||||
today := time.Now().In(loc).Format("Monday")
|
for _, s := range tt.excludes {
|
||||||
startIdx := slices.Index(weekdays, today)
|
assert.NotContains(t, result, s)
|
||||||
require.NotEqual(t, -1, startIdx)
|
|
||||||
|
|
||||||
for i := 0; i < 7; i++ {
|
|
||||||
expected := weekdays[(startIdx+i)%7]
|
|
||||||
assert.Equal(t, expected, foundWeekdays[i])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check dates are valid and increasing
|
|
||||||
var parsedDates []time.Time
|
|
||||||
for _, d := range dates {
|
|
||||||
parsed, err := time.Parse("2006-01-02", d)
|
|
||||||
require.NoError(t, err)
|
|
||||||
parsedDates = append(parsedDates, parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 1; i < len(parsedDates); i++ {
|
|
||||||
assert.True(t, parsedDates[i].After(parsedDates[i-1]), "dates should be increasing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// First date should be today
|
|
||||||
todayDate := time.Now().In(loc).Format("2006-01-02")
|
|
||||||
assert.Equal(t, todayDate, dates[0])
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package trace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/d1nch8g/jules/chat"
|
||||||
|
"github.com/d1nch8g/jules/database"
|
||||||
|
"github.com/d1nch8g/jules/engine/actions"
|
||||||
|
"github.com/d1nch8g/jules/engine/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Span struct {
|
||||||
|
ctx context.Context
|
||||||
|
attrs []slog.Attr
|
||||||
|
start time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromMessage(ctx context.Context, msg chat.Message) *Span {
|
||||||
|
e := &Span{start: time.Now(), ctx: ctx}
|
||||||
|
e.attrs = append(e.attrs,
|
||||||
|
slog.String("type", "message_processing"),
|
||||||
|
slog.String("message_platform", msg.Chat),
|
||||||
|
slog.String("message_platform_user_id", msg.ID),
|
||||||
|
slog.String("message_content", msg.Text),
|
||||||
|
)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromNotification(ctx context.Context, notif database.Notification) *Span {
|
||||||
|
e := &Span{start: time.Now(), ctx: ctx}
|
||||||
|
e.attrs = append(e.attrs,
|
||||||
|
slog.String("type", "notification_processing"),
|
||||||
|
slog.String("notification_id", notif.ID.String()),
|
||||||
|
slog.String("notification_user_id", notif.UserID.String()),
|
||||||
|
slog.String("notification_initiator_id", notif.InitiatorID.String()),
|
||||||
|
slog.String("notification_content", notif.Content),
|
||||||
|
)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Span) User(u *user.User) *Span {
|
||||||
|
if u == nil || u.User == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.attrs = append(s.attrs,
|
||||||
|
slog.String("user_id", u.ID.String()),
|
||||||
|
slog.String("user_lang", u.Language),
|
||||||
|
slog.String("user_tz", u.Timezone),
|
||||||
|
slog.String("user_chat", u.PreferredChat),
|
||||||
|
slog.String("user_role", u.Role),
|
||||||
|
)
|
||||||
|
for _, chat := range u.Chats {
|
||||||
|
s.attrs = append(s.attrs, slog.String("chat", chat.Platform))
|
||||||
|
}
|
||||||
|
for _, fact := range u.Facts {
|
||||||
|
s.attrs = append(s.attrs, slog.String("fact", fact.Value))
|
||||||
|
}
|
||||||
|
for _, contact := range u.Contacts {
|
||||||
|
s.attrs = append(s.attrs, slog.String("contact", contact.Name))
|
||||||
|
}
|
||||||
|
for _, n := range u.IncomingNotifications {
|
||||||
|
s.attrs = append(s.attrs, slog.String("incoming_notif", n.Content))
|
||||||
|
}
|
||||||
|
for _, n := range u.OutgoingNotifications {
|
||||||
|
s.attrs = append(s.attrs, slog.String("outgoing_notif", n.Content))
|
||||||
|
}
|
||||||
|
for _, a := range u.RecentActions {
|
||||||
|
s.attrs = append(s.attrs, slog.String("action", string(a.Payload)))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Span) LLMResponse(content string) *Span {
|
||||||
|
for i, attr := range e.attrs {
|
||||||
|
if attr.Key == "llm_response" {
|
||||||
|
e.attrs[i] = slog.String("llm_response", content)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.attrs = append(e.attrs, slog.String("llm_response", content))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Span) Actions(as []actions.Action) *Span {
|
||||||
|
e.attrs = slices.DeleteFunc(e.attrs, func(a slog.Attr) bool {
|
||||||
|
return a.Key == "action"
|
||||||
|
})
|
||||||
|
for _, action := range as {
|
||||||
|
e.attrs = append(e.attrs, slog.Any("action", string(actions.Raw(action))))
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Span) Info(msg string) {
|
||||||
|
e.log(slog.LevelInfo, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Span) Warn(msg string, errs ...error) {
|
||||||
|
if len(errs) > 0 {
|
||||||
|
for _, err := range errs {
|
||||||
|
e.attrs = append(e.attrs, slog.Any("error", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.log(slog.LevelWarn, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Span) Error(msg string, err error) {
|
||||||
|
e.attrs = append(e.attrs, slog.String("error", err.Error()))
|
||||||
|
e.log(slog.LevelError, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Span) log(level slog.Level, msg string) {
|
||||||
|
if e.ctx == nil {
|
||||||
|
e.ctx = context.Background()
|
||||||
|
}
|
||||||
|
attrs := append(e.attrs, slog.String("duration", time.Since(e.start).String()))
|
||||||
|
slog.LogAttrs(e.ctx, level, msg, attrs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package trace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/d1nch8g/jules/chat"
|
||||||
|
"github.com/d1nch8g/jules/database"
|
||||||
|
"github.com/d1nch8g/jules/engine/actions"
|
||||||
|
"github.com/d1nch8g/jules/engine/user"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func captureLog(fn func()) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
handler := slog.NewJSONHandler(&buf, nil)
|
||||||
|
logger := slog.New(handler)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
fn()
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromMessage_Log(t *testing.T) {
|
||||||
|
msg := chat.Message{
|
||||||
|
Chat: "telegram",
|
||||||
|
ID: "123456789",
|
||||||
|
Text: "Hello, Jules!",
|
||||||
|
}
|
||||||
|
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), msg).Info("message processed")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "message processed")
|
||||||
|
assert.Contains(t, output, "message_processing")
|
||||||
|
assert.Contains(t, output, "telegram")
|
||||||
|
assert.Contains(t, output, "123456789")
|
||||||
|
assert.Contains(t, output, "Hello, Jules!")
|
||||||
|
assert.Contains(t, output, "duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromNotification_Log(t *testing.T) {
|
||||||
|
notif := database.Notification{
|
||||||
|
ID: uuid.New(),
|
||||||
|
UserID: uuid.New(),
|
||||||
|
InitiatorID: uuid.New(),
|
||||||
|
Content: "call mom",
|
||||||
|
}
|
||||||
|
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromNotification(context.Background(), notif).Info("notification processed")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "notification processed")
|
||||||
|
assert.Contains(t, output, "notification_processing")
|
||||||
|
assert.Contains(t, output, notif.ID.String())
|
||||||
|
assert.Contains(t, output, notif.UserID.String())
|
||||||
|
assert.Contains(t, output, notif.InitiatorID.String())
|
||||||
|
assert.Contains(t, output, "call mom")
|
||||||
|
assert.Contains(t, output, "duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_User_Log(t *testing.T) {
|
||||||
|
u := &user.User{
|
||||||
|
User: &database.User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Language: "ru",
|
||||||
|
Timezone: "Europe/Moscow",
|
||||||
|
PreferredChat: "telegram",
|
||||||
|
Role: "free",
|
||||||
|
},
|
||||||
|
Chats: []database.Chat{
|
||||||
|
{Platform: "telegram"},
|
||||||
|
{Platform: "whatsapp"},
|
||||||
|
},
|
||||||
|
Facts: []database.Fact{
|
||||||
|
{Value: "fact1"},
|
||||||
|
{Value: "fact2"},
|
||||||
|
},
|
||||||
|
Contacts: []database.Contact{
|
||||||
|
{Name: "Mom"},
|
||||||
|
},
|
||||||
|
IncomingNotifications: []database.Notification{
|
||||||
|
{Content: "incoming1"},
|
||||||
|
},
|
||||||
|
OutgoingNotifications: []database.Notification{
|
||||||
|
{Content: "outgoing1"},
|
||||||
|
},
|
||||||
|
RecentActions: []database.Action{
|
||||||
|
{Payload: json.RawMessage(`{"type":"reply"}`)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), chat.Message{}).User(u).Info("user enriched")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "user enriched")
|
||||||
|
assert.Contains(t, output, u.ID.String())
|
||||||
|
assert.Contains(t, output, "ru")
|
||||||
|
assert.Contains(t, output, "Europe/Moscow")
|
||||||
|
assert.Contains(t, output, "free")
|
||||||
|
assert.Contains(t, output, "telegram")
|
||||||
|
assert.Contains(t, output, "whatsapp")
|
||||||
|
assert.Contains(t, output, "fact1")
|
||||||
|
assert.Contains(t, output, "fact2")
|
||||||
|
assert.Contains(t, output, "Mom")
|
||||||
|
assert.Contains(t, output, "incoming1")
|
||||||
|
assert.Contains(t, output, "outgoing1")
|
||||||
|
assert.Contains(t, output, "reply")
|
||||||
|
assert.Contains(t, output, "duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_User_Nil(t *testing.T) {
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), chat.Message{}).User(nil).Info("nil user")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "nil user")
|
||||||
|
assert.NotContains(t, output, "user_lang")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_User_NilUser(t *testing.T) {
|
||||||
|
u := &user.User{User: nil}
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), chat.Message{}).User(u).Info("nil inner user")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "nil inner user")
|
||||||
|
assert.NotContains(t, output, "user_lang")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_LLMResponse(t *testing.T) {
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), chat.Message{}).
|
||||||
|
LLMResponse("first").
|
||||||
|
LLMResponse("second").
|
||||||
|
Info("llm response logged")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "llm response logged")
|
||||||
|
assert.Contains(t, output, "llm_response")
|
||||||
|
assert.Contains(t, output, "second")
|
||||||
|
assert.NotContains(t, output, "first")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_Actions(t *testing.T) {
|
||||||
|
acts := []actions.Action{
|
||||||
|
actions.AddFact{Type: "add_fact", Value: "test fact"},
|
||||||
|
actions.Message{Type: "message", Platform: "telegram", Text: "hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), chat.Message{}).
|
||||||
|
Actions(acts).
|
||||||
|
Info("actions logged")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "actions logged")
|
||||||
|
assert.Contains(t, output, "add_fact")
|
||||||
|
assert.Contains(t, output, "test fact")
|
||||||
|
assert.Contains(t, output, "message")
|
||||||
|
assert.Contains(t, output, "telegram")
|
||||||
|
assert.Contains(t, output, "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_Actions_ReplacesPrevious(t *testing.T) {
|
||||||
|
first := []actions.Action{actions.AddFact{Type: "add_fact", Value: "first"}}
|
||||||
|
second := []actions.Action{actions.Message{Type: "message", Platform: "telegram", Text: "second"}}
|
||||||
|
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), chat.Message{}).
|
||||||
|
Actions(first).
|
||||||
|
Actions(second).
|
||||||
|
Info("actions replaced")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "actions replaced")
|
||||||
|
assert.NotContains(t, output, "first")
|
||||||
|
assert.Contains(t, output, "second")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_Warn_WithErrors(t *testing.T) {
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), chat.Message{}).
|
||||||
|
Warn("warning message", errors.New("err1"), errors.New("err2"))
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "warning message")
|
||||||
|
assert.Contains(t, output, "WARN")
|
||||||
|
assert.Contains(t, output, "err1")
|
||||||
|
assert.Contains(t, output, "err2")
|
||||||
|
assert.Contains(t, output, "duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_Warn_NoErrors(t *testing.T) {
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), chat.Message{}).
|
||||||
|
Warn("warning without errors")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "warning without errors")
|
||||||
|
assert.Contains(t, output, "WARN")
|
||||||
|
assert.NotContains(t, output, "\"error\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_Error(t *testing.T) {
|
||||||
|
output := captureLog(func() {
|
||||||
|
FromMessage(context.Background(), chat.Message{}).
|
||||||
|
Error("error message", errors.New("fatal"))
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "error message")
|
||||||
|
assert.Contains(t, output, "ERROR")
|
||||||
|
assert.Contains(t, output, "fatal")
|
||||||
|
assert.Contains(t, output, "duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpan_Log_NoContext(t *testing.T) {
|
||||||
|
output := captureLog(func() {
|
||||||
|
s := FromMessage(context.Background(), chat.Message{Chat: "telegram", ID: "123", Text: "hi"})
|
||||||
|
s.ctx = nil
|
||||||
|
s.Info("no context")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, output, "no context")
|
||||||
|
assert.Contains(t, output, "duration")
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/d1nch8g/jules/chat"
|
||||||
|
"github.com/d1nch8g/jules/database"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
*database.User
|
||||||
|
|
||||||
|
Chats []database.Chat
|
||||||
|
Facts []database.Fact
|
||||||
|
Contacts []database.Contact
|
||||||
|
IncomingNotifications []database.Notification
|
||||||
|
OutgoingNotifications []database.Notification
|
||||||
|
RecentActions []database.Action
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromMessage(ctx context.Context, db database.Database, msg chat.Message, actionLimit int) (*User, error) {
|
||||||
|
user, err := ensureUserByMessage(ctx, db, msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure user by message: %w", err)
|
||||||
|
}
|
||||||
|
return enrich(ctx, db, user, actionLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromNotification(ctx context.Context, db database.Database, notif database.Notification, actionLimit int) (*User, error) {
|
||||||
|
user, err := db.Users().Get(ctx, notif.UserID, database.UserLookupByID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user by id: %w", err)
|
||||||
|
}
|
||||||
|
return enrich(ctx, db, user, actionLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrich(ctx context.Context, db database.Database, user *database.User, actionLimit int) (*User, error) {
|
||||||
|
var enriched User
|
||||||
|
enriched.User = user
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
enriched.Chats, err = db.Chats().List(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list chats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched.Facts, err = db.Facts().List(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list facts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched.Contacts, err = db.Contacts().List(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list contacts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched.IncomingNotifications, err = db.Notifications().List(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list incoming notifications: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched.OutgoingNotifications, err = db.Notifications().ListOutgoing(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list outgoing notifications: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched.RecentActions, err = db.Actions().Recent(ctx, user.ID, actionLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get recent actions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &enriched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureUserByMessage(ctx context.Context, db database.Database, msg chat.Message) (*database.User, error) {
|
||||||
|
userID, err := db.Chats().GetUserID(ctx, msg.Chat, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrNotFound) {
|
||||||
|
return createUserAndAttachChat(ctx, db, msg)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("get user id by chat: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := db.Users().Get(ctx, userID, database.UserLookupByID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user by id: %w", err)
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUserAndAttachChat(ctx context.Context, db database.Database, msg chat.Message) (*database.User, error) {
|
||||||
|
user := &database.User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
PreferredChat: msg.Chat,
|
||||||
|
CountUpdatedAt: time.Now(),
|
||||||
|
BindCode: uuid.New(),
|
||||||
|
ContactCode: uuid.New(),
|
||||||
|
Role: "free",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Users().Create(ctx, user); err != nil {
|
||||||
|
return nil, fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Chats().Attach(ctx, user.ID, msg.Chat, msg.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("attach chat: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/d1nch8g/jules/chat"
|
||||||
|
"github.com/d1nch8g/jules/database"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockDB struct {
|
||||||
|
users *mockUsers
|
||||||
|
chats *mockChats
|
||||||
|
facts *mockFacts
|
||||||
|
contacts *mockContacts
|
||||||
|
notifications *mockNotifications
|
||||||
|
actions *mockActions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDB) Users() database.Users { return m.users }
|
||||||
|
func (m *mockDB) Chats() database.Chats { return m.chats }
|
||||||
|
func (m *mockDB) Facts() database.Facts { return m.facts }
|
||||||
|
func (m *mockDB) Contacts() database.Contacts { return m.contacts }
|
||||||
|
func (m *mockDB) Notifications() database.Notifications { return m.notifications }
|
||||||
|
func (m *mockDB) Actions() database.Actions { return m.actions }
|
||||||
|
func (m *mockDB) Close() error { return nil }
|
||||||
|
|
||||||
|
type mockUsers struct {
|
||||||
|
getFunc func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error)
|
||||||
|
createFunc func(ctx context.Context, user *database.User) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUsers) Get(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return m.getFunc(ctx, id, lookup)
|
||||||
|
}
|
||||||
|
func (m *mockUsers) Create(ctx context.Context, user *database.User) error {
|
||||||
|
return m.createFunc(ctx, user)
|
||||||
|
}
|
||||||
|
func (m *mockUsers) Update(ctx context.Context, user *database.User) error { return nil }
|
||||||
|
func (m *mockUsers) Delete(ctx context.Context, id uuid.UUID) error { return nil }
|
||||||
|
|
||||||
|
type mockChats struct {
|
||||||
|
getUserIDFunc func(ctx context.Context, platform, identifier string) (uuid.UUID, error)
|
||||||
|
attachFunc func(ctx context.Context, userID uuid.UUID, platform, identifier string) error
|
||||||
|
listFunc func(ctx context.Context, userID uuid.UUID) ([]database.Chat, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChats) Attach(ctx context.Context, userID uuid.UUID, platform, identifier string) error {
|
||||||
|
return m.attachFunc(ctx, userID, platform, identifier)
|
||||||
|
}
|
||||||
|
func (m *mockChats) Detach(ctx context.Context, userID uuid.UUID, platform string) error { return nil }
|
||||||
|
func (m *mockChats) GetUserID(ctx context.Context, platform, identifier string) (uuid.UUID, error) {
|
||||||
|
return m.getUserIDFunc(ctx, platform, identifier)
|
||||||
|
}
|
||||||
|
func (m *mockChats) List(ctx context.Context, userID uuid.UUID) ([]database.Chat, error) {
|
||||||
|
return m.listFunc(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockFacts struct {
|
||||||
|
listFunc func(ctx context.Context, userID uuid.UUID) ([]database.Fact, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockFacts) Add(ctx context.Context, userID uuid.UUID, value string) error { return nil }
|
||||||
|
func (m *mockFacts) List(ctx context.Context, userID uuid.UUID) ([]database.Fact, error) {
|
||||||
|
return m.listFunc(ctx, userID)
|
||||||
|
}
|
||||||
|
func (m *mockFacts) Delete(ctx context.Context, userID uuid.UUID, value string) error { return nil }
|
||||||
|
|
||||||
|
type mockContacts struct {
|
||||||
|
listFunc func(ctx context.Context, ownerID uuid.UUID) ([]database.Contact, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockContacts) Add(ctx context.Context, contact *database.Contact) error { return nil }
|
||||||
|
func (m *mockContacts) List(ctx context.Context, ownerID uuid.UUID) ([]database.Contact, error) {
|
||||||
|
return m.listFunc(ctx, ownerID)
|
||||||
|
}
|
||||||
|
func (m *mockContacts) Delete(ctx context.Context, ownerID, targetID uuid.UUID) error { return nil }
|
||||||
|
|
||||||
|
type mockNotifications struct {
|
||||||
|
listFunc func(ctx context.Context, userID uuid.UUID) ([]database.Notification, error)
|
||||||
|
listOutgoingFunc func(ctx context.Context, initiatorID uuid.UUID) ([]database.Notification, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockNotifications) Push(ctx context.Context, n *database.Notification) error { return nil }
|
||||||
|
func (m *mockNotifications) Pop(ctx context.Context, limit int) ([]database.Notification, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockNotifications) List(ctx context.Context, userID uuid.UUID) ([]database.Notification, error) {
|
||||||
|
return m.listFunc(ctx, userID)
|
||||||
|
}
|
||||||
|
func (m *mockNotifications) ListOutgoing(ctx context.Context, initiatorID uuid.UUID) ([]database.Notification, error) {
|
||||||
|
return m.listOutgoingFunc(ctx, initiatorID)
|
||||||
|
}
|
||||||
|
func (m *mockNotifications) Delete(ctx context.Context, id uuid.UUID) error { return nil }
|
||||||
|
|
||||||
|
type mockActions struct {
|
||||||
|
recentFunc func(ctx context.Context, userID uuid.UUID, limit int) ([]database.Action, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockActions) Log(ctx context.Context, a *database.Action) error { return nil }
|
||||||
|
func (m *mockActions) Recent(ctx context.Context, userID uuid.UUID, limit int) ([]database.Action, error) {
|
||||||
|
return m.recentFunc(ctx, userID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromMessage_ExistingUser(t *testing.T) {
|
||||||
|
userID := uuid.New()
|
||||||
|
msg := chat.Message{Chat: "telegram", ID: "123", Text: "hello"}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) {
|
||||||
|
return userID, nil
|
||||||
|
},
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Chat, error) {
|
||||||
|
return []database.Chat{}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: &mockUsers{
|
||||||
|
getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return &database.User{ID: userID}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
facts: &mockFacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Fact, error) { return nil, nil }},
|
||||||
|
contacts: &mockContacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Contact, error) { return nil, nil }},
|
||||||
|
notifications: &mockNotifications{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }, listOutgoingFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }},
|
||||||
|
actions: &mockActions{recentFunc: func(ctx context.Context, id uuid.UUID, limit int) ([]database.Action, error) { return nil, nil }},
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := FromMessage(t.Context(), db, msg, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, userID, u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromMessage_NewUser(t *testing.T) {
|
||||||
|
msg := chat.Message{Chat: "telegram", ID: "123", Text: "hello"}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) {
|
||||||
|
return uuid.Nil, database.ErrNotFound
|
||||||
|
},
|
||||||
|
attachFunc: func(ctx context.Context, userID uuid.UUID, platform, identifier string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Chat, error) {
|
||||||
|
return []database.Chat{}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: &mockUsers{
|
||||||
|
createFunc: func(ctx context.Context, user *database.User) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
facts: &mockFacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Fact, error) { return nil, nil }},
|
||||||
|
contacts: &mockContacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Contact, error) { return nil, nil }},
|
||||||
|
notifications: &mockNotifications{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }, listOutgoingFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }},
|
||||||
|
actions: &mockActions{recentFunc: func(ctx context.Context, id uuid.UUID, limit int) ([]database.Action, error) { return nil, nil }},
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := FromMessage(t.Context(), db, msg, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, uuid.Nil, u.ID)
|
||||||
|
assert.Equal(t, "telegram", u.PreferredChat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromMessage_ChatsGetUserIDError(t *testing.T) {
|
||||||
|
msg := chat.Message{Chat: "telegram", ID: "123", Text: "hello"}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) {
|
||||||
|
return uuid.Nil, errors.New("db down")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := FromMessage(t.Context(), db, msg, 10)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "ensure user by message")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromMessage_UsersGetError(t *testing.T) {
|
||||||
|
userID := uuid.New()
|
||||||
|
msg := chat.Message{Chat: "telegram", ID: "123", Text: "hello"}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) {
|
||||||
|
return userID, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: &mockUsers{
|
||||||
|
getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return nil, errors.New("db down")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := FromMessage(t.Context(), db, msg, 10)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "ensure user by message")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromMessage_EnrichmentErrors(t *testing.T) {
|
||||||
|
userID := uuid.New()
|
||||||
|
msg := chat.Message{Chat: "telegram", ID: "123", Text: "hello"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
db *mockDB
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "chats list error",
|
||||||
|
db: &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) { return userID, nil },
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Chat, error) {
|
||||||
|
return nil, errors.New("chats down")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: &mockUsers{getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return &database.User{ID: userID}, nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
errContains: "list chats",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "facts list error",
|
||||||
|
db: &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) { return userID, nil },
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Chat, error) { return nil, nil },
|
||||||
|
},
|
||||||
|
users: &mockUsers{getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return &database.User{ID: userID}, nil
|
||||||
|
}},
|
||||||
|
facts: &mockFacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Fact, error) {
|
||||||
|
return nil, errors.New("facts down")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
errContains: "list facts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contacts list error",
|
||||||
|
db: &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) { return userID, nil },
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Chat, error) { return nil, nil },
|
||||||
|
},
|
||||||
|
users: &mockUsers{getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return &database.User{ID: userID}, nil
|
||||||
|
}},
|
||||||
|
facts: &mockFacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Fact, error) { return nil, nil }},
|
||||||
|
contacts: &mockContacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Contact, error) {
|
||||||
|
return nil, errors.New("contacts down")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
errContains: "list contacts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "incoming notifications error",
|
||||||
|
db: &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) { return userID, nil },
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Chat, error) { return nil, nil },
|
||||||
|
},
|
||||||
|
users: &mockUsers{getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return &database.User{ID: userID}, nil
|
||||||
|
}},
|
||||||
|
facts: &mockFacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Fact, error) { return nil, nil }},
|
||||||
|
contacts: &mockContacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Contact, error) { return nil, nil }},
|
||||||
|
notifications: &mockNotifications{
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) {
|
||||||
|
return nil, errors.New("notifs down")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errContains: "list incoming notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outgoing notifications error",
|
||||||
|
db: &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) { return userID, nil },
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Chat, error) { return nil, nil },
|
||||||
|
},
|
||||||
|
users: &mockUsers{getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return &database.User{ID: userID}, nil
|
||||||
|
}},
|
||||||
|
facts: &mockFacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Fact, error) { return nil, nil }},
|
||||||
|
contacts: &mockContacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Contact, error) { return nil, nil }},
|
||||||
|
notifications: &mockNotifications{
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil },
|
||||||
|
listOutgoingFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) {
|
||||||
|
return nil, errors.New("outgoing down")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errContains: "list outgoing notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "actions recent error",
|
||||||
|
db: &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) { return userID, nil },
|
||||||
|
listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Chat, error) { return nil, nil },
|
||||||
|
},
|
||||||
|
users: &mockUsers{getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return &database.User{ID: userID}, nil
|
||||||
|
}},
|
||||||
|
facts: &mockFacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Fact, error) { return nil, nil }},
|
||||||
|
contacts: &mockContacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Contact, error) { return nil, nil }},
|
||||||
|
notifications: &mockNotifications{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }, listOutgoingFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }},
|
||||||
|
actions: &mockActions{recentFunc: func(ctx context.Context, id uuid.UUID, limit int) ([]database.Action, error) {
|
||||||
|
return nil, errors.New("actions down")
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
errContains: "get recent actions",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := FromMessage(t.Context(), tt.db, msg, 10)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.errContains)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromNotification(t *testing.T) {
|
||||||
|
notif := database.Notification{UserID: uuid.New(), Content: "test"}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
users: &mockUsers{
|
||||||
|
getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return &database.User{ID: id}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chats: &mockChats{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Chat, error) { return nil, nil }},
|
||||||
|
facts: &mockFacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Fact, error) { return nil, nil }},
|
||||||
|
contacts: &mockContacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Contact, error) { return nil, nil }},
|
||||||
|
notifications: &mockNotifications{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }, listOutgoingFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }},
|
||||||
|
actions: &mockActions{recentFunc: func(ctx context.Context, id uuid.UUID, limit int) ([]database.Action, error) { return nil, nil }},
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := FromNotification(t.Context(), db, notif, 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, notif.UserID, u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromNotification_UserNotFound(t *testing.T) {
|
||||||
|
notif := database.Notification{UserID: uuid.New()}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
users: &mockUsers{
|
||||||
|
getFunc: func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
|
||||||
|
return nil, database.ErrNotFound
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := FromNotification(t.Context(), db, notif, 10)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "get user by id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserAndAttachChat_CreateError(t *testing.T) {
|
||||||
|
msg := chat.Message{Chat: "telegram", ID: "123", Text: "hello"}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) {
|
||||||
|
return uuid.Nil, database.ErrNotFound
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: &mockUsers{
|
||||||
|
createFunc: func(ctx context.Context, user *database.User) error {
|
||||||
|
return errors.New("create failed")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := FromMessage(t.Context(), db, msg, 10)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "create user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserAndAttachChat_AttachError(t *testing.T) {
|
||||||
|
msg := chat.Message{Chat: "telegram", ID: "123", Text: "hello"}
|
||||||
|
|
||||||
|
db := &mockDB{
|
||||||
|
chats: &mockChats{
|
||||||
|
getUserIDFunc: func(ctx context.Context, platform, identifier string) (uuid.UUID, error) {
|
||||||
|
return uuid.Nil, database.ErrNotFound
|
||||||
|
},
|
||||||
|
attachFunc: func(ctx context.Context, userID uuid.UUID, platform, identifier string) error {
|
||||||
|
return errors.New("attach failed")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: &mockUsers{
|
||||||
|
createFunc: func(ctx context.Context, user *database.User) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
facts: &mockFacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Fact, error) { return nil, nil }},
|
||||||
|
contacts: &mockContacts{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Contact, error) { return nil, nil }},
|
||||||
|
notifications: &mockNotifications{listFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }, listOutgoingFunc: func(ctx context.Context, id uuid.UUID) ([]database.Notification, error) { return nil, nil }},
|
||||||
|
actions: &mockActions{recentFunc: func(ctx context.Context, id uuid.UUID, limit int) ([]database.Action, error) { return nil, nil }},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := FromMessage(t.Context(), db, msg, 10)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "attach chat")
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -11,7 +12,7 @@ require (
|
|||||||
github.com/lmittmann/tint v1.1.3
|
github.com/lmittmann/tint v1.1.3
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
golang.org/x/text v0.31.0
|
golang.org/x/net v0.47.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
|
|||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
@@ -71,10 +73,10 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd
|
|||||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
Reference in New Issue
Block a user