Files
gochat/internal/automation/template_variable.go
T
2026-06-04 15:44:48 +08:00

283 lines
7.3 KiB
Go

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
}