Files
jules/engine/actions/actions.go
T
2026-04-27 19:59:54 +03:00

589 lines
17 KiB
Go

package actions
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/d1nch8g/jules/chat"
"github.com/d1nch8g/jules/database"
"github.com/d1nch8g/jules/engine/timeconv"
"github.com/d1nch8g/jules/engine/user"
"github.com/d1nch8g/jules/search"
"github.com/google/uuid"
"github.com/tidwall/gjson"
)
const ActionsPromptPart = `=== AVAILABLE ACTIONS ===
{"type": "message", "platform": "telegram", "text": "short response"}
{"type": "message", "text": "short response", "target": "dad"}
{"type": "wait", "ms": 100-600}
{"type": "add_fact", "value": "fact about user"}
{"type": "remove_fact", "value": "fact to remove"}
{"type": "add_notification", "time": "2027-02-03 12:24", "content": "...", "repeat_on": "daily, each morning"}
{"type": "add_notification", "target": "mom", "time": "2026-01-02 15:04", "content": "...", "repeat_on": "monday 09:00"}
{"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"}
ONLY USE ACTION TYPES EXISTING ON THAT LIST
ACTIONS LIST SHOULD START WITH [ AND END WITH ]
ARRAY SHOULD CONTAIN PARSABLE ARRAY OF JSON OBJECTS
EMPTY ARRAYS ARE NOT ALLOWED
=== OUTPUT EXAMPLE ===
User has 2 facts and no ping notification, I should set one up. Also, he asked to remind him about gym and message his dad.
[{"type":"add_fact","value":"goes to gym"},{"type":"add_notification","time":"2026-04-25 09:00","content":"gym workout","repeat_on":"every monday, wednesday, friday at 09:00"},{"type":"message","text":"hey dad, your son is hitting the gym today 💪","target":"dad"},{"type":"wait","ms":1200},{"type":"message","platform":"telegram","text":"got it! reminded you for MWF 9am and messaged your dad"},{"type":"wait","ms":600},{"type":"message","platform":"telegram","text":"🔥"}]
`
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 {
Type string `json:"type"`
UUID string `json:"uuid"`
}
func (a BindChat) Validate(ctx context.Context, rt *Runtime) error {
if a.UUID == "" {
return errors.New("bind_chat: target_uuid is required")
}
if _, err := uuid.Parse(a.UUID); err != nil {
return fmt.Errorf("bind_chat: invalid UUID %q: %w", a.UUID, 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("bind_chat: user with bind code %s not found", a.UUID)
}
return fmt.Errorf("bind_chat: failed to get user by bind code: %w", 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 fmt.Errorf("bind_chat: failed to get target user: %w", err)
}
u := rt.User
if err = rt.Database.Users().Delete(ctx, u.ID); err != nil {
return fmt.Errorf("bind_chat: failed to delete temporary user: %w", err)
}
for _, chat := range u.Chats {
if err = rt.Database.Chats().Attach(ctx, targetUser.ID, chat.Platform, chat.Identifier); err != nil {
return fmt.Errorf("bind_chat: failed to attach chat %s: %w", chat.Platform, 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 fmt.Errorf("bind_chat: failed to migrate contact %s: %w", contact.Name, err)
}
}
for _, fact := range u.Facts {
if err = rt.Database.Facts().Add(ctx, targetUser.ID, fact.Value); err != nil {
return fmt.Errorf("bind_chat: failed to migrate fact %q: %w", fact.Value, 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 fmt.Errorf("bind_chat: failed to migrate incoming notification: %w", err)
}
}
for _, notif := range u.OutgoingNotifications {
if err = rt.Database.Notifications().Push(ctx, &notif); err != nil {
return fmt.Errorf("bind_chat: failed to migrate outgoing notification: %w", err)
}
}
return nil
}
type Message struct {
Type string `json:"type"`
Platform string `json:"platform,omitempty"`
Text string `json:"text"`
Target string `json:"target,omitempty"`
}
func (a Message) Validate(_ context.Context, rt *Runtime) error {
if a.Text == "" {
return errors.New("message: text is empty")
}
if a.Target != "" {
for _, c := range rt.User.Contacts {
if c.Name == a.Target {
return nil
}
}
return fmt.Errorf("message: target %s not found", a.Target)
}
return nil
}
func (a Message) Execute(ctx context.Context, rt *Runtime) error {
uid, plat := rt.User.ID, a.Platform
if plat == "" {
plat = rt.User.PreferredChat
}
if a.Target != "" {
contact, found := findContact(rt.User.Contacts, a.Target)
if !found {
return fmt.Errorf("message: target %s not found", a.Target)
}
t, err := rt.Database.Users().Get(ctx, contact.TargetID, database.UserLookupByID)
if err != nil {
return fmt.Errorf("failed to get contact user from db: %w", err)
}
uid = t.ID
if a.Platform != "" {
plat = a.Platform
} else {
plat = t.PreferredChat
}
}
chats, err := rt.Database.Chats().List(ctx, uid)
if err != nil {
return fmt.Errorf("failed to list chats: %w", err)
}
for _, c := range chats {
if c.Platform != plat {
continue
}
chat, ok := rt.Chats[plat]
if !ok {
return fmt.Errorf("platform: %s not available user", plat)
}
if err = chat.Send(ctx, c.Identifier, a.Text); err != nil {
return fmt.Errorf("failed to send message to chat: %w", err)
}
if err = rt.Database.Actions().Log(ctx, uid, "jules_msg", a.Text); err != nil {
return fmt.Errorf("failed to record action to database: %w", err)
}
return nil
}
return fmt.Errorf("message: %s not available", plat)
}
func findContact(contacts []database.Contact, name string) (database.Contact, bool) {
for _, c := range contacts {
if c.Name == name {
return c, true
}
}
return database.Contact{}, false
}
type Wait struct {
Type string `json:"type"`
Ms int `json:"ms"`
}
func (a Wait) Validate(_ context.Context, _ *Runtime) error {
if a.Ms <= 0 {
return fmt.Errorf("wait: ms must be positive, got %d", a.Ms)
}
if a.Ms > 60000 {
return fmt.Errorf("wait: ms cannot exceed 60000, got %d", a.Ms)
}
return nil
}
func (a Wait) Execute(_ context.Context, _ *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(_ context.Context, _ *Runtime) error {
if a.Lang == "" {
return errors.New("update_lang: lang is required")
}
return nil
}
func (a UpdateLang) Execute(ctx context.Context, rt *Runtime) error {
rt.User.Language = a.Lang
if err := rt.Database.Users().Update(ctx, rt.User.User); err != nil {
return fmt.Errorf("update_lang: failed to update user: %w", err)
}
return nil
}
type UpdateTZ struct {
Type string `json:"type"`
TZ string `json:"tz"`
}
func (a UpdateTZ) Validate(_ context.Context, _ *Runtime) error {
if a.TZ == "" {
return errors.New("update_tz: tz is required")
}
if _, err := time.LoadLocation(a.TZ); err != nil {
return fmt.Errorf("update_tz: invalid timezone %q: %w", a.TZ, err)
}
return nil
}
func (a UpdateTZ) Execute(ctx context.Context, rt *Runtime) error {
rt.User.Timezone = a.TZ
if err := rt.Database.Users().Update(ctx, rt.User.User); err != nil {
return fmt.Errorf("update_tz: failed to update user: %w", err)
}
return nil
}
type SetChat struct {
Type string `json:"type"`
Chat string `json:"chat"`
}
func (a SetChat) Validate(_ context.Context, rt *Runtime) error {
if a.Chat == "" {
return errors.New("set_chat: chat is required")
}
for _, c := range rt.User.Chats {
if c.Platform == a.Chat {
return nil
}
}
return fmt.Errorf("set_chat: chat %s not connected", a.Chat)
}
func (a SetChat) Execute(ctx context.Context, rt *Runtime) error {
rt.User.PreferredChat = a.Chat
if err := rt.Database.Users().Update(ctx, rt.User.User); err != nil {
return fmt.Errorf("set_chat: failed to update user: %w", err)
}
return nil
}
type AddFact struct {
Type string `json:"type"`
Value string `json:"value"`
}
func (a AddFact) Validate(_ context.Context, _ *Runtime) error {
if a.Value == "" {
return errors.New("add_fact: value is required")
}
return nil
}
func (a AddFact) Execute(ctx context.Context, rt *Runtime) error {
if err := rt.Database.Facts().Add(ctx, rt.User.ID, a.Value); err != nil {
return fmt.Errorf("add_fact: failed to add fact: %w", err)
}
return nil
}
type RemoveFact struct {
Type string `json:"type"`
Value string `json:"value"`
}
func (a RemoveFact) Validate(_ context.Context, _ *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
}
if err != nil {
return fmt.Errorf("remove_fact: failed to delete fact: %w", err)
}
return nil
}
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("add_contact: uuid is required")
}
if _, err := uuid.Parse(a.UUID); err != nil {
return fmt.Errorf("add_contact: invalid UUID %q: %w", a.UUID, err)
}
if a.Name == "" {
return errors.New("add_contact: 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("add_contact: user with contact code %s not found", a.UUID)
}
return fmt.Errorf("add_contact: failed to get user by contact code: %w", err)
}
return nil
}
func (a AddContact) Execute(ctx context.Context, rt *Runtime) error {
targetUser, err := rt.Database.Users().Get(ctx, uuid.MustParse(a.UUID), database.UserLookupByContactCode)
if err != nil {
return fmt.Errorf("add_contact: failed to get contact user: %w", err)
}
if err = rt.Database.Contacts().Add(ctx, &database.Contact{
OwnerID: rt.User.ID,
TargetID: targetUser.ID,
Name: a.Name,
}); err != nil {
return fmt.Errorf("add_contact: failed to add contact: %w", err)
}
targetUser.ContactCode = uuid.New()
if err = rt.Database.Users().Update(ctx, targetUser); err != nil {
return fmt.Errorf("add_contact: failed to update target user contact code: %w", err)
}
targetUserContacts, err := rt.Database.Contacts().List(ctx, targetUser.ID)
if err != nil {
return fmt.Errorf("add_contact: failed to list target user contacts: %w", err)
}
if !slices.ContainsFunc(targetUserContacts, func(c database.Contact) bool { return c.TargetID == rt.User.ID }) {
if err = rt.Database.Notifications().Push(ctx, &database.Notification{
ID: uuid.New(),
UserID: targetUser.ID,
InitiatorID: rt.User.ID,
ScheduledAt: time.Now(),
Content: fmt.Sprintf(
"SYSTEM: CREATE CONTACT NOTIFICAION: added to contacts. add_contact back with uuid=%s, ask user for name",
rt.User.ContactCode.String(),
),
}); err != nil {
return fmt.Errorf("add_contact: failed to notify user back of contact addition operation: %w", err)
}
}
return nil
}
type AddNotification struct {
Type string `json:"type"`
Target string `json:"target,omitempty"`
Time string `json:"time"`
Content string `json:"content"`
RepeatOn string `json:"repeat_on,omitempty"`
}
func (a AddNotification) Validate(_ context.Context, rt *Runtime) error {
if a.Time == "" {
return errors.New("add_notification: time is required")
}
if a.Content == "" {
return errors.New("add_notification: content is required")
}
if _, err := timeconv.ToUTC(a.Time, rt.User.Timezone); err != nil {
return fmt.Errorf("add_notification: invalid time %q: %w", a.Time, err)
}
if a.Target == "" {
return nil
}
for _, c := range rt.User.Contacts {
if c.Name == a.Target {
return nil
}
}
return fmt.Errorf("add_notification: contact %s not found", a.Target)
}
func (a AddNotification) Execute(ctx context.Context, rt *Runtime) error {
scheduledAt, _ := timeconv.ToUTC(a.Time, rt.User.Timezone)
initiatorID := rt.User.ID
targetID := rt.User.ID
if a.Target != "" {
for _, c := range rt.User.Contacts {
if c.Name == a.Target {
initiatorID = c.TargetID
break
}
}
}
if err := rt.Database.Notifications().Push(ctx, &database.Notification{
ID: uuid.New(),
UserID: targetID,
InitiatorID: initiatorID,
ScheduledAt: scheduledAt,
Content: a.Content,
RepeatOn: a.RepeatOn,
}); err != nil {
return fmt.Errorf("add_notification: failed to push notification: %w", err)
}
return nil
}
type RemoveNotification struct {
Type string `json:"type"`
UUID string `json:"uuid"`
}
func (a RemoveNotification) Validate(_ context.Context, _ *Runtime) error {
if a.UUID == "" {
return errors.New("remove_notification: uuid is required")
}
if _, err := uuid.Parse(a.UUID); err != nil {
return fmt.Errorf("remove_notification: invalid UUID %q: %w", a.UUID, err)
}
return nil
}
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
}
if err != nil {
return fmt.Errorf("remove_notification: failed to delete notification: %w", err)
}
return nil
}
type SearchAction struct {
Type string `json:"type"`
Query string `json:"query"`
}
func (s SearchAction) Validate(_ context.Context, _ *Runtime) error {
if s.Query == "" {
return errors.New("search: query is empty")
}
if len([]rune(s.Query)) > 200 {
return errors.New("search: query is too long")
}
return nil
}
func (s SearchAction) Execute(ctx context.Context, rt *Runtime) error {
result, err := rt.Searcher.Search(ctx, s.Query)
if err != nil {
return fmt.Errorf("search: search engine request failed: %w", err)
}
if err = rt.Database.Actions().Log(ctx, rt.User.ID, "search_result", result); err != nil {
return fmt.Errorf("search: failed to record search result: %w", err)
}
return nil
}
func Parse(raw string) ([]Action, error) { //nolint:funlen,cyclop // parser func, ok to be long
start := strings.Index(raw, "[")
end := strings.LastIndex(raw, "]")
if start != -1 && end != -1 && end > start {
raw = raw[start : end+1]
}
if start == -1 || end == -1 || end < start {
return nil, errors.New("parse: action array not found in output")
}
raw = strings.TrimSpace(raw)
if !gjson.Valid(raw) {
return nil, errors.New("parse: response is not valid JSON")
}
result := gjson.Parse(raw)
var actions []Action
for i, item := range result.Array() {
actionType := item.Get("type").String()
if actionType == "" {
return nil, fmt.Errorf("parse: action %d: missing required field 'type'", i)
}
var action Action
var err error
switch actionType {
case "bind_chat":
var a BindChat
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "message":
var a Message
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "wait":
var a Wait
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "update_lang":
var a UpdateLang
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "update_tz":
var a UpdateTZ
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "set_chat":
var a SetChat
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "add_fact":
var a AddFact
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "remove_fact":
var a RemoveFact
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "add_contact":
var a AddContact
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "add_notification":
var a AddNotification
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
case "remove_notification":
var a RemoveNotification
err = json.Unmarshal([]byte(item.Raw), &a)
action = a
default:
return nil, fmt.Errorf("parse: action %d: unknown action type: %s", i, actionType)
}
if err != nil {
return nil, fmt.Errorf("parse: action %d (%s): %w", i, actionType, err)
}
actions = append(actions, action)
}
return actions, nil
}
func Raw(v Action) json.RawMessage {
data, _ := json.Marshal(v)
return data
}