package automation import ( "context" "fmt" "regexp" "strings" "github.com/gochat/gochat/internal/model" ) // =========================== // Template Variable Resolution // =========================== // TemplateVariableResolver resolves {{variable}} placeholders in action params // using conversation, contact, agent, and account context. // Reference: Chatwoot liquid-style template variables — {{contact.name}}, {{conversation.status}}, etc. // Chatwoot currently does NOT implement full liquid templating; gochat adds this as an extension // per the M6 requirements doc section on CannedResponse variable interpolation. // TemplateContext holds all the data needed to resolve template variables. type TemplateContext struct { Conversation *model.Conversation Contact *model.Contact Assignee *model.User // the assigned agent (may be nil) Account *model.Account Inbox *model.Inbox Team *model.Team // the assigned team (may be nil) } // templateVarRegex matches {{variable.name}} patterns. var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\.(\w+)\}\}`) // ResolveTemplateVars replaces all {{object.attribute}} placeholders in a string // with values from the provided TemplateContext. // Unresolved variables are left as-is (not removed), matching Chatwoot behavior // where unknown variables render as empty string rather than error. func ResolveTemplateVars(input string, ctx TemplateContext) string { return templateVarRegex.ReplaceAllStringFunc(input, func(match string) string { parts := templateVarRegex.FindStringSubmatch(match) if len(parts) < 3 { return match // leave unresolved } object := parts[1] attribute := parts[2] value := resolveVariable(object, attribute, ctx) if value == "" { return "" // unresolved → empty string (Chatwoot convention) } return value }) } // ResolveActionParams resolves template variables in all string values within // an action's ActionParams map. This is the main integration point for ActionService. // Returns nil if params is nil. func ResolveActionParams(params map[string]interface{}, ctx TemplateContext) map[string]interface{} { if params == nil { return nil } resolved := make(map[string]interface{}, len(params)) for key, val := range params { switch v := val.(type) { case string: resolved[key] = ResolveTemplateVars(v, ctx) default: resolved[key] = val // non-string values pass through unchanged } } return resolved } // resolveVariable resolves a single {{object.attribute}} reference. func resolveVariable(object, attribute string, ctx TemplateContext) string { switch object { case "conversation": return resolveConversationAttr(attribute, ctx) case "contact": return resolveContactAttr(attribute, ctx) case "agent": return resolveAgentAttr(attribute, ctx) case "inbox": return resolveInboxAttr(attribute, ctx) case "team": return resolveTeamAttr(attribute, ctx) case "account": return resolveAccountAttr(attribute, ctx) default: return "" // unknown object → empty } } func resolveConversationAttr(attr string, ctx TemplateContext) string { if ctx.Conversation == nil { return "" } switch attr { case "status": return ctx.Conversation.Status case "priority": return ctx.Conversation.Priority case "channel_type": return ctx.Conversation.ChannelType case "display_id": if ctx.Conversation.DisplayID != nil { return fmt.Sprintf("%d", *ctx.Conversation.DisplayID) } return "" case "uuid": return ctx.Conversation.UUID case "id": return fmt.Sprintf("%d", ctx.Conversation.ID) default: return "" } } func resolveContactAttr(attr string, ctx TemplateContext) string { if ctx.Contact == nil { return "" } switch attr { case "name": return ctx.Contact.Name case "email": return ctx.Contact.Email case "phone_number": return ctx.Contact.PhoneNumber case "identifier": return ctx.Contact.Identifier case "country_code": return ctx.Contact.CountryCode case "location": return ctx.Contact.Location case "id": return fmt.Sprintf("%d", ctx.Contact.ID) default: return "" } } func resolveAgentAttr(attr string, ctx TemplateContext) string { if ctx.Assignee == nil { return "" } switch attr { case "name": return ctx.Assignee.Name case "email": return ctx.Assignee.Email case "id": return fmt.Sprintf("%d", ctx.Assignee.ID) case "role": return ctx.Assignee.Role default: return "" } } func resolveInboxAttr(attr string, ctx TemplateContext) string { if ctx.Inbox == nil { return "" } switch attr { case "name": return ctx.Inbox.Name case "channel_type": return ctx.Inbox.ChannelType case "id": return fmt.Sprintf("%d", ctx.Inbox.ID) default: return "" } } func resolveTeamAttr(attr string, ctx TemplateContext) string { if ctx.Team == nil { return "" } switch attr { case "name": return ctx.Team.Name case "id": return fmt.Sprintf("%d", ctx.Team.ID) default: return "" } } func resolveAccountAttr(attr string, ctx TemplateContext) string { if ctx.Account == nil { return "" } switch attr { case "name": return ctx.Account.Name case "id": return fmt.Sprintf("%d", ctx.Account.ID) default: return "" } } // =========================== // Template Context Builder // =========================== // TemplateContextBuilder builds a TemplateContext by loading related entities // from the database based on a conversation ID. type TemplateContextBuilder struct { db DBProvider } // NewTemplateContextBuilder creates a new TemplateContextBuilder. func NewTemplateContextBuilder(db DBProvider) *TemplateContextBuilder { return &TemplateContextBuilder{db: db} } // Build loads all related entities for a conversation and returns a TemplateContext. func (b *TemplateContextBuilder) Build(ctx context.Context, conversationID uint) (TemplateContext, error) { db := b.db.DB().WithContext(ctx) // Load conversation var conv model.Conversation if err := db.First(&conv, conversationID).Error; err != nil { return TemplateContext{}, fmt.Errorf("failed to load conversation %d: %w", conversationID, err) } result := TemplateContext{ Conversation: &conv, } // Load contact var contact model.Contact if err := db.First(&contact, conv.ContactID).Error; err != nil { // Contact not found is non-fatal; leave nil if !strings.Contains(err.Error(), "record not found") { return TemplateContext{}, fmt.Errorf("failed to load contact %d: %w", conv.ContactID, err) } } else { result.Contact = &contact } // Load assignee (agent) if conv.AssigneeID != nil && *conv.AssigneeID != 0 { var agent model.User if err := db.First(&agent, *conv.AssigneeID).Error; err != nil { // Assignee not found is non-fatal; leave nil } else { result.Assignee = &agent } } // Load inbox var inbox model.Inbox if err := db.First(&inbox, conv.InboxID).Error; err != nil { // Inbox not found is non-fatal; leave nil } else { result.Inbox = &inbox } // Load account var account model.Account if err := db.First(&account, conv.AccountID).Error; err != nil { // Account not found is non-fatal; leave nil } else { result.Account = &account } // Load team if conv.TeamID != nil && *conv.TeamID != 0 { var team model.Team if err := db.First(&team, *conv.TeamID).Error; err != nil { // Team not found is non-fatal; leave nil } else { result.Team = &team } } return result, nil }