Enhance messenger service with template support for message sending

- Introduced template loading functionality during messenger initialization, allowing for dynamic message templates.
- Added SendT, SendTWithProvider, SendTBatch, and SendTBatchMixed methods to the Service for sending messages using templates.
- Updated provider implementations for Mailgun, Twilio, and Mailer to support template-based message sending.
- Enhanced the Provider interface to include optional template methods, improving flexibility and usability across providers.
This commit is contained in:
Max 2025-10-08 09:21:57 +08:00
parent d113adaa8a
commit ec8b6c336b
19 changed files with 2190 additions and 21 deletions

View file

@ -18,6 +18,7 @@ import (
"github.com/yaoapp/yao/messenger/providers/mailer"
"github.com/yaoapp/yao/messenger/providers/mailgun"
"github.com/yaoapp/yao/messenger/providers/twilio"
"github.com/yaoapp/yao/messenger/template"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/share"
)
@ -73,6 +74,13 @@ func Load(cfg config.Config) error {
return err
}
// Load templates
err = template.LoadTemplates()
if err != nil {
log.Warn("[Messenger] Failed to load templates: %v", err)
// Don't fail messenger loading if templates fail
}
// Create messenger configuration
config := &types.Config{
Providers: []types.ProviderConfig{},
@ -212,7 +220,7 @@ func createProvider(config types.ProviderConfig) (types.Provider, error) {
case "twilio":
return createTwilioProvider(config)
case "mailgun":
return mailgun.NewMailgunProvider(config)
return createMailgunProvider(config)
default:
return nil, fmt.Errorf("unsupported connector: %s", connector)
}
@ -220,7 +228,12 @@ func createProvider(config types.ProviderConfig) (types.Provider, error) {
// createTwilioProvider creates a unified Twilio provider that handles all message types
func createTwilioProvider(config types.ProviderConfig) (types.Provider, error) {
return twilio.NewTwilioProvider(config)
return twilio.NewTwilioProviderWithTemplateManager(config, template.Global)
}
// createMailgunProvider creates a Mailgun provider with template manager
func createMailgunProvider(config types.ProviderConfig) (types.Provider, error) {
return mailgun.NewMailgunProviderWithTemplateManager(config, template.Global)
}
// Send sends a message using the specified channel or default provider
@ -286,6 +299,81 @@ func (m *Service) SendWithProvider(ctx context.Context, providerName string, mes
return fmt.Errorf("failed to send message after %d attempts: %w", maxAttempts, lastErr)
}
// SendT sends a message using a template
func (m *Service) SendT(ctx context.Context, channel string, templateID string, data types.TemplateData) error {
// Get providers for the channel
providers := m.GetProviders(channel)
if len(providers) == 0 {
return fmt.Errorf("no providers available for channel: %s", channel)
}
// Use the first available provider's SendT method
provider := providers[0]
return provider.SendT(ctx, templateID, data)
}
// SendTWithProvider sends a message using a template and specific provider
func (m *Service) SendTWithProvider(ctx context.Context, providerName string, templateID string, data types.TemplateData) error {
// Get provider
provider, exists := m.providers[providerName]
if !exists {
return fmt.Errorf("provider not found: %s", providerName)
}
// Use the provider's SendT method directly
return provider.SendT(ctx, templateID, data)
}
// SendTBatch sends multiple messages using templates in batch
func (m *Service) SendTBatch(ctx context.Context, channel string, templateID string, dataList []types.TemplateData) error {
// Get providers for the channel
providers := m.GetProviders(channel)
if len(providers) == 0 {
return fmt.Errorf("no providers available for channel: %s", channel)
}
// Use the first available provider's SendTBatch method
provider := providers[0]
return provider.SendTBatch(ctx, templateID, dataList)
}
// SendTBatchWithProvider sends multiple messages using templates and specific provider in batch
func (m *Service) SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []types.TemplateData) error {
// Get provider
provider, exists := m.providers[providerName]
if !exists {
return fmt.Errorf("provider not found: %s", providerName)
}
// Use the provider's SendTBatch method directly
return provider.SendTBatch(ctx, templateID, dataList)
}
// SendTBatchMixed sends multiple messages using different templates with different data
func (m *Service) SendTBatchMixed(ctx context.Context, channel string, templateRequests []types.TemplateRequest) error {
// Get providers for the channel
providers := m.GetProviders(channel)
if len(providers) == 0 {
return fmt.Errorf("no providers available for channel: %s", channel)
}
// Use the first available provider's SendTBatchMixed method
provider := providers[0]
return provider.SendTBatchMixed(ctx, templateRequests)
}
// SendTBatchMixedWithProvider sends multiple messages using different templates with different data and specific provider
func (m *Service) SendTBatchMixedWithProvider(ctx context.Context, providerName string, templateRequests []types.TemplateRequest) error {
// Get provider
provider, exists := m.providers[providerName]
if !exists {
return fmt.Errorf("provider not found: %s", providerName)
}
// Use the provider's SendTBatchMixed method directly
return provider.SendTBatchMixed(ctx, templateRequests)
}
// SendBatch sends multiple messages in batch
func (m *Service) SendBatch(ctx context.Context, channel string, messages []*types.Message) error {
if len(messages) == 0 {

View file

@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/yaoapp/yao/messenger/template"
"github.com/yaoapp/yao/messenger/types"
)
@ -32,13 +33,22 @@ type Provider struct {
imapPassword string
imapUseSSL bool
imapMailbox string
// Template manager for template support
templateManager types.TemplateManager
}
// NewMailerProvider creates a new Mailer provider
func NewMailerProvider(config types.ProviderConfig) (*Provider, error) {
return NewMailerProviderWithTemplateManager(config, template.Global)
}
// NewMailerProviderWithTemplateManager creates a new Mailer provider with template manager
func NewMailerProviderWithTemplateManager(config types.ProviderConfig, templateManager types.TemplateManager) (*Provider, error) {
provider := &Provider{
config: config,
useTLS: true, // Default to TLS
config: config,
useTLS: true, // Default to TLS
templateManager: templateManager,
}
// Extract options
@ -199,6 +209,77 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err
return nil
}
// SendT sends a message using a template
func (p *Provider) SendT(ctx context.Context, templateID string, data types.TemplateData) error {
// Get template from provider's template manager (mailer supports mail templates)
template, err := p.getTemplate(templateID, types.TemplateTypeMail)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
// Convert template to message
message, err := template.ToMessage(data)
if err != nil {
return fmt.Errorf("failed to convert template to message: %w", err)
}
// Send message using existing Send method
return p.Send(ctx, message)
}
// SendTBatch sends multiple messages using templates in batch
func (p *Provider) SendTBatch(ctx context.Context, templateID string, dataList []types.TemplateData) error {
// Get template from provider's template manager (mailer supports mail templates)
template, err := p.getTemplate(templateID, types.TemplateTypeMail)
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
// Convert templates to messages
messages := make([]*types.Message, 0, len(dataList))
for _, data := range dataList {
message, err := template.ToMessage(data)
if err != nil {
return fmt.Errorf("failed to convert template to message: %w", err)
}
messages = append(messages, message)
}
// Send messages using existing SendBatch method
return p.SendBatch(ctx, messages)
}
// SendTBatchMixed sends multiple messages using different templates with different data
func (p *Provider) SendTBatchMixed(ctx context.Context, templateRequests []types.TemplateRequest) error {
// Convert template requests to messages
messages := make([]*types.Message, 0, len(templateRequests))
for _, req := range templateRequests {
// Get template from provider's template manager (mailer supports mail templates)
template, err := p.getTemplate(req.TemplateID, types.TemplateTypeMail)
if err != nil {
return fmt.Errorf("template not found: %s, %w", req.TemplateID, err)
}
// Convert template to message
message, err := template.ToMessage(req.Data)
if err != nil {
return fmt.Errorf("failed to convert template %s to message: %w", req.TemplateID, err)
}
messages = append(messages, message)
}
// Send messages using existing SendBatch method
return p.SendBatch(ctx, messages)
}
// getTemplate gets a template by ID and type from the provider's template manager
func (p *Provider) getTemplate(templateID string, templateType types.TemplateType) (*types.Template, error) {
if p.templateManager == nil {
return nil, fmt.Errorf("template manager not available")
}
return p.templateManager.GetTemplate(templateID, templateType)
}
// GetType returns the provider type
func (p *Provider) GetType() string {
return "mailer"

View file

@ -0,0 +1,200 @@
package mailer
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
func TestSendTBatch_Success(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider with template manager
config := types.ProviderConfig{
Name: "test-mailer",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "localhost",
"port": 587,
"username": "test@example.com",
"password": "password",
"from": "test@example.com",
},
},
}
provider, err := NewMailerProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data for batch sending
dataList := []types.TemplateData{
{
"to": []string{"user1@example.com"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
{
"to": []string{"user2@example.com"},
"team_name": "Team B",
"inviter_name": "Bob",
"invite_link": "https://example.com/invite/2",
},
}
// Test SendTBatch - should fail because template manager is nil
err = provider.SendTBatch(context.Background(), "en.invite_member", dataList)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
func TestSendTBatch_ContextTimeout(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider
config := types.ProviderConfig{
Name: "test-mailer",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "localhost",
"port": 587,
"username": "test@example.com",
"password": "password",
"from": "test@example.com",
},
},
}
provider, err := NewMailerProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data
dataList := []types.TemplateData{
{
"to": []string{"user1@example.com"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Wait for context to timeout
time.Sleep(2 * time.Nanosecond)
// Test SendTBatch with expired context
err = provider.SendTBatch(ctx, "en.invite_member", dataList)
assert.Error(t, err)
// Error could be either "template manager not available" or "context deadline exceeded"
t.Logf("Error: %v", err)
}
func TestSendTBatchMixed_Success(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider with template manager
config := types.ProviderConfig{
Name: "test-mailer",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "localhost",
"port": 587,
"username": "test@example.com",
"password": "password",
"from": "test@example.com",
},
},
}
provider, err := NewMailerProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data for mixed batch sending
templateRequests := []types.TemplateRequest{
{
TemplateID: "en.invite_member",
Data: types.TemplateData{
"to": []string{"user1@example.com"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
},
{
TemplateID: "en.welcome",
Data: types.TemplateData{
"to": []string{"user2@example.com"},
"user_name": "Bob",
"company": "Example Corp",
},
},
}
// Test SendTBatchMixed - should fail because template manager is nil
err = provider.SendTBatchMixed(context.Background(), templateRequests)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
func TestSendTBatchMixed_ContextTimeout(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider
config := types.ProviderConfig{
Name: "test-mailer",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "localhost",
"port": 587,
"username": "test@example.com",
"password": "password",
"from": "test@example.com",
},
},
}
provider, err := NewMailerProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data
templateRequests := []types.TemplateRequest{
{
TemplateID: "en.invite_member",
Data: types.TemplateData{
"to": []string{"user1@example.com"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
},
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Wait for context to timeout
time.Sleep(2 * time.Nanosecond)
// Test SendTBatchMixed with expired context
err = provider.SendTBatchMixed(ctx, templateRequests)
assert.Error(t, err)
// Error could be either "template manager not available" or "context deadline exceeded"
t.Logf("Error: %v", err)
}

View file

@ -0,0 +1,178 @@
package mailer
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
// Test SendT method
func TestSendT_TemplateNotImplemented(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Create a simple provider config for testing
config := types.ProviderConfig{
Name: "test-mailer",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "smtp.example.com",
"port": 587,
"username": "test@example.com",
"password": "testpass",
"from": "test@example.com",
"use_tls": true,
},
},
}
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
templateData := types.TemplateData{
"to": []string{"test@example.com"},
"team_name": "Test Team",
"inviter_name": "John Doe",
"invite_link": "https://example.com/invite/123",
}
// Test that SendT returns "template not found" error (template system is working)
err = provider.SendT(ctx, "en.invite_member.mail", templateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template not found")
}
func TestSendT_ContextTimeout(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Create a simple provider config for testing
config := types.ProviderConfig{
Name: "test-mailer",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "smtp.example.com",
"port": 587,
"username": "test@example.com",
"password": "testpass",
"from": "test@example.com",
"use_tls": true,
},
},
}
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Create a very short timeout context to test timeout functionality
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
templateData := types.TemplateData{
"to": []string{"test@example.com"},
"team_name": "Test Team",
"inviter_name": "John Doe",
"invite_link": "https://example.com/invite/123",
}
err = provider.SendT(ctx, "en.invite_member.mail", templateData)
assert.Error(t, err)
// Verify it's a context timeout error or not implemented error
if strings.Contains(err.Error(), "context deadline exceeded") {
t.Log("Context timeout working correctly with template API")
} else if strings.Contains(err.Error(), "context canceled") {
t.Log("Context cancellation working correctly with template API")
} else if strings.Contains(err.Error(), "template not found") {
t.Log("Template not found error as expected")
} else {
t.Logf("Got different error: %v", err)
}
}
// Test template system integration
func TestTemplateSystem_LoadTemplates(t *testing.T) {
// This test verifies that the template system can be loaded
// We'll test the template loading logic
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Test that we can create a template data structure
templateData := types.TemplateData{
"team_name": "Awesome Team",
"inviter_name": "Alice Johnson",
"invite_link": "https://example.com/invite/abc123",
"to": []string{"test@example.com"},
}
// Verify template data structure
assert.NotNil(t, templateData)
assert.Equal(t, "Awesome Team", templateData["team_name"])
assert.Equal(t, "Alice Johnson", templateData["inviter_name"])
assert.Equal(t, "https://example.com/invite/abc123", templateData["invite_link"])
// Verify recipients
recipients, ok := templateData["to"].([]string)
assert.True(t, ok)
assert.Len(t, recipients, 1)
assert.Equal(t, "test@example.com", recipients[0])
}
// Benchmark Tests
func BenchmarkSendT(b *testing.B) {
// Setup
t := &testing.T{}
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
config := types.ProviderConfig{
Name: "test-mailer",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "smtp.example.com",
"port": 587,
"username": "test@example.com",
"password": "testpass",
"from": "test@example.com",
"use_tls": true,
},
},
}
provider, err := NewMailerProvider(config)
if err != nil {
b.Fatal(err)
}
ctx := context.Background()
templateData := types.TemplateData{
"to": []string{"test@example.com"},
"team_name": "Test Team",
"inviter_name": "John Doe",
"invite_link": "https://example.com/invite/123",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// This will return "not implemented" error, but we're measuring the overhead
_ = provider.SendT(ctx, "en.invite_member.mail", templateData)
}
}

View file

@ -14,18 +14,25 @@ import (
// Provider implements the Provider interface for Mailgun email sending
type Provider struct {
config types.ProviderConfig
domain string
apiKey string
from string
baseURL string
httpClient *http.Client
config types.ProviderConfig
domain string
apiKey string
from string
baseURL string
httpClient *http.Client
templateManager types.TemplateManager
}
// NewMailgunProvider creates a new Mailgun provider
func NewMailgunProvider(config types.ProviderConfig) (*Provider, error) {
return NewMailgunProviderWithTemplateManager(config, nil)
}
// NewMailgunProviderWithTemplateManager creates a new Mailgun provider with template manager
func NewMailgunProviderWithTemplateManager(config types.ProviderConfig, templateManager types.TemplateManager) (*Provider, error) {
provider := &Provider{
config: config,
config: config,
templateManager: templateManager,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
@ -86,6 +93,77 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err
return nil
}
// SendT sends a message using a template
func (p *Provider) SendT(ctx context.Context, templateID string, data types.TemplateData) error {
// Get template from provider's template manager
template, err := p.getTemplate(templateID, types.TemplateTypeMail) // Mailgun supports email
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
// Convert template to message
message, err := template.ToMessage(data)
if err != nil {
return fmt.Errorf("failed to convert template to message: %w", err)
}
// Send message using existing Send method
return p.Send(ctx, message)
}
// SendTBatch sends multiple messages using templates in batch
func (p *Provider) SendTBatch(ctx context.Context, templateID string, dataList []types.TemplateData) error {
// Get template from provider's template manager
template, err := p.getTemplate(templateID, types.TemplateTypeMail) // Mailgun supports email
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
// Convert templates to messages
messages := make([]*types.Message, 0, len(dataList))
for _, data := range dataList {
message, err := template.ToMessage(data)
if err != nil {
return fmt.Errorf("failed to convert template to message: %w", err)
}
messages = append(messages, message)
}
// Send messages using existing SendBatch method
return p.SendBatch(ctx, messages)
}
// SendTBatchMixed sends multiple messages using different templates with different data
func (p *Provider) SendTBatchMixed(ctx context.Context, templateRequests []types.TemplateRequest) error {
// Convert template requests to messages
messages := make([]*types.Message, 0, len(templateRequests))
for _, req := range templateRequests {
// Get template from provider's template manager
template, err := p.getTemplate(req.TemplateID, types.TemplateTypeMail) // Mailgun supports email
if err != nil {
return fmt.Errorf("template not found: %s, %w", req.TemplateID, err)
}
// Convert template to message
message, err := template.ToMessage(req.Data)
if err != nil {
return fmt.Errorf("failed to convert template %s to message: %w", req.TemplateID, err)
}
messages = append(messages, message)
}
// Send messages using existing SendBatch method
return p.SendBatch(ctx, messages)
}
// getTemplate gets a template by ID and type from the provider's template manager
func (p *Provider) getTemplate(templateID string, templateType types.TemplateType) (*types.Template, error) {
if p.templateManager == nil {
return nil, fmt.Errorf("template manager not available")
}
return p.templateManager.GetTemplate(templateID, templateType)
}
// GetType returns the provider type
func (p *Provider) GetType() string {
return "mailgun"

View file

@ -0,0 +1,184 @@
package mailgun
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
func TestSendTBatch_Success(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider with template manager
config := types.ProviderConfig{
Name: "test-mailgun",
Connector: "mailgun",
Options: map[string]interface{}{
"domain": "test.example.com",
"api_key": "test_api_key",
"from": "test@example.com",
},
}
provider, err := NewMailgunProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data for batch sending
dataList := []types.TemplateData{
{
"to": []string{"user1@example.com"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
{
"to": []string{"user2@example.com"},
"team_name": "Team B",
"inviter_name": "Bob",
"invite_link": "https://example.com/invite/2",
},
}
// Test SendTBatch - should fail because template manager is nil
err = provider.SendTBatch(context.Background(), "en.invite_member", dataList)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
func TestSendTBatch_ContextTimeout(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider
config := types.ProviderConfig{
Name: "test-mailgun",
Connector: "mailgun",
Options: map[string]interface{}{
"domain": "test.example.com",
"api_key": "test_api_key",
"from": "test@example.com",
},
}
provider, err := NewMailgunProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data
dataList := []types.TemplateData{
{
"to": []string{"user1@example.com"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Wait for context to timeout
time.Sleep(2 * time.Nanosecond)
// Test SendTBatch with expired context
err = provider.SendTBatch(ctx, "en.invite_member", dataList)
assert.Error(t, err)
// Error could be either "template manager not available" or "context deadline exceeded"
t.Logf("Error: %v", err)
}
func TestSendTBatchMixed_Success(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider with template manager
config := types.ProviderConfig{
Name: "test-mailgun",
Connector: "mailgun",
Options: map[string]interface{}{
"domain": "test.example.com",
"api_key": "test_api_key",
"from": "test@example.com",
},
}
provider, err := NewMailgunProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data for mixed batch sending
templateRequests := []types.TemplateRequest{
{
TemplateID: "en.invite_member",
Data: types.TemplateData{
"to": []string{"user1@example.com"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
},
{
TemplateID: "en.welcome",
Data: types.TemplateData{
"to": []string{"user2@example.com"},
"user_name": "Bob",
"company": "Example Corp",
},
},
}
// Test SendTBatchMixed - should fail because template manager is nil
err = provider.SendTBatchMixed(context.Background(), templateRequests)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
func TestSendTBatchMixed_ContextTimeout(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider
config := types.ProviderConfig{
Name: "test-mailgun",
Connector: "mailgun",
Options: map[string]interface{}{
"domain": "test.example.com",
"api_key": "test_api_key",
"from": "test@example.com",
},
}
provider, err := NewMailgunProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data
templateRequests := []types.TemplateRequest{
{
TemplateID: "en.invite_member",
Data: types.TemplateData{
"to": []string{"user1@example.com"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
},
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Wait for context to timeout
time.Sleep(2 * time.Nanosecond)
// Test SendTBatchMixed with expired context
err = provider.SendTBatchMixed(ctx, templateRequests)
assert.Error(t, err)
// Error could be either "template manager not available" or "context deadline exceeded"
t.Logf("Error: %v", err)
}

View file

@ -0,0 +1,96 @@
package mailgun
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
func TestSendT_TemplateNotImplemented(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Create provider with minimal config
config := types.ProviderConfig{
Name: "test-mailgun",
Connector: "mailgun",
Options: map[string]interface{}{
"api_key": "test_api_key",
"domain": "test.example.com",
"from": "test@example.com",
},
}
provider, err := NewMailgunProvider(config)
require.NoError(t, err)
ctx := context.Background()
templateData := types.TemplateData{
"to": []string{"test@example.com"},
"team_name": "Test Team",
"inviter_name": "John Doe",
"invite_link": "https://example.com/invite/123",
}
// Test that SendT returns "template manager not available" error
err = provider.SendT(ctx, "en.invite_member", templateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
func TestSendT_ContextTimeout(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Create provider with minimal config
config := types.ProviderConfig{
Name: "test-mailgun",
Connector: "mailgun",
Options: map[string]interface{}{
"api_key": "test_api_key",
"domain": "test.example.com",
"from": "test@example.com",
},
}
provider, err := NewMailgunProvider(config)
require.NoError(t, err)
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
// Wait for timeout
time.Sleep(2 * time.Millisecond)
templateData := types.TemplateData{
"to": []string{"test@example.com"},
"team_name": "Test Team",
"inviter_name": "John Doe",
"invite_link": "https://example.com/invite/123",
}
// Test that SendT handles context timeout
err = provider.SendT(ctx, "en.invite_member", templateData)
assert.Error(t, err)
// Verify it's a context timeout error or template manager error
if strings.Contains(err.Error(), "context deadline exceeded") {
t.Log("Context timeout working correctly with template API")
} else if strings.Contains(err.Error(), "context canceled") {
t.Log("Context cancellation working correctly with template API")
} else if strings.Contains(err.Error(), "template manager not available") {
t.Log("Template manager not available error as expected")
} else {
t.Logf("Got different error: %v", err)
}
}

View file

@ -28,12 +28,19 @@ type Provider struct {
sendGridAPIKey string
httpClient *http.Client
baseURL string
templateManager types.TemplateManager
}
// NewTwilioProvider creates a new unified Twilio provider
func NewTwilioProvider(config types.ProviderConfig) (*Provider, error) {
return NewTwilioProviderWithTemplateManager(config, nil)
}
// NewTwilioProviderWithTemplateManager creates a new Twilio provider with template manager
func NewTwilioProviderWithTemplateManager(config types.ProviderConfig, templateManager types.TemplateManager) (*Provider, error) {
provider := &Provider{
config: config,
config: config,
templateManager: templateManager,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
@ -118,6 +125,77 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err
return nil
}
// SendT sends a message using a template
func (p *Provider) SendT(ctx context.Context, templateID string, data types.TemplateData) error {
// Get template from provider's template manager
template, err := p.getTemplate(templateID, types.TemplateTypeSMS) // Twilio primarily supports SMS
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
// Convert template to message
message, err := template.ToMessage(data)
if err != nil {
return fmt.Errorf("failed to convert template to message: %w", err)
}
// Send message using existing Send method
return p.Send(ctx, message)
}
// SendTBatch sends multiple messages using templates in batch
func (p *Provider) SendTBatch(ctx context.Context, templateID string, dataList []types.TemplateData) error {
// Get template from provider's template manager
template, err := p.getTemplate(templateID, types.TemplateTypeSMS) // Twilio primarily supports SMS
if err != nil {
return fmt.Errorf("template not found: %w", err)
}
// Convert templates to messages
messages := make([]*types.Message, 0, len(dataList))
for _, data := range dataList {
message, err := template.ToMessage(data)
if err != nil {
return fmt.Errorf("failed to convert template to message: %w", err)
}
messages = append(messages, message)
}
// Send messages using existing SendBatch method
return p.SendBatch(ctx, messages)
}
// SendTBatchMixed sends multiple messages using different templates with different data
func (p *Provider) SendTBatchMixed(ctx context.Context, templateRequests []types.TemplateRequest) error {
// Convert template requests to messages
messages := make([]*types.Message, 0, len(templateRequests))
for _, req := range templateRequests {
// Get template from provider's template manager
template, err := p.getTemplate(req.TemplateID, types.TemplateTypeSMS) // Twilio primarily supports SMS
if err != nil {
return fmt.Errorf("template not found: %s, %w", req.TemplateID, err)
}
// Convert template to message
message, err := template.ToMessage(req.Data)
if err != nil {
return fmt.Errorf("failed to convert template %s to message: %w", req.TemplateID, err)
}
messages = append(messages, message)
}
// Send messages using existing SendBatch method
return p.SendBatch(ctx, messages)
}
// getTemplate gets a template by ID and type from the provider's template manager
func (p *Provider) getTemplate(templateID string, templateType types.TemplateType) (*types.Template, error) {
if p.templateManager == nil {
return nil, fmt.Errorf("template manager not available")
}
return p.templateManager.GetTemplate(templateID, templateType)
}
// GetType returns the provider type
func (p *Provider) GetType() string {
return "twilio"

View file

@ -0,0 +1,184 @@
package twilio
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
func TestSendTBatch_Success(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider with template manager
config := types.ProviderConfig{
Name: "test-twilio",
Connector: "twilio",
Options: map[string]interface{}{
"account_sid": "test_account_sid",
"auth_token": "test_auth_token",
"from": "+1234567890",
},
}
provider, err := NewTwilioProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data for batch sending
dataList := []types.TemplateData{
{
"to": []string{"+1234567890"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
{
"to": []string{"+0987654321"},
"team_name": "Team B",
"inviter_name": "Bob",
"invite_link": "https://example.com/invite/2",
},
}
// Test SendTBatch - should fail because template manager is nil
err = provider.SendTBatch(context.Background(), "en.invite_member", dataList)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
func TestSendTBatch_ContextTimeout(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider
config := types.ProviderConfig{
Name: "test-twilio",
Connector: "twilio",
Options: map[string]interface{}{
"account_sid": "test_account_sid",
"auth_token": "test_auth_token",
"from": "+1234567890",
},
}
provider, err := NewTwilioProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data
dataList := []types.TemplateData{
{
"to": []string{"+1234567890"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Wait for context to timeout
time.Sleep(2 * time.Nanosecond)
// Test SendTBatch with expired context
err = provider.SendTBatch(ctx, "en.invite_member", dataList)
assert.Error(t, err)
// Error could be either "template manager not available" or "context deadline exceeded"
t.Logf("Error: %v", err)
}
func TestSendTBatchMixed_Success(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider with template manager
config := types.ProviderConfig{
Name: "test-twilio",
Connector: "twilio",
Options: map[string]interface{}{
"account_sid": "test_account_sid",
"auth_token": "test_auth_token",
"from": "+1234567890",
},
}
provider, err := NewTwilioProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data for mixed batch sending
templateRequests := []types.TemplateRequest{
{
TemplateID: "en.invite_member",
Data: types.TemplateData{
"to": []string{"+1234567890"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
},
{
TemplateID: "en.welcome",
Data: types.TemplateData{
"to": []string{"+0987654321"},
"user_name": "Bob",
"company": "Example Corp",
},
},
}
// Test SendTBatchMixed - should fail because template manager is nil
err = provider.SendTBatchMixed(context.Background(), templateRequests)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
func TestSendTBatchMixed_ContextTimeout(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
// Create provider
config := types.ProviderConfig{
Name: "test-twilio",
Connector: "twilio",
Options: map[string]interface{}{
"account_sid": "test_account_sid",
"auth_token": "test_auth_token",
"from": "+1234567890",
},
}
provider, err := NewTwilioProviderWithTemplateManager(config, nil)
assert.NoError(t, err)
// Test data
templateRequests := []types.TemplateRequest{
{
TemplateID: "en.invite_member",
Data: types.TemplateData{
"to": []string{"+1234567890"},
"team_name": "Team A",
"inviter_name": "Alice",
"invite_link": "https://example.com/invite/1",
},
},
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Wait for context to timeout
time.Sleep(2 * time.Nanosecond)
// Test SendTBatchMixed with expired context
err = provider.SendTBatchMixed(ctx, templateRequests)
assert.Error(t, err)
// Error could be either "template manager not available" or "context deadline exceeded"
t.Logf("Error: %v", err)
}

View file

@ -0,0 +1,94 @@
package twilio
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
func TestSendT_TemplateNotImplemented(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Create provider with minimal config
config := types.ProviderConfig{
Name: "test-twilio",
Connector: "twilio",
Options: map[string]interface{}{
"account_sid": "test_account_sid",
"auth_token": "test_auth_token",
},
}
provider, err := NewTwilioProvider(config)
require.NoError(t, err)
ctx := context.Background()
templateData := types.TemplateData{
"to": []string{"+1234567890"},
"team_name": "Test Team",
"inviter_name": "John Doe",
"invite_link": "https://example.com/invite/123",
}
// Test that SendT returns "template manager not available" error
err = provider.SendT(ctx, "en.invite_member", templateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "template manager not available")
}
func TestSendT_ContextTimeout(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Create provider with minimal config
config := types.ProviderConfig{
Name: "test-twilio",
Connector: "twilio",
Options: map[string]interface{}{
"account_sid": "test_account_sid",
"auth_token": "test_auth_token",
},
}
provider, err := NewTwilioProvider(config)
require.NoError(t, err)
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
// Wait for timeout
time.Sleep(2 * time.Millisecond)
templateData := types.TemplateData{
"to": []string{"+1234567890"},
"team_name": "Test Team",
"inviter_name": "John Doe",
"invite_link": "https://example.com/invite/123",
}
// Test that SendT handles context timeout
err = provider.SendT(ctx, "en.invite_member", templateData)
assert.Error(t, err)
// Verify it's a context timeout error or template manager error
if strings.Contains(err.Error(), "context deadline exceeded") {
t.Log("Context timeout working correctly with template API")
} else if strings.Contains(err.Error(), "context canceled") {
t.Log("Context cancellation working correctly with template API")
} else if strings.Contains(err.Error(), "template manager not available") {
t.Log("Template manager not available error as expected")
} else {
t.Logf("Got different error: %v", err)
}
}

View file

@ -0,0 +1,47 @@
package template
import (
"testing"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
func TestDebugTemplateLoading(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Test direct template loading
t.Log("Testing direct template loading...")
// Try to load a specific template file using application.App
content, err := application.App.Read("messengers/templates/en/invite_member.mail.html")
if err != nil {
t.Logf("Could not read template file: %v", err)
} else {
t.Logf("Template file content length: %d", len(content))
t.Logf("First 200 chars: %s", content[:min(200, len(content))])
}
// Test template parsing
subject, body, html, err := parseTemplateContent(string(content), types.TemplateTypeMail)
if err != nil {
t.Logf("Could not parse template: %v", err)
} else {
t.Logf("Parsed template content:")
t.Logf("Subject: %s", subject)
t.Logf("Body length: %d", len(body))
t.Logf("HTML length: %d", len(html))
t.Logf("Body content: %s", body)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,36 @@
package template
import (
"testing"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/share"
"github.com/yaoapp/yao/test"
)
func TestLoadTemplate(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Test loading a specific template
file := "messengers/templates/en/invite_member.mail.html"
templateID := share.ID("messengers/templates", file)
t.Logf("Testing loadTemplate with file: %s, templateID: %s", file, templateID)
template, err := loadTemplate(file, templateID)
if err != nil {
t.Fatalf("Failed to load template: %v", err)
}
if template == nil {
t.Fatal("Template is nil")
}
t.Logf("Loaded template: ID=%s, Type=%s, Language=%s", template.ID, template.Type, template.Language)
t.Logf("Subject: %s", template.Subject)
t.Logf("Body length: %d", len(template.Body))
t.Logf("HTML length: %d", len(template.HTML))
t.Logf("Body content: %s", template.Body)
}

View file

@ -0,0 +1,154 @@
package template
import (
"testing"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
func TestTemplateRender(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load templates
err := LoadTemplates()
if err != nil {
t.Fatalf("Failed to load templates: %v", err)
}
// Get template
template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail)
if err != nil {
t.Fatalf("Failed to get template: %v", err)
}
// Test data
data := types.TemplateData{
"to": []string{"test@example.com"},
"team_name": "Awesome Team",
"inviter_name": "Alice Johnson",
"invite_link": "https://example.com/invite/abc123",
}
// Test rendering
subject, body, html, err := template.Render(data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
// Verify rendered content
t.Logf("Rendered subject: %s", subject)
t.Logf("Rendered body length: %d", len(body))
t.Logf("Rendered HTML length: %d", len(html))
// Check that variables were replaced
if !contains(subject, "Awesome Team") {
t.Errorf("Subject should contain 'Awesome Team', got: %s", subject)
}
if !contains(body, "Alice Johnson") {
t.Errorf("Body should contain 'Alice Johnson', got: %s", body)
}
if !contains(body, "https://example.com/invite/abc123") {
t.Errorf("Body should contain invite link, got: %s", body)
}
}
func TestTemplateToMessage(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load templates
err := LoadTemplates()
if err != nil {
t.Fatalf("Failed to load templates: %v", err)
}
// Get template
template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail)
if err != nil {
t.Fatalf("Failed to get template: %v", err)
}
// Test data
data := types.TemplateData{
"to": []string{"test@example.com", "user@example.com"},
"team_name": "Awesome Team",
"inviter_name": "Alice Johnson",
"invite_link": "https://example.com/invite/abc123",
}
// Convert template to message
message, err := template.ToMessage(data)
if err != nil {
t.Fatalf("Failed to convert template to message: %v", err)
}
// Verify message properties
if message.Type != types.MessageType("mail") {
t.Errorf("Expected message type 'mail', got %s", message.Type)
}
if len(message.To) != 2 {
t.Errorf("Expected 2 recipients, got %d", len(message.To))
}
if !contains(message.Subject, "Awesome Team") {
t.Errorf("Subject should contain 'Awesome Team', got: %s", message.Subject)
}
if !contains(message.Body, "Alice Johnson") {
t.Errorf("Body should contain 'Alice Johnson', got: %s", message.Body)
}
t.Logf("Generated message: Subject=%s, To=%v", message.Subject, message.To)
}
func TestNestedTemplateRender(t *testing.T) {
// Test nested object access
template := &types.Template{
Subject: "Hello {{ user.name }}, welcome to {{ team.name }}!",
Body: "Your role is {{ user.role }} in {{ team.department.name }}.",
}
data := types.TemplateData{
"user": map[string]interface{}{
"name": "John Doe",
"role": "Developer",
},
"team": map[string]interface{}{
"name": "Awesome Team",
"department": map[string]interface{}{
"name": "Engineering",
},
},
}
subject, body, _, err := template.Render(data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
// Verify nested access works
if !contains(subject, "John Doe") {
t.Errorf("Subject should contain 'John Doe', got: %s", subject)
}
if !contains(subject, "Awesome Team") {
t.Errorf("Subject should contain 'Awesome Team', got: %s", subject)
}
if !contains(body, "Developer") {
t.Errorf("Body should contain 'Developer', got: %s", body)
}
if !contains(body, "Engineering") {
t.Errorf("Body should contain 'Engineering', got: %s", body)
}
t.Logf("Nested render - Subject: %s", subject)
t.Logf("Nested render - Body: %s", body)
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
contains(s[1:], substr))))
}

View file

@ -0,0 +1,263 @@
package template
import (
"fmt"
"path/filepath"
"strings"
"sync"
"github.com/PuerkitoBio/goquery"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/messenger/types"
)
// Manager manages message templates
type Manager struct {
templates map[string]map[types.TemplateType]*types.Template // [templateID][type] -> template
mutex sync.RWMutex
}
// Global template manager instance
var Global *Manager = &Manager{
templates: make(map[string]map[types.TemplateType]*types.Template),
}
// GetTemplate returns a template by ID and type
func (m *Manager) GetTemplate(templateID string, templateType types.TemplateType) (*types.Template, error) {
m.mutex.RLock()
defer m.mutex.RUnlock()
if templates, exists := m.templates[templateID]; exists {
if template, typeExists := templates[templateType]; typeExists {
return template, nil
}
}
return nil, fmt.Errorf("template not found: %s.%s", templateID, templateType)
}
// GetAllTemplates returns all loaded templates
func (m *Manager) GetAllTemplates() map[string]map[types.TemplateType]*types.Template {
m.mutex.RLock()
defer m.mutex.RUnlock()
// Return a copy to prevent external modifications
result := make(map[string]map[types.TemplateType]*types.Template)
for id, templates := range m.templates {
result[id] = make(map[types.TemplateType]*types.Template)
for templateType, template := range templates {
result[id][templateType] = template
}
}
return result
}
// ReloadTemplates reloads all templates from disk
func (m *Manager) ReloadTemplates() error {
m.mutex.Lock()
defer m.mutex.Unlock()
// Clear existing templates
m.templates = make(map[string]map[types.TemplateType]*types.Template)
// Load templates from disk
return loadTemplates(m)
}
// loadTemplates loads all templates from the templates directory
func loadTemplates(m *Manager) error {
// Check if templates directory exists
templatesPath := "messengers/templates"
exists, err := application.App.Exists(templatesPath)
if err != nil {
log.Error("[Template] Error checking templates directory: %v", err)
return err
}
if !exists {
log.Warn("[Template] templates directory not found, skip loading templates")
return nil
}
log.Info("[Template] Templates directory exists, starting to load templates")
// Walk through template files
// Pattern: {name}.{type}.html and {name}.{type}.txt
exts := []string{"*.mail.html", "*.sms.txt", "*.whatsapp.html"}
log.Info("[Template] Starting to walk templates directory with extensions: %v", exts)
err = application.App.Walk(templatesPath, func(root, file string, isdir bool) error {
log.Info("[Template] Walk callback: root=%s, file=%s, isdir=%v", root, file, isdir)
if isdir {
return nil
}
log.Info("[Template] Processing file: %s", file)
// Generate template ID manually to avoid share.ID's dot-to-underscore conversion
// Format: {language}.{name} (e.g., "en.invite_member")
relativePath := strings.TrimPrefix(file, root+"/")
pathParts := strings.Split(relativePath, "/")
language := pathParts[0]
filename := pathParts[len(pathParts)-1]
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
// Remove type suffix (e.g., "invite_member.mail" -> "invite_member")
templateName := strings.Split(baseName, ".")[0]
templateID := fmt.Sprintf("%s.%s", language, templateName)
log.Info("[Template] Generated templateID: %s for file: %s", templateID, file)
template, err := loadTemplate(file, templateID)
if err != nil {
log.Warn("[Template] Failed to load template %s: %v", file, err)
return nil // Continue loading other templates
}
if template != nil {
log.Info("[Template] Loaded template: %s.%s", template.ID, template.Type)
// Initialize template map for this ID if it doesn't exist
if m.templates[template.ID] == nil {
m.templates[template.ID] = make(map[types.TemplateType]*types.Template)
}
m.templates[template.ID][template.Type] = template
}
return nil
}, exts...)
if err != nil {
return err
}
log.Info("[Template] Loaded %d templates", len(m.templates))
return nil
}
// loadTemplate loads a single template file
func loadTemplate(file string, templateID string) (*types.Template, error) {
raw, err := application.App.Read(file)
if err != nil {
return nil, err
}
// Extract filename from file path to determine template type
filename := filepath.Base(file)
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
// Parse template type from filename
// Format: {name}.{type}.{ext} -> {type}
templateType, _ := parseTemplateType(baseName)
// Use the provided templateID (already in format: language.name)
fullTemplateID := templateID
// Extract language from templateID (format: language.name)
parts := strings.Split(templateID, ".")
language := parts[0]
// Determine template type
var msgType types.TemplateType
switch templateType {
case "mail":
msgType = types.TemplateTypeMail
case "sms":
msgType = types.TemplateTypeSMS
case "whatsapp":
msgType = types.TemplateTypeWhatsApp
default:
return nil, fmt.Errorf("unsupported template type: %s", templateType)
}
// Parse template content
subject, body, html, err := parseTemplateContent(string(raw), msgType)
if err != nil {
return nil, err
}
// No need to compile templates - we'll use simple string replacement
return &types.Template{
ID: fullTemplateID,
Type: msgType,
Language: language,
Subject: subject,
Body: body,
HTML: html,
}, nil
}
// parseTemplateType parses template type from filename
// Example: "invite_member.mail" -> "mail", "invite_member"
func parseTemplateType(filename string) (templateType, templateName string) {
parts := strings.Split(filename, ".")
if len(parts) < 2 {
return "", filename
}
// Last part is the type
templateType = parts[len(parts)-1]
// Everything before the last part is the name
templateName = strings.Join(parts[:len(parts)-1], ".")
return templateType, templateName
}
// parseTemplateContent parses template content based on type
func parseTemplateContent(content string, templateType types.TemplateType) (subject, body, html string, err error) {
content = strings.TrimSpace(content)
switch templateType {
case types.TemplateTypeMail:
return parseMailTemplate(content)
case types.TemplateTypeSMS:
return parseSMSTemplate(content)
case types.TemplateTypeWhatsApp:
return parseWhatsAppTemplate(content)
default:
return "", "", "", fmt.Errorf("unsupported template type: %s", templateType)
}
}
// parseMailTemplate parses mail template with HTML structure using goquery
func parseMailTemplate(content string) (subject, body, html string, err error) {
// Parse HTML content with goquery
doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
if err != nil {
return "", "", "", fmt.Errorf("failed to parse mail template HTML: %w", err)
}
// Extract subject from <Subject> tag
subject = strings.TrimSpace(doc.Find("Subject").Text())
// Extract body content from <body> tag
bodySelection := doc.Find("body")
if bodySelection.Length() == 0 {
return "", "", "", fmt.Errorf("no <body> tag found in mail template")
}
// Get the HTML content of the body tag
body, err = bodySelection.Html()
if err != nil {
return "", "", "", fmt.Errorf("failed to extract body HTML: %w", err)
}
body = strings.TrimSpace(body)
// For mail templates, body is HTML content
html = body
return subject, body, html, nil
}
// parseSMSTemplate parses SMS template (plain text)
func parseSMSTemplate(content string) (subject, body, html string, err error) {
// SMS templates are just plain text
body = content
return "", body, "", nil
}
// parseWhatsAppTemplate parses WhatsApp template (similar to mail)
func parseWhatsAppTemplate(content string) (subject, body, html string, err error) {
// WhatsApp templates use similar structure to mail
return parseMailTemplate(content)
}
// LoadTemplates loads all templates during messenger initialization
func LoadTemplates() error {
return Global.ReloadTemplates()
}

View file

@ -0,0 +1,189 @@
package template
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
func TestTemplateManager_LoadTemplates(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load templates
err := LoadTemplates()
require.NoError(t, err)
// Check if templates were loaded
templates := Global.GetAllTemplates()
assert.NotNil(t, templates)
// Log loaded templates for debugging
t.Logf("Loaded %d template groups", len(templates))
for _, templateGroup := range templates {
for _, template := range templateGroup {
t.Logf("Template: %s, Type: %s, Language: %s", template.ID, template.Type, template.Language)
}
}
}
func TestTemplateManager_GetTemplate(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load templates
err := LoadTemplates()
require.NoError(t, err)
// Test getting a specific template
template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail)
if err != nil {
t.Logf("Template not found (expected if templates not loaded): %v", err)
return
}
// Verify template properties
assert.NotNil(t, template)
assert.Equal(t, "en.invite_member", template.ID)
assert.Equal(t, types.TemplateTypeMail, template.Type)
assert.Equal(t, "en", template.Language)
assert.NotEmpty(t, template.Subject)
assert.NotEmpty(t, template.Body)
}
func TestTemplate_Render(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load templates
err := LoadTemplates()
require.NoError(t, err)
// Test template rendering
template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail)
if err != nil {
t.Logf("Template not found (expected if templates not loaded): %v", err)
return
}
// Test data
data := types.TemplateData{
"team_name": "Awesome Team",
"inviter_name": "Alice Johnson",
"invite_link": "https://example.com/invite/abc123",
}
// Render template
subject, body, html, err := template.Render(data)
require.NoError(t, err)
// Verify rendered content
assert.NotEmpty(t, subject)
assert.NotEmpty(t, body)
assert.NotEmpty(t, html)
// Check that variables were replaced
assert.Contains(t, subject, "Awesome Team")
assert.Contains(t, body, "Alice Johnson")
assert.Contains(t, body, "https://example.com/invite/abc123")
t.Logf("Rendered subject: %s", subject)
t.Logf("Rendered body: %s", body)
}
func TestTemplate_ToMessage(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load templates
err := LoadTemplates()
require.NoError(t, err)
// Test template to message conversion
template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeMail)
if err != nil {
t.Logf("Template not found (expected if templates not loaded): %v", err)
return
}
// Test data with recipients
data := types.TemplateData{
"to": []string{"test@example.com", "user@example.com"},
"team_name": "Awesome Team",
"inviter_name": "Alice Johnson",
"invite_link": "https://example.com/invite/abc123",
}
// Convert template to message
message, err := template.ToMessage(data)
require.NoError(t, err)
// Verify message properties
assert.NotNil(t, message)
assert.Equal(t, types.MessageType("mail"), message.Type)
assert.NotEmpty(t, message.Subject)
assert.NotEmpty(t, message.Body)
assert.NotEmpty(t, message.HTML)
assert.Len(t, message.To, 2)
assert.Equal(t, "test@example.com", message.To[0])
assert.Equal(t, "user@example.com", message.To[1])
// Check that variables were replaced
assert.Contains(t, message.Subject, "Awesome Team")
assert.Contains(t, message.Body, "Alice Johnson")
assert.Contains(t, message.Body, "https://example.com/invite/abc123")
t.Logf("Generated message: Subject=%s, To=%v", message.Subject, message.To)
}
func TestTemplate_SMSTemplate(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load templates
err := LoadTemplates()
require.NoError(t, err)
// Test SMS template
template, err := Global.GetTemplate("en.invite_member", types.TemplateTypeSMS)
if err != nil {
t.Logf("SMS template not found (expected if templates not loaded): %v", err)
return
}
// Test data
data := types.TemplateData{
"to": []string{"+1234567890"},
"team_name": "Awesome Team",
"inviter_name": "Alice Johnson",
"invite_link": "https://example.com/invite/abc123",
}
// Convert template to message
message, err := template.ToMessage(data)
require.NoError(t, err)
// Verify SMS message properties
assert.NotNil(t, message)
assert.Equal(t, types.MessageTypeSMS, message.Type)
assert.NotEmpty(t, message.Body)
assert.Empty(t, message.HTML) // SMS should not have HTML
assert.Len(t, message.To, 1)
assert.Equal(t, "+1234567890", message.To[0])
// Check that variables were replaced
assert.Contains(t, message.Body, "Alice Johnson")
assert.Contains(t, message.Body, "Awesome Team")
assert.Contains(t, message.Body, "https://example.com/invite/abc123")
t.Logf("Generated SMS message: Body=%s, To=%v", message.Body, message.To)
}

View file

@ -0,0 +1,49 @@
package template
import (
"testing"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
func TestWalkTemplates(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Test Walk function directly
t.Log("Testing Walk function directly...")
// Check if templates directory exists
exists, err := application.App.Exists("messengers/templates")
if err != nil {
t.Fatalf("Error checking templates directory: %v", err)
}
if !exists {
t.Log("Templates directory not found")
return
}
t.Log("Templates directory exists")
// Test Walk with different extensions
exts := []string{"*.mail.html", "*.sms.txt", "*.whatsapp.html"}
t.Logf("Testing Walk with extensions: %v", exts)
fileCount := 0
err = application.App.Walk("messengers/templates", func(root, file string, isdir bool) error {
t.Logf("Walk callback: root=%s, file=%s, isdir=%v", root, file, isdir)
if !isdir {
fileCount++
}
return nil
}, exts...)
if err != nil {
t.Fatalf("Walk failed: %v", err)
}
t.Logf("Walk completed, found %d files", fileCount)
}

View file

@ -13,6 +13,15 @@ type Provider interface {
// SendBatch sends multiple messages in batch
SendBatch(ctx context.Context, messages []*Message) error
// SendT sends a message using a template (optional - providers may return "not implemented" error)
SendT(ctx context.Context, templateID string, data TemplateData) error
// SendTBatch sends multiple messages using the same template with different data (optional - providers may return "not implemented" error)
SendTBatch(ctx context.Context, templateID string, dataList []TemplateData) error
// SendTBatchMixed sends multiple messages using different templates with different data (optional - providers may return "not implemented" error)
SendTBatchMixed(ctx context.Context, templateRequests []TemplateRequest) error
// TriggerWebhook processes webhook requests and converts to Message
TriggerWebhook(c interface{}) (*Message, error)
@ -40,6 +49,24 @@ type Messenger interface {
// SendWithProvider sends a message using a specific provider
SendWithProvider(ctx context.Context, providerName string, message *Message) error
// SendT sends a message using a template
SendT(ctx context.Context, channel string, templateID string, data TemplateData) error
// SendTWithProvider sends a message using a template and specific provider
SendTWithProvider(ctx context.Context, providerName string, templateID string, data TemplateData) error
// SendTBatch sends multiple messages using the same template with different data
SendTBatch(ctx context.Context, channel string, templateID string, dataList []TemplateData) error
// SendTBatchWithProvider sends multiple messages using the same template with different data and specific provider
SendTBatchWithProvider(ctx context.Context, providerName string, templateID string, dataList []TemplateData) error
// SendTBatchMixed sends multiple messages using different templates with different data
SendTBatchMixed(ctx context.Context, channel string, templateRequests []TemplateRequest) error
// SendTBatchMixedWithProvider sends multiple messages using different templates with different data and specific provider
SendTBatchMixedWithProvider(ctx context.Context, providerName string, templateRequests []TemplateRequest) error
// SendBatch sends multiple messages in batch
SendBatch(ctx context.Context, channel string, messages []*Message) error

146
messenger/types/template.go Normal file
View file

@ -0,0 +1,146 @@
package types
import (
"fmt"
"regexp"
"strings"
)
// TemplateType represents the type of template (mail, sms, whatsapp)
type TemplateType string
const (
TemplateTypeMail TemplateType = "mail"
TemplateTypeSMS TemplateType = "sms"
TemplateTypeWhatsApp TemplateType = "whatsapp"
)
// Template represents a message template
type Template struct {
ID string `json:"id"`
Type TemplateType `json:"type"`
Language string `json:"language"`
Subject string `json:"subject,omitempty"`
Body string `json:"body"`
HTML string `json:"html,omitempty"`
}
// TemplateData represents data to be used in template rendering
type TemplateData map[string]interface{}
// Render renders the template with the provided data using simple string replacement
func (t *Template) Render(data TemplateData) (subject, body, html string, err error) {
// Render subject if available
if t.Subject != "" {
subject = renderTemplate(t.Subject, data)
}
// Render body
if t.Body != "" {
body = renderTemplate(t.Body, data)
}
// Render HTML if available
if t.HTML != "" {
html = renderTemplate(t.HTML, data)
}
return subject, body, html, nil
}
// ToMessage converts template to Message with provided data
func (t *Template) ToMessage(data TemplateData) (*Message, error) {
// Render template
subject, body, html, err := t.Render(data)
if err != nil {
return nil, fmt.Errorf("failed to render template: %w", err)
}
// Get recipients from data
var recipients []string
if toData, exists := data["to"]; exists {
switch v := toData.(type) {
case []string:
recipients = v
case string:
recipients = []string{v}
default:
return nil, fmt.Errorf("'to' field must be string or []string")
}
} else {
return nil, fmt.Errorf("template data must include 'to' field with recipients")
}
// Create message
message := &Message{
Type: MessageType(t.Type),
Subject: subject,
Body: body,
HTML: html,
To: recipients,
}
// Add optional fields from data
if from, exists := data["from"]; exists {
if fromStr, ok := from.(string); ok {
message.From = fromStr
}
}
return message, nil
}
// renderTemplate renders a template string with data using {{ }} syntax
func renderTemplate(template string, data TemplateData) string {
// Find all {{ variable }} patterns
re := regexp.MustCompile(`\{\{\s*([^}]+)\s*\}\}`)
return re.ReplaceAllStringFunc(template, func(match string) string {
// Extract variable name from {{ variable }}
variable := strings.TrimSpace(strings.Trim(match, "{}"))
// Get value from data using dot notation for nested access
value := getNestedValue(data, variable)
// Convert to string
return fmt.Sprintf("%v", value)
})
}
// getNestedValue gets a value from data using dot notation (e.g., "user.name", "team.members.count")
func getNestedValue(data TemplateData, key string) interface{} {
parts := strings.Split(key, ".")
current := interface{}(data)
for _, part := range parts {
part = strings.TrimSpace(part)
switch v := current.(type) {
case map[string]interface{}:
if val, exists := v[part]; exists {
current = val
} else {
return "" // Return empty string if key not found
}
case TemplateData:
if val, exists := v[part]; exists {
current = val
} else {
return "" // Return empty string if key not found
}
default:
return "" // Return empty string if not a map
}
}
return current
}
// TemplateManager manages message templates
type TemplateManager interface {
// GetTemplate returns a template by ID and type
GetTemplate(templateID string, templateType TemplateType) (*Template, error)
// GetAllTemplates returns all loaded templates
GetAllTemplates() map[string]map[TemplateType]*Template
}

View file

@ -80,15 +80,6 @@ type RateLimit struct {
Window time.Duration `json:"window,omitempty"`
}
// Template represents a message template
type Template struct {
Subject string `json:"subject,omitempty"`
Body string `json:"body"`
HTML string `json:"html,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// GlobalConfig represents global messenger settings
type GlobalConfig struct {
RetryAttempts int `json:"retry_attempts,omitempty"`
@ -134,3 +125,9 @@ type Features struct {
SupportsTracking bool `json:"supports_tracking"`
SupportsScheduling bool `json:"supports_scheduling"`
}
// TemplateRequest represents a request to send a message using a specific template
type TemplateRequest struct {
TemplateID string `json:"template_id"`
Data TemplateData `json:"data"`
}