Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user