589 lines
17 KiB
Go
589 lines
17 KiB
Go
package actions
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/tidwall/gjson"
|
|
"m8sh.su/d/jules/chat"
|
|
"m8sh.su/d/jules/database"
|
|
"m8sh.su/d/jules/engine/timeconv"
|
|
"m8sh.su/d/jules/engine/user"
|
|
"m8sh.su/d/jules/search"
|
|
)
|
|
|
|
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, ¬if); 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, ¬if); 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
|
|
}
|