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:
d1nch8g
2026-04-20 23:40:55 +03:00
parent 9d3cd703c9
commit 658bf657ef
20 changed files with 2216 additions and 1645 deletions
+1
View File
@@ -33,3 +33,4 @@ go.work.sum
jules
.env
.ENV
secret
+15 -1
View File
@@ -5,11 +5,13 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
"github.com/d1nch8g/jules/chat"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"golang.org/x/net/proxy"
)
const BaseURL = tgbotapi.APIEndpoint
@@ -18,11 +20,23 @@ type Bot struct {
api *tgbotapi.BotAPI
}
func New(token, baseURL string) (*Bot, error) {
func New(token, baseURL, proxyURL string) (*Bot, error) {
client := &http.Client{
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)
if err != nil {
return nil, fmt.Errorf("failed to connect to telegram: %w", err)
+57 -3
View File
@@ -3,12 +3,15 @@ package telegram
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/armon/go-socks5"
"github.com/stretchr/testify/assert"
"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) {
mock := &mockTelegramServer{}
return httptest.NewServer(mock.handler()), mock
@@ -78,7 +101,7 @@ func newMockServer() (*httptest.Server, *mockTelegramServer) {
func newTestBot(t *testing.T, server *httptest.Server) *Bot {
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)
return bot
}
@@ -95,7 +118,7 @@ func TestNew_Error(t *testing.T) {
server, _ := newMockServer()
defer server.Close()
_, err := New("test_token", server.URL+"/invalid")
_, err := New("test_token", server.URL+"/invalid", "")
assert.Error(t, err)
}
@@ -110,7 +133,7 @@ func TestNew_GetMeError(t *testing.T) {
}))
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.Contains(t, err.Error(), "failed to connect to telegram")
}
@@ -194,3 +217,34 @@ func TestReceive_ContextCancelDuringSend(t *testing.T) {
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
View File
@@ -30,7 +30,7 @@ func main() {
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 {
exit("failed to initialize telegram", err)
}
@@ -55,6 +55,8 @@ func main() {
},
})
slog.Info("starting...")
if err := e.Run(ctx); err != nil {
exit("run failed", err)
}
@@ -64,7 +66,6 @@ func initLogger() {
slog.SetDefault(slog.New(tint.NewHandler(os.Stderr, &tint.Options{
Level: slog.LevelDebug,
TimeFormat: "15:04:05.000",
AddSource: true,
})))
}
+9 -8
View File
@@ -7,6 +7,7 @@ import (
type Config struct {
TelegramBotToken string
TelegramProxy string
PostgresConnString string
DeepSeekAPIKey string
BraveAPIKey string
@@ -15,24 +16,24 @@ type Config struct {
func Load() (*Config, error) {
cfg := &Config{
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
TelegramProxy: os.Getenv("TELEGRAM_PROXY"),
DeepSeekAPIKey: os.Getenv("DEEPSEEK_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 == "" {
return nil, errors.New("TELEGRAM_BOT_TOKEN is required")
}
if cfg.BraveAPIKey == "" {
return nil, errors.New("BRAVE_API_KEY is required")
}
if cfg.PostgresConnString == "" {
return nil, errors.New("POSTGRES_CONN_STRING is required")
}
return cfg, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
+30 -36
View File
@@ -3,6 +3,9 @@ package config
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoad(t *testing.T) {
@@ -18,6 +21,7 @@ func TestLoad(t *testing.T) {
"TELEGRAM_BOT_TOKEN": "tg",
"DEEPSEEK_API_KEY": "ds",
"BRAVE_API_KEY": "br",
"POSTGRES_CONN_STRING": "pg://localhost/db",
},
wantErr: false,
},
@@ -26,35 +30,49 @@ func TestLoad(t *testing.T) {
env: map[string]string{
"DEEPSEEK_API_KEY": "ds",
"BRAVE_API_KEY": "br",
"POSTGRES_CONN_STRING": "pg://localhost/db",
},
wantErr: true,
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",
env: map[string]string{
"TELEGRAM_BOT_TOKEN": "tg",
"DEEPSEEK_API_KEY": "ds",
"POSTGRES_CONN_STRING": "pg://localhost/db",
},
wantErr: true,
errMsg: "BRAVE_API_KEY is required",
},
{
name: "default postgres conn string",
name: "missing postgres conn string",
env: map[string]string{
"TELEGRAM_BOT_TOKEN": "tg",
"DEEPSEEK_API_KEY": "ds",
"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{
"TELEGRAM_BOT_TOKEN": "tg",
"DEEPSEEK_API_KEY": "ds",
"BRAVE_API_KEY": "br",
"POSTGRES_CONN_STRING": "pg://custom",
"POSTGRES_CONN_STRING": "pg://localhost/db",
"TELEGRAM_PROXY": "socks5://localhost:1080",
},
wantErr: false,
},
@@ -69,41 +87,17 @@ func TestLoad(t *testing.T) {
cfg, err := Load()
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("expected error %q, got %q", tt.errMsg, err.Error())
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
return
}
require.NoError(t, err)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedTG := tt.env["TELEGRAM_BOT_TOKEN"]
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)
}
assert.Equal(t, tt.env["TELEGRAM_BOT_TOKEN"], cfg.TelegramBotToken)
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)
assert.Equal(t, tt.env["TELEGRAM_PROXY"], cfg.TelegramProxy)
})
}
}
-239
View File
@@ -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
}
+324 -158
View File
@@ -1,52 +1,50 @@
package actions //nolint:cyclop // optimized action parser
package actions
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"time"
"github.com/d1nch8g/jules/chat"
"github.com/d1nch8g/jules/database"
"github.com/d1nch8g/jules/engine/jtime"
"github.com/d1nch8g/jules/engine/user"
"github.com/d1nch8g/jules/search"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"golang.org/x/text/language"
)
// Important notice - when adding an action, don't forget to add it to:
//
// 1) Constants in that package
// 2) Parser in that package
// 3) Parser tests in that package
// 4) Prompt, since it is a raw string
// 5) Processing logic, in an engine module
//
// TODO: rewrite module in such a way, that every action related stuff
// is processed within this module, not elsewhere, and so that it provides
// necessary components for other modules.
const ActionsPromptPart = `
=== AVAILABLE ACTIONS ===
{"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": "... (repetition rules)"}
{"type": "add_notification", "target": "contact name", "time": "...", "content": "... (repetition rules)"}
{"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"}
`
const (
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": "search", "query": "search query"} // temporary disabled
type UserMessage struct {
Type string `json:"type"`
Message string `json:"message"`
type Action interface {
Validate(ctx context.Context, rt *Runtime) error
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 {
@@ -54,48 +52,243 @@ type BindChat struct {
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, &notif); err != nil {
return err
}
}
for _, notif := range u.OutgoingNotifications {
if err := rt.Database.Notifications().Push(ctx, &notif); err != nil {
return err
}
}
return nil
}
type Message struct {
Type string `json:"type"`
Platform string `json:"platform"`
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 string `json:"type"`
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 string `json:"type"`
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 string `json:"type"`
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 string `json:"type"`
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 string `json:"type"`
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 string `json:"type"`
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 string `json:"type"`
UUID string `json:"uuid"`
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 string `json:"type"`
Target string `json:"target"`
@@ -103,178 +296,147 @@ type AddNotification struct {
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 string `json:"type"`
UUID string `json:"uuid"`
}
type Search struct {
Type string `json:"type"`
Query string `json:"query"`
func (a RemoveNotification) 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)
}
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, "[")
end := strings.LastIndex(raw, "]")
if start != -1 && end != -1 && end > start {
raw = raw[start : end+1]
}
raw = strings.TrimSpace(raw)
if !gjson.Valid(raw) {
return nil, errors.New("response is not valid JSON")
}
result := gjson.Parse(raw)
if !result.IsArray() {
return nil, errors.New("response must be an array of actions")
}
var actions []any
var actions []Action
for i, item := range result.Array() {
actionType := item.Get("type").String()
if actionType == "" {
return nil, fmt.Errorf("action %d: missing required field 'type'", i)
}
var action any
var action Action
var err error
switch actionType {
case ActionBindChat:
case "bind_chat":
var a BindChat
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
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)
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionMessage:
case "message":
var a Message
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
if !slices.Contains(chat.PlatformList, a.Platform) {
err = errors.New("platform is unsupported")
} else if a.Text == "" {
err = errors.New("text is empty")
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionWait:
case "wait":
var a Wait
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
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)")
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionUpdateLang:
case "update_lang":
var a UpdateLang
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
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)
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionUpdateTZ:
case "update_tz":
var a UpdateTZ
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
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)
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionSetChat:
case "set_chat":
var a SetChat
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
if a.Chat == "" {
err = errors.New("chat is required")
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionAddFact:
case "add_fact":
var a AddFact
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
if a.Value == "" {
err = errors.New("value is required")
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionRemoveFact:
case "remove_fact":
var a RemoveFact
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
if a.Value == "" {
err = errors.New("value is required")
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionAddContact:
case "add_contact":
var a AddContact
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
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")
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionAddNotification:
case "add_notification":
var a AddNotification
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
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)
}
}
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case ActionRemoveNotification:
case "remove_notification":
var a RemoveNotification
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
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)
}
}
err = json.Unmarshal([]byte(item.Raw), &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:
err = fmt.Errorf("unknown action type: %s", actionType)
return nil, fmt.Errorf("action %d: unknown action type: %s", i, actionType)
}
if err != nil {
@@ -282,15 +444,19 @@ func Parse(raw string, userTimezone string) ([]any, error) { //nolint:gocognit,g
}
actions = append(actions, action)
}
return actions, nil
}
func Raw(action any) json.RawMessage {
b, err := json.Marshal(action)
if err != nil {
slog.Error("failed to marshal action to bytes", "error", err)
return json.RawMessage{}
type UserAction struct {
Type string `json:"type"`
Content string `json:"content"`
}
return json.RawMessage(b)
func (UserAction) Validate(ctx context.Context, rt *Runtime) error { return nil }
func (UserAction) Execute(ctx context.Context, rt *Runtime) error { return nil }
func Raw(v Action) json.RawMessage {
data, _ := json.Marshal(v)
return data
}
+524 -390
View File
@@ -1,25 +1,446 @@
package actions
import (
"context"
"testing"
"time"
"github.com/d1nch8g/jules/chat"
"github.com/d1nch8g/jules/database"
"github.com/d1nch8g/jules/engine/user"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"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 {
name string
action Message
wantErr bool
}{
{"valid", Message{Platform: "telegram", Text: "hi"}, false},
{"empty text", Message{Platform: "telegram", Text: ""}, true},
{"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
userTimezone string
wantLen int
userTZ string
wantActions int
wantErr bool
errContains string
check func(t *testing.T, actions []any)
}{
{
name: "empty string",
name: "empty",
raw: "",
wantErr: true,
},
@@ -29,429 +450,142 @@ func TestParseActions(t *testing.T) {
wantErr: true,
},
{
name: "not an array",
name: "not array",
raw: `{"type":"wait"}`,
wantErr: true,
errContains: "must be an array",
},
{
name: "missing type field",
raw: `[{"ms": 100}]`,
wantErr: true,
errContains: "missing required field 'type'",
},
{
name: "unknown action type",
name: "unknown type",
raw: `[{"type":"unknown"}]`,
wantErr: true,
errContains: "unknown action type",
},
{
name: "missing type",
raw: `[{"ms":100}]`,
wantErr: true,
},
{
name: "wait valid",
raw: `[{"type": "wait", "ms": 1000}]`,
wantLen: 1,
check: func(t *testing.T, actions []any) {
a, ok := actions[0].(Wait)
assert.True(t, ok)
assert.Equal(t, 1000, a.Ms)
},
raw: `[{"type":"wait","ms":100}]`,
wantActions: 1,
},
{
name: "wait ms <= 0",
raw: `[{"type": "wait", "ms": 0}]`,
wantErr: true,
errContains: "ms must be positive",
},
{
name: "wait ms > 60000",
raw: `[{"type": "wait", "ms": 70000}]`,
wantErr: true,
errContains: "ms cannot exceed 60000",
},
{
name: "bind_chat valid",
raw: `[{"type": "bind_chat", "uuid": "` + uuid.New().String() + `"}]`,
wantLen: 1,
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",
raw: `[{"type": "bind_chat"}]`,
wantErr: true,
errContains: "uuid is required",
},
{
name: "bind_chat invalid uuid",
raw: `[{"type": "bind_chat", "uuid": "not-uuid"}]`,
wantErr: true,
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: "message valid",
raw: `[{"type":"message","platform":"telegram","text":"hi"}]`,
wantActions: 1,
},
{
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)
},
raw: `[{"type":"add_fact","value":"test"}]`,
wantActions: 1,
},
{
name: "multiple actions",
raw: `[{"type":"wait","ms":100},{"type":"add_fact","value":"test"}]`,
wantLen: 2,
wantActions: 2,
},
{
name: "json inside markdown",
name: "json with markdown",
raw: "```json\n[{\"type\":\"wait\",\"ms\":100}]\n```",
wantLen: 1,
wantActions: 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",
raw: "Here: [{\"type\":\"wait\",\"ms\":100}]",
wantActions: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tz := tt.userTimezone
if tz == "" {
tz = "UTC"
}
actions, err := Parse(tt.raw, tz)
actions, err := Parse(tt.raw, "UTC")
if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Len(t, actions, tt.wantLen)
if tt.check != nil {
tt.check(t, actions)
}
assert.Len(t, actions, tt.wantActions)
})
}
}
func TestRaw(t *testing.T) {
t.Run("valid action", func(t *testing.T) {
action := AddFact{Type: "add_fact", Value: "test"}
raw := Raw(action)
assert.NotEmpty(t, raw)
a := AddFact{Type: "add_fact", Value: "test"}
raw := Raw(a)
assert.JSONEq(t, `{"type":"add_fact","value":"test"}`, string(raw))
})
}
t.Run("invalid action", func(t *testing.T) {
action := make(chan int)
raw := Raw(action)
assert.Empty(t, raw)
func TestBindChat_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 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)
}
-139
View File
@@ -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...)
}
-195
View File
@@ -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
}
+82 -137
View File
@@ -3,192 +3,137 @@ package engine
import (
"context"
"errors"
"fmt"
"time"
"github.com/d1nch8g/jules/chat"
"github.com/d1nch8g/jules/database"
"github.com/d1nch8g/jules/engine/actions"
"github.com/d1nch8g/jules/engine/jlog"
"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) {
var (
le = jlog.FromMessage(ctx, msg)
user *database.User
)
span := trace.FromMessage(ctx, msg)
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 errors.Is(err, database.ErrNotFound) {
user = &database.User{
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)
span.Error("failed to get user from message", 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{
UserID: userID,
UserID: u.ID,
ExecutedAt: time.Now(),
Payload: actions.Raw(&actions.UserMessage{
Type: actions.ActionUserMessage,
Message: msg.Text,
Payload: actions.Raw(actions.UserAction{
Type: "user_message",
Content: msg.Text,
}),
})
if err != nil {
le.Error("unable to save action", err)
span.Error("failed to record action", err)
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) {
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 {
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
}
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) {
promptContext, err := e.collectPromptContext(ctx, user, source, message)
if err != nil {
le.Error("failed to build prompt", err)
return
}
le.Context(promptContext)
func (e *Engine) process(ctx context.Context, span *trace.Span, user *user.User, source, content string) {
var errs []error
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 {
promptString := prompt.Build(*promptCtx)
p := prompt.Build(user, source, content, errs...)
ctx, cancel := context.WithTimeout(ctx, e.LLMResponseTimeout)
result, err := e.LLM.Process(ctx, promptString)
result, err := e.LLM.Process(ctx, p)
cancel()
if err != nil {
promptCtx.Error = errors.Join(promptCtx.Error, err)
le.Warn("first attempt to receive response from LLM api failed", err)
errs = append(errs, err)
span.LLMResponse(result)
span.Warn("failed to receive LLM response", errs...)
continue
}
actions, err := actions.Parse(result, timezone)
actionSlice, err := actions.Parse(result, user.Timezone)
if err != nil {
promptCtx.Error = errors.Join(promptCtx.Error, err)
le.LLMResponse(result)
le.Warn("failed to parse actions by llm", err)
errs = append(errs, err)
span.LLMResponse(result)
span.Warn("failed to parse actions received from LLM", errs...)
continue
}
err = e.validateActions(ctx, actions, *promptCtx)
runtime := &actions.Runtime{
User: user,
Database: e.Database,
Searcher: e.Searcher,
Chats: e.Chats,
}
var validationFailed bool
for _, action := range actionSlice {
err = action.Validate(ctx, runtime)
if err != nil {
promptCtx.Error = errors.Join(promptCtx.Error, err)
le.Actions(actions)
le.Warn("failed to validate actions", err)
errs = append(errs, err)
validationFailed = true
}
}
if validationFailed {
span.Actions(actionSlice)
span.Warn("failed to validate actions", errs...)
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...)
}
return nil, errors.New("all attempt to receive LLM response failed")
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
}
span.Error("all attempts to process event have failed", errors.Join(errs...))
}
+83 -104
View File
@@ -6,8 +6,9 @@ import (
"time"
"github.com/d1nch8g/jules/database"
"github.com/d1nch8g/jules/engine/actions"
"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.
@@ -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.
=== ADDING VARIOUS CHATS ===
- Jules is multiplatform, currently supported: "telegram"
- When a new user comes without much info, ask if they already use Jules.
- If they do, ask for their BIND CODE,
- 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.
- Jules is multiplatform, currently supported: "telegram", soon "email"
- When a new user comes without much info, ask if they already use Jules
- If they do, ask for their BIND CODE
- 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
=== 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:
- 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"
=== COMMUNICATION STYLE ===
- Keep messages SHORT and CONCISE, like a real human texting.
- 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 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).
- 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.
- VERY SHORT messages like "Jules", "hey", "sup" should get VERY SHORT responses: "yo", "sup", "??", etc.
- Keep messages SHORT and CONCISE, like a real human texting
- 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 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)
- 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
- 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 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)
@@ -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
=== ONBOARDING & CAPABILITIES ===
- 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.
- Each user has an "Informed about Jules capabilities" fact.
- 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
- Each user has an "Informed about Jules capabilities" fact
- 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?"
- When the user agrees, explain using "reply" with "wait" between messages:
- Birthday reminders
- Cooking timers
- 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.
- 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.
- Dialog about informing will fully be loaded into context, so don't shoot the whole info in 1 message, wait for responses.
- 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"
- 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
=== BEHAVIOR RULES ===
- The user's integration level is determined by how many facts you know.
- LOW integration (few facts):
- Initiate dialogue VERY RARELY (once every 1-2 weeks).
- 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):
- Initiate 1-2 times per week.
- Suggest slightly more involved actions: weekly check-ins, birthday reminders.
- Initiate 1-2 times per week
- Suggest slightly more involved actions: weekly check-ins, birthday reminders
- HIGH integration (many facts, active notifications):
- Initiate 2-3 times per week MAX.
- You can suggest more complex routines.
- Initiate 2-3 times per week MAX
- You can suggest more complex routines
- REGARDLESS of integration:
- NEVER ping during work hours (9-18) or sleep time (23-08).
- Do NOT overload—one proactive suggestion per conversation is enough.
- NEVER ping during work hours (9-18) or sleep time (23-08)
- Do NOT overload—one proactive suggestion per conversation is enough
=== TECHNICAL RULES ===
- 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 language codes should be parsable by the system, by golang language.
- For notifications, specify in content if ONE-TIME or RECURRING.
- 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
- 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 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)
- 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".
- 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.
- NEVER use indentation to make responses faster.
- 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
- Do not give BIND CODE or CONTACT CODE unless user asks you that he wants to add contact or bind another chat
- 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 ===
{"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 {
func Build(user *user.User, source, content string, errs ...error) string {
var b strings.Builder
b.WriteString(masterPrompt)
if len(ctx.Chats) > 0 {
if len(user.Chats) > 0 {
b.WriteString("Connected chats:\n")
for _, chat := range ctx.Chats {
for _, chat := range user.Chats {
fmt.Fprintf(&b, " - %s\n", chat.Platform)
}
}
if len(ctx.Facts) > 0 {
if len(user.Facts) > 0 {
b.WriteString("Facts:\n")
for _, f := range ctx.Facts {
for _, f := range user.Facts {
fmt.Fprintf(&b, " - %s\n", f.Value)
}
}
if len(ctx.Contacts) > 0 {
if len(user.Contacts) > 0 {
b.WriteString("Contacts:\n")
for _, c := range ctx.Contacts {
for _, c := range user.Contacts {
fmt.Fprintf(&b, " - %s\n", c.Name)
}
}
if len(ctx.IncomingNotifications) > 0 {
if len(user.IncomingNotifications) > 0 {
b.WriteString("Incoming notifications:\n")
for _, n := range ctx.IncomingNotifications {
localTime := jtime.ToLocal(n.ScheduledAt, ctx.UserTimezone)
for _, n := range user.IncomingNotifications {
localTime := jtime.ToLocal(n.ScheduledAt, user.Timezone)
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")
for _, n := range ctx.OutgoingNotificaions {
localTime := jtime.ToLocal(n.ScheduledAt, ctx.UserTimezone)
for _, n := range user.OutgoingNotifications {
localTime := jtime.ToLocal(n.ScheduledAt, user.Timezone)
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")
for _, a := range ctx.RecentActions {
localTime := jtime.ToLocal(a.ExecutedAt, ctx.UserTimezone)
for _, a := range user.RecentActions {
localTime := jtime.ToLocal(a.ExecutedAt, user.Timezone)
fmt.Fprintf(&b, " - [%s] %s\n", localTime, string(a.Payload))
}
}
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)
loc, _ := time.LoadLocation(ctx.UserTimezone)
currentTime := jtime.CurrentLocalTime(user.Timezone)
loc, _ := time.LoadLocation(user.Timezone)
if loc == nil {
loc = time.UTC
}
weekday := time.Now().In(loc).Format("Monday")
fmt.Fprintf(&b, "Time: %s (%s)\n", currentTime, weekday)
fmt.Fprintf(&b, "Timezone: %s\n", ctx.UserTimezone)
fmt.Fprintf(&b, "User chat (selected): %s\n", ctx.UserPreferredChat)
fmt.Fprintf(&b, "Bind code: %s\n", ctx.UserBindCode.String())
fmt.Fprintf(&b, "Contact code: %s\n", ctx.UserContactCode.String())
fmt.Fprintf(&b, "Timezone: %s\n", user.Timezone)
fmt.Fprintf(&b, "User chat (selected): %s\n", user.PreferredChat)
fmt.Fprintf(&b, "Bind code: %s\n", user.BindCode.String())
fmt.Fprintf(&b, "Contact code: %s\n", user.ContactCode.String())
b.WriteString(buildWeekdaysContext(ctx.UserTimezone))
b.WriteString(buildWeekdaysContext(user.Timezone))
b.WriteString("\n")
if ctx.Error != nil {
if len(errs) > 0 {
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("\n")
if ctx.MessageSource == database.DatabaseSource {
fmt.Fprintf(&b, "Process user notification: %s", ctx.MessageContent)
if source == database.DatabaseSource {
fmt.Fprintf(&b, "Process user notification: %s", content)
} else {
fmt.Fprintf(&b, "Message platform: %s\n", ctx.MessageSource)
fmt.Fprintf(&b, "Message contents: %s", ctx.MessageContent)
fmt.Fprintf(&b, "Message platform: %s\n", source)
fmt.Fprintf(&b, "Message contents: %s", content)
}
return b.String()
+158 -195
View File
@@ -1,230 +1,193 @@
package prompt
import (
"errors"
"slices"
"strings"
"testing"
"time"
"github.com/d1nch8g/jules/database"
"github.com/d1nch8g/jules/engine/user"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuild_Message(t *testing.T) {
bindCode := uuid.New()
contactCode := uuid.New()
ctx := Context{
UserLanguage: "en",
UserTimezone: "Europe/Moscow",
UserPreferredChat: "telegram",
UserBindCode: bindCode,
UserContactCode: contactCode,
func TestBuild(t *testing.T) {
tests := []struct {
name string
user *user.User
source string
content string
errs []error
contains []string
excludes []string
}{
{
name: "full user context with message",
user: &user.User{
User: &database.User{
Language: "en",
Timezone: "Europe/Moscow",
PreferredChat: "telegram",
BindCode: uuid.New(),
ContactCode: uuid.New(),
},
Chats: []database.Chat{
{Platform: "telegram", Identifier: "@test"},
{Platform: "telegram"},
{Platform: "whatsapp"},
},
Facts: []database.Fact{
{Value: "mom name is Irina"},
{Value: "goes to gym"},
{Value: "fact1"},
{Value: "fact2"},
},
Contacts: []database.Contact{
{Name: "Mom"},
{Name: "Brother"},
},
IncomingNotifications: []database.Notification{
{ScheduledAt: time.Now().Add(time.Hour), Content: "call mom"},
{ID: uuid.New(), ScheduledAt: time.Now(), Content: "incoming1"},
},
OutgoingNotificaions: []database.Notification{
{ScheduledAt: time.Now().Add(2 * time.Hour), Content: "ping brother"},
OutgoingNotifications: []database.Notification{
{ID: uuid.New(), ScheduledAt: time.Now(), Content: "outgoing1"},
},
RecentActions: []database.Action{
{ExecutedAt: time.Now().Add(-time.Hour), Payload: []byte(`{"type":"message","platform":"telegram","text":"hello"}`)},
{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:",
},
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 {
name string
timezone string
}{
{"UTC", "UTC"},
{"Moscow", "Europe/Moscow"},
{"invalid falls back to UTC", "Mars/City"},
}
weekdays := []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildWeekdaysContext(tt.timezone)
assert.Contains(t, result, "Next 7 days:")
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])
var err error
if len(tt.errs) > 0 {
err = tt.errs[0]
}
result := Build(tt.user, tt.source, tt.content, err)
// Check weekdays are in correct order (cyclically from today)
loc, _ := time.LoadLocation(tt.timezone)
if loc == nil {
loc = time.UTC
for _, s := range tt.contains {
assert.Contains(t, result, s)
}
today := time.Now().In(loc).Format("Monday")
startIdx := slices.Index(weekdays, today)
require.NotEqual(t, -1, startIdx)
for i := 0; i < 7; i++ {
expected := weekdays[(startIdx+i)%7]
assert.Equal(t, expected, foundWeekdays[i])
for _, s := range tt.excludes {
assert.NotContains(t, result, s)
}
// 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])
})
}
}
+121
View File
@@ -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...)
}
+233
View File
@@ -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")
}
+115
View File
@@ -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
}
+420
View File
@@ -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")
}
+2 -1
View File
@@ -4,6 +4,7 @@ go 1.26.1
require (
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/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
@@ -11,7 +12,7 @@ require (
github.com/lmittmann/tint v1.1.3
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
golang.org/x/text v0.31.0
golang.org/x/net v0.47.0
)
require (
+4 -2
View File
@@ -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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
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/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
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/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=