Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
283 lines
7.3 KiB
Go
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
|
|
} |