Enhance Email Delivery Configuration and Update Documentation

- Added `robot_email` field to the Robot structure for specifying the sender's email address in email communications.
- Updated the EmailTarget structure by removing the CC field, simplifying the configuration for email targets.
- Revised TECHNICAL.md to include details on the new `robot_email` field and its usage in email delivery, ensuring clarity on the sender identity.
- Enhanced the Delivery Center's email handling to utilize the Robot's email as the From address, with a fallback to the provider's default if not configured.
- Updated DESIGN.md to reflect changes in email delivery architecture and added new sections for global email configuration.
- Marked completion of related tasks in TODO.md, confirming the integration of new email features and structures.
This commit is contained in:
Max 2026-01-18 18:16:09 +08:00
parent 6c45cc41be
commit 62a18f0c8d
9 changed files with 2060 additions and 76 deletions

View file

@ -138,6 +138,7 @@ Uses existing `__yao.member` model (`yao/models/member.mod.yao`):
| `robot_config` | JSON | Agent configuration (see section 5) |
| `robot_status` | enum | `idle` \| `working` \| `paused` \| `error` \| `maintenance` |
| `system_prompt` | text | Identity & role prompt |
| `robot_email` | string | Robot's email address for sending emails (From address) |
| `agents` | JSON | Accessible agents list |
| `mcp_servers` | JSON | Accessible MCP servers |
| `manager_id` | string | Direct manager user ID |
@ -859,7 +860,6 @@ type EmailPreference struct {
type EmailTarget struct {
To []string `json:"to"` // Recipient addresses
CC []string `json:"cc,omitempty"` // CC addresses
Template string `json:"template,omitempty"` // Email template ID
Subject string `json:"subject,omitempty"` // Subject template
}

View file

@ -1169,6 +1169,7 @@ type Robot struct {
SystemPrompt string `json:"system_prompt"`
Status RobotStatus `json:"robot_status"`
AutonomousMode bool `json:"autonomous_mode"`
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
// Parsed config (from robot_config JSON field)
Config *Config `json:"-"`
@ -1448,7 +1449,6 @@ type EmailPreference struct {
type EmailTarget struct {
To []string `json:"to"` // Recipient addresses
CC []string `json:"cc,omitempty"` // CC addresses
Template string `json:"template,omitempty"` // Email template ID
Subject string `json:"subject,omitempty"` // Subject template (default: content.Summary)
}
@ -2166,7 +2166,27 @@ The agent focuses on content generation:
}
```
### 6.5 Delivery Center
### 6.5 Global Email Configuration
Email delivery uses global configuration for channel selection and Robot-specific sender identity:
```go
// types/config_global.go
// DefaultEmailChannel returns the default messenger channel name
// Default: "email" (maps to messengers/channels.yao)
func DefaultEmailChannel() string
// SetDefaultEmailChannel sets the default channel (call during agent init)
func SetDefaultEmailChannel(channel string)
```
**Usage:**
- `DefaultEmailChannel()` - returns the messenger channel name for email delivery
- `Robot.RobotEmail` - used as the `From` address when sending emails
- If `RobotEmail` is empty, falls back to provider's default `from` address
### 6.6 Delivery Center
The Delivery Center receives `DeliveryRequest`, reads preferences, and executes delivery to **all enabled targets**.
@ -2187,10 +2207,10 @@ func (dc *DeliveryCenter) Deliver(ctx context.Context, req *DeliveryRequest) *De
var results []ChannelResult
allSuccess := true
// Email - send to all targets
// Email - send to all targets (robot passed for From address)
if prefs.Email != nil && prefs.Email.Enabled {
for _, target := range prefs.Email.Targets {
result := dc.sendEmail(ctx, req.Content, target)
result := dc.sendEmail(ctx, req.Content, target, req.Context, robot)
results = append(results, result)
if !result.Success {
allSuccess = false
@ -2232,13 +2252,20 @@ func (dc *DeliveryCenter) Deliver(ctx context.Context, req *DeliveryRequest) *De
}
```
### 6.6 Channel Handlers
### 6.7 Channel Handlers
Each delivery channel is handled by dedicated methods in DeliveryCenter:
```go
// sendEmail - send to a single email target
func (dc *DeliveryCenter) sendEmail(ctx context.Context, content *DeliveryContent, target EmailTarget) ChannelResult {
// Uses Robot.RobotEmail as From address and global DefaultEmailChannel()
func (dc *DeliveryCenter) sendEmail(
ctx context.Context,
content *DeliveryContent,
target EmailTarget,
deliveryCtx *DeliveryContext,
robot *Robot,
) ChannelResult {
// Convert attachments to messenger format
var attachments []messenger.Attachment
for _, att := range content.Attachments {
@ -2255,17 +2282,25 @@ func (dc *DeliveryCenter) sendEmail(ctx context.Context, content *DeliveryConten
}
subject := content.Summary
if target.SubjectTemplate != "" {
subject = target.SubjectTemplate
if target.Subject != "" {
subject = target.Subject
}
err := dc.messenger.Send(ctx, &messenger.Message{
msg := &messenger.Message{
To: target.To,
CC: target.CC,
Subject: subject,
Body: content.Body,
Attachments: attachments,
})
}
// Set From address from Robot's email (if configured)
if robot != nil && robot.RobotEmail != "" {
msg.From = robot.RobotEmail
}
// Use global default email channel
channel := DefaultEmailChannel() // from types/config_global.go
err := dc.messenger.Send(ctx, channel, msg)
now := time.Now()
return ChannelResult{
@ -2344,7 +2379,7 @@ func (dc *DeliveryCenter) callProcess(ctx context.Context, content *DeliveryCont
2. Automatically send in-app notifications to subscribed users
3. This is transparent to P4 and Delivery Agent
### 6.7 Execution Persistence
### 6.8 Execution Persistence
Robot execution history is stored in `__yao.agent_execution` table for UI display:

View file

@ -975,11 +975,11 @@ Supported channels:
### 10.4 Delivery Agent Setup
- [ ] `robot/delivery/package.yao` - Delivery Agent config
- [ ] `robot/delivery/prompts.yml` - delivery prompts
- [ ] Input: Full execution context (P0-P3 results)
- [ ] Output: DeliveryContent (Summary, Body, Attachments) - **only content, no channels**
- [ ] Agent focuses on content generation, NOT channel selection
- [x] `robot/delivery/package.yao` - Delivery Agent config
- [x] `robot/delivery/prompts.yml` - delivery prompts
- [x] Input: Full execution context (P0-P3 results)
- [x] Output: DeliveryContent (Summary, Body, Attachments) - **only content, no channels**
- [x] Agent focuses on content generation, NOT channel selection
### 10.5 Delivery Content Structure
@ -1036,46 +1036,48 @@ type DeliveryContext struct {
### 10.6 Implementation
**P4 Entry (executor/delivery.go):**
- [ ] `RunDelivery(ctx, exec, data)` - P4 entry point
- [ ] Call Delivery Agent to generate content (only content, no channels)
- [ ] Build DeliveryRequest (Content + Context)
- [ ] Push to Delivery Center
- [ ] Store DeliveryResult in exec.Delivery
- [x] `RunDelivery(ctx, exec, data)` - P4 entry point
- [x] Call Delivery Agent to generate content (only content, no channels)
- [x] Build DeliveryRequest (Content + Context)
- [x] Push to Delivery Center
- [x] Store DeliveryResult in exec.Delivery
**Delivery Center (executor/delivery.go, future: yao/delivery):**
- [ ] `DeliveryCenter.Deliver(ctx, request)` - main entry
- [ ] Read Robot/User delivery preferences
- [ ] Iterate through all enabled targets for each channel
- [ ] Aggregate ChannelResults into DeliveryResult
**Delivery Center (executor/delivery_center.go):**
- [x] `DeliveryCenter.Deliver(ctx, request)` - main entry
- [x] Read Robot/User delivery preferences
- [x] Iterate through all enabled targets for each channel
- [x] Aggregate ChannelResults into DeliveryResult
**Channel Handlers (each supports multiple targets):**
- [ ] `sendEmail()` - uses yao/messenger
- [ ] Convert DeliveryAttachment to messenger.Attachment
- [ ] Support multiple EmailTarget
- [ ] Support custom subject_template per target
- [ ] `postWebhook()` - POST JSON
- [ ] POST DeliveryContent as JSON payload
- [ ] Support multiple WebhookTarget
- [ ] Support custom headers per target
- [ ] `callProcess()` - Yao Process call
- [ ] DeliveryContent as first arg
- [ ] Support multiple ProcessTarget
- [ ] Support additional args per target
- [x] `sendEmail()` - uses yao/messenger
- [x] Convert DeliveryAttachment to messenger.Attachment
- [x] Support multiple EmailTarget
- [x] Support custom subject_template per target
- [x] Use `Robot.RobotEmail` as From address (if configured)
- [x] Use global `DefaultEmailChannel()` for messenger channel selection
- [x] `postWebhook()` - POST JSON
- [x] POST DeliveryContent as JSON payload
- [x] Support multiple WebhookTarget
- [x] Support custom headers per target
- [x] `callProcess()` - Yao Process call
- [x] DeliveryContent as first arg
- [x] Support multiple ProcessTarget
- [x] Support additional args per target
### 10.7 Tests
- [ ] `executor/delivery_test.go` - P4 delivery
- [ ] Test: Delivery Agent generates content (only content)
- [ ] Test: DeliveryCenter reads preferences
- [ ] Test: Multiple email targets
- [ ] Test: Multiple webhook targets
- [ ] Test: Multiple process targets
- [ ] Test: Mixed channels (email + webhook + process)
- [ ] Test: sendEmail with attachments
- [ ] Test: postWebhook with custom headers
- [ ] Test: callProcess with args
- [ ] Test: Partial success (some targets fail)
- [ ] Test: DeliveryResult aggregation
- [x] `executor/delivery_test.go` - P4 delivery
- [x] Test: Delivery Agent generates content (only content)
- [x] Test: DeliveryCenter reads preferences
- [x] Test: Multiple email targets (TestDeliveryCenterEmail)
- [x] Test: Multiple webhook targets
- [x] Test: Multiple process targets (TestDeliveryCenterProcess)
- [x] Test: Mixed channels (email + webhook + process) (TestDeliveryCenterAllChannels)
- [x] Test: sendEmail with attachments (TestDeliveryCenterEmail)
- [x] Test: postWebhook with custom headers
- [x] Test: callProcess with args (TestDeliveryCenterProcess)
- [x] Test: Partial success (some targets fail)
- [x] Test: DeliveryResult aggregation
---
@ -1291,7 +1293,7 @@ func TestWithLLM(t *testing.T) {
| 7. P1 Goals | ✅ | Goal Generation Agent integration |
| 8. P2 Tasks | ✅ | Task Planning Agent integration |
| 9. P3 Run | ✅ | Task execution + validation + yao/assert + multi-turn conversation |
| 10. P4 Delivery | | Output delivery (email/webhook/process, notify future) |
| 10. P4 Delivery | | Output delivery (email/webhook/process, notify future) |
| 11. API & Integration | ⬜ | Complete API, end-to-end tests (main flow: P0→P1→P2→P3→P4) |
| 12. Advanced | ⬜ | P5 Learning, dedup, plan queue, Sandbox mode |

View file

@ -1,36 +1,397 @@
package standard
import (
"encoding/json"
"fmt"
"strings"
"time"
robottypes "github.com/yaoapp/yao/agent/robot/types"
)
// RunDelivery executes P4: Delivery phase
// Delivers results to configured targets
// Calls the Delivery Agent to generate content, then routes to Delivery Center
//
// Input:
// - TaskResults (from P3)
// - Delivery config from robot
// - Full execution context (P0-P3)
// - Robot config
//
// Output:
// - DeliveryResult with success status
// - DeliveryResult with content and channel results
//
// Delivery Types:
// - DeliveryEmail: Send email
// - DeliveryNotify: Send notification
// - DeliveryWebhook: Call webhook
// - DeliveryStore: Store to database
//
// TODO: Implement real delivery
// Process:
// 1. Call Delivery Agent with full execution context
// 2. Agent generates DeliveryContent (summary, body, attachments)
// 3. Route content to Delivery Center for actual delivery
func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
e.simulateStreamDelay()
exec.Delivery = &robottypes.DeliveryResult{
RequestID: "delivery-" + exec.ID,
Content: &robottypes.DeliveryContent{
Summary: "Delivery completed (placeholder)",
Body: "# Delivery\n\nTODO: Implement real delivery logic.",
},
Success: true,
// Get robot for identity and resources
robot := exec.GetRobot()
if robot == nil {
return fmt.Errorf("robot not found in execution")
}
// Get agent ID for delivery phase
agentID := "__yao.delivery" // default
if robot.Config != nil && robot.Config.Resources != nil {
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseDelivery)
}
// Build input for Delivery Agent
formatter := NewInputFormatter()
userContent := formatter.FormatDeliveryInput(exec, robot)
if userContent == "" {
return fmt.Errorf("no content available for delivery generation")
}
// Call Delivery Agent
caller := NewAgentCaller()
result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil {
return fmt.Errorf("delivery agent (%s) call failed: %w", agentID, err)
}
// Parse response as JSON
// Delivery Agent returns: { "content": { "summary": "...", "body": "...", "attachments": [...] } }
data, err := result.GetJSON()
if err != nil {
// Fallback: if not JSON, create minimal content from raw text
content := result.GetText()
if content == "" {
return fmt.Errorf("delivery agent returned empty response")
}
exec.Delivery = &robottypes.DeliveryResult{
RequestID: generateRequestID(exec.ID),
Content: &robottypes.DeliveryContent{
Summary: truncateSummary(content, 200),
Body: content,
},
Success: true,
}
return e.routeToDeliveryCenter(ctx, exec, robot)
}
// Build DeliveryContent from JSON
content := parseDeliveryContent(data)
if content == nil {
return fmt.Errorf("delivery agent (%s) returned invalid content", agentID)
}
// Build DeliveryResult
exec.Delivery = &robottypes.DeliveryResult{
RequestID: generateRequestID(exec.ID),
Content: content,
Success: true,
}
// Route to Delivery Center for actual delivery
return e.routeToDeliveryCenter(ctx, exec, robot)
}
// routeToDeliveryCenter sends content to the Delivery Center for actual delivery
// The Delivery Center decides which channels to use based on robot/user preferences
func (e *Executor) routeToDeliveryCenter(ctx *robottypes.Context, exec *robottypes.Execution, robot *robottypes.Robot) error {
if exec.Delivery == nil || exec.Delivery.Content == nil {
return fmt.Errorf("no delivery content to route")
}
// Get delivery preferences from robot config
var prefs *robottypes.DeliveryPreferences
if robot.Config != nil {
prefs = robot.Config.Delivery
}
// If no preferences configured, skip delivery (content is still saved in exec.Delivery)
if prefs == nil || !hasActiveChannels(prefs) {
// No channels configured - mark as success but with no results
exec.Delivery.Success = true
return nil
}
// Create Delivery Center and execute
center := NewDeliveryCenter()
results, err := center.Deliver(ctx, exec.Delivery.Content, &robottypes.DeliveryContext{
MemberID: exec.MemberID,
ExecutionID: exec.ID,
TriggerType: exec.TriggerType,
TeamID: exec.TeamID,
}, prefs, robot)
// Update delivery result
exec.Delivery.Results = results
now := time.Now()
exec.Delivery.SentAt = &now
if err != nil {
exec.Delivery.Success = false
exec.Delivery.Error = err.Error()
return err
}
// Check if all channels succeeded
allSuccess := true
for _, r := range results {
if !r.Success {
allSuccess = false
break
}
}
exec.Delivery.Success = allSuccess
return nil
}
// parseDeliveryContent parses the Delivery Agent response into DeliveryContent
func parseDeliveryContent(data map[string]interface{}) *robottypes.DeliveryContent {
if data == nil {
return nil
}
// Try to get content object
contentData, ok := data["content"].(map[string]interface{})
if !ok {
// Fallback: maybe the data itself is the content
contentData = data
}
content := &robottypes.DeliveryContent{}
// Parse summary
if summary, ok := contentData["summary"].(string); ok {
content.Summary = summary
}
// Parse body
if body, ok := contentData["body"].(string); ok {
content.Body = body
}
// Parse attachments
if attachments, ok := contentData["attachments"].([]interface{}); ok {
for _, att := range attachments {
if attMap, ok := att.(map[string]interface{}); ok {
attachment := parseDeliveryAttachment(attMap)
if attachment != nil {
content.Attachments = append(content.Attachments, *attachment)
}
}
}
}
// Validate: at least summary or body should be present
if content.Summary == "" && content.Body == "" {
return nil
}
return content
}
// parseDeliveryAttachment parses a single attachment from the agent response
func parseDeliveryAttachment(data map[string]interface{}) *robottypes.DeliveryAttachment {
if data == nil {
return nil
}
att := &robottypes.DeliveryAttachment{}
if title, ok := data["title"].(string); ok {
att.Title = title
}
if desc, ok := data["description"].(string); ok {
att.Description = desc
}
if taskID, ok := data["task_id"].(string); ok {
att.TaskID = taskID
}
if file, ok := data["file"].(string); ok {
att.File = file
}
// At minimum, need title and file
if att.Title == "" || att.File == "" {
return nil
}
return att
}
// generateRequestID generates a unique request ID for delivery tracking
func generateRequestID(execID string) string {
return fmt.Sprintf("dlv-%s-%d", execID, time.Now().UnixNano()%1000000)
}
// getTaskDescription extracts a description from task messages
func getTaskDescription(task robottypes.Task) string {
if len(task.Messages) == 0 {
return task.GoalRef
}
// Try to get text from first message
for _, msg := range task.Messages {
if content, ok := msg.Content.(string); ok && content != "" {
// Truncate if too long
if len(content) > 100 {
return content[:97] + "..."
}
return content
}
}
// Fallback to goal reference
if task.GoalRef != "" {
return task.GoalRef
}
return "Task " + task.ID
}
// truncateSummary truncates text to maxLen characters
func truncateSummary(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
// Find last space before maxLen to avoid cutting words
truncated := text[:maxLen]
if idx := strings.LastIndex(truncated, " "); idx > maxLen/2 {
return truncated[:idx] + "..."
}
return truncated + "..."
}
// hasActiveChannels checks if any delivery channel is configured
func hasActiveChannels(prefs *robottypes.DeliveryPreferences) bool {
if prefs == nil {
return false
}
if prefs.Email != nil && prefs.Email.Enabled && len(prefs.Email.Targets) > 0 {
return true
}
if prefs.Webhook != nil && prefs.Webhook.Enabled && len(prefs.Webhook.Targets) > 0 {
return true
}
if prefs.Process != nil && prefs.Process.Enabled && len(prefs.Process.Targets) > 0 {
return true
}
return false
}
// FormatDeliveryInput formats the full execution context for the Delivery Agent
func (f *InputFormatter) FormatDeliveryInput(exec *robottypes.Execution, robot *robottypes.Robot) string {
if exec == nil {
return ""
}
var sb strings.Builder
// Robot identity
if robot != nil && robot.Config != nil && robot.Config.Identity != nil {
sb.WriteString("## Robot Identity\n\n")
sb.WriteString(fmt.Sprintf("- **Role**: %s\n", robot.Config.Identity.Role))
if len(robot.Config.Identity.Duties) > 0 {
sb.WriteString("- **Duties**: ")
sb.WriteString(strings.Join(robot.Config.Identity.Duties, ", "))
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// Trigger type
sb.WriteString("## Execution Context\n\n")
sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType))
sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status))
sb.WriteString(fmt.Sprintf("- **Start Time**: %s\n", exec.StartTime.Format("2006-01-02 15:04:05")))
if exec.EndTime != nil {
duration := exec.EndTime.Sub(exec.StartTime)
sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String()))
}
sb.WriteString("\n")
// Inspiration (P0) - for clock trigger
if exec.Inspiration != nil && exec.Inspiration.Content != "" {
sb.WriteString("## Inspiration (P0)\n\n")
sb.WriteString(exec.Inspiration.Content)
sb.WriteString("\n\n")
}
// Goals (P1)
if exec.Goals != nil && exec.Goals.Content != "" {
sb.WriteString("## Goals (P1)\n\n")
sb.WriteString(exec.Goals.Content)
sb.WriteString("\n\n")
}
// Tasks (P2)
if len(exec.Tasks) > 0 {
sb.WriteString("## Tasks (P2)\n\n")
for i, task := range exec.Tasks {
// Extract task description from messages if available
taskDesc := getTaskDescription(task)
sb.WriteString(fmt.Sprintf("%d. **%s** - %s\n", i+1, task.ID, taskDesc))
sb.WriteString(fmt.Sprintf(" - Executor: %s (%s)\n", task.ExecutorID, task.ExecutorType))
sb.WriteString(fmt.Sprintf(" - Status: %s\n", task.Status))
if task.ExpectedOutput != "" {
sb.WriteString(fmt.Sprintf(" - Expected: %s\n", task.ExpectedOutput))
}
}
sb.WriteString("\n")
}
// Results (P3) - detailed
if len(exec.Results) > 0 {
sb.WriteString("## Results (P3)\n\n")
successCount := 0
failCount := 0
for _, result := range exec.Results {
if result.Success {
successCount++
sb.WriteString(fmt.Sprintf("### ✓ Task: %s\n\n", result.TaskID))
} else {
failCount++
sb.WriteString(fmt.Sprintf("### ✗ Task: %s\n\n", result.TaskID))
}
sb.WriteString(fmt.Sprintf("- **Duration**: %dms\n", result.Duration))
// Validation
if result.Validation != nil {
if result.Validation.Passed {
sb.WriteString(fmt.Sprintf("- **Validation**: ✓ Passed (score: %.2f)\n", result.Validation.Score))
} else {
sb.WriteString("- **Validation**: ✗ Failed\n")
if len(result.Validation.Issues) > 0 {
for _, issue := range result.Validation.Issues {
sb.WriteString(fmt.Sprintf(" - %s\n", issue))
}
}
}
}
// Output
if result.Output != nil {
sb.WriteString("\n**Output**:\n")
if output, err := json.MarshalIndent(result.Output, "", " "); err == nil {
sb.WriteString("```json\n")
sb.WriteString(string(output))
sb.WriteString("\n```\n")
} else {
sb.WriteString(fmt.Sprintf("%v\n", result.Output))
}
}
// Error
if result.Error != "" {
sb.WriteString(fmt.Sprintf("\n**Error**: %s\n", result.Error))
}
sb.WriteString("\n")
}
// Summary
sb.WriteString(fmt.Sprintf("### Summary\n\n- **Total Tasks**: %d\n- **Succeeded**: %d\n- **Failed**: %d\n\n",
len(exec.Results), successCount, failCount))
}
return sb.String()
}

View file

@ -0,0 +1,378 @@
package standard
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/yaoapp/gou/process"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/attachment"
"github.com/yaoapp/yao/messenger"
messengerTypes "github.com/yaoapp/yao/messenger/types"
)
// DeliveryCenter handles routing delivery content to configured channels
// It decides which channels to use based on robot/user preferences and executes the delivery
type DeliveryCenter struct {
httpClient *http.Client
}
// NewDeliveryCenter creates a new DeliveryCenter instance
func NewDeliveryCenter() *DeliveryCenter {
return &DeliveryCenter{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// Deliver sends content to all configured channels based on preferences
// Returns results for each channel target and any error
func (dc *DeliveryCenter) Deliver(
ctx *robottypes.Context,
content *robottypes.DeliveryContent,
deliveryCtx *robottypes.DeliveryContext,
prefs *robottypes.DeliveryPreferences,
robotInstance *robottypes.Robot,
) ([]robottypes.ChannelResult, error) {
if content == nil {
return nil, fmt.Errorf("delivery content is nil")
}
if prefs == nil {
return nil, nil // No preferences = no delivery
}
var results []robottypes.ChannelResult
var lastErr error
// Process email targets
if prefs.Email != nil && prefs.Email.Enabled {
for _, target := range prefs.Email.Targets {
result := dc.sendEmail(ctx.Context, content, target, deliveryCtx, robotInstance)
results = append(results, result)
if !result.Success && lastErr == nil {
lastErr = fmt.Errorf("email delivery failed: %s", result.Error)
}
}
}
// Process webhook targets
if prefs.Webhook != nil && prefs.Webhook.Enabled {
for _, target := range prefs.Webhook.Targets {
result := dc.postWebhook(ctx.Context, content, target, deliveryCtx)
results = append(results, result)
if !result.Success && lastErr == nil {
lastErr = fmt.Errorf("webhook delivery failed: %s", result.Error)
}
}
}
// Process process targets
if prefs.Process != nil && prefs.Process.Enabled {
for _, target := range prefs.Process.Targets {
result := dc.callProcess(ctx.Context, content, target, deliveryCtx)
results = append(results, result)
if !result.Success && lastErr == nil {
lastErr = fmt.Errorf("process delivery failed: %s", result.Error)
}
}
}
return results, lastErr
}
// sendEmail sends delivery content to a single email target
func (dc *DeliveryCenter) sendEmail(
ctx context.Context,
content *robottypes.DeliveryContent,
target robottypes.EmailTarget,
deliveryCtx *robottypes.DeliveryContext,
robotInstance *robottypes.Robot,
) robottypes.ChannelResult {
now := time.Now()
// Build target identifier from recipients
targetID := strings.Join(target.To, ",")
if targetID == "" {
targetID = "no-recipients"
}
result := robottypes.ChannelResult{
Type: robottypes.DeliveryEmail,
Target: targetID,
SentAt: &now,
}
// Get messenger service
svc := messenger.Instance
if svc == nil {
result.Error = "messenger service not available"
return result
}
// Build email message
msg := &messengerTypes.Message{
To: target.To,
Subject: buildEmailSubject(target.Subject, target.Template, content, deliveryCtx),
Body: buildEmailBody(target.Template, content),
Type: messengerTypes.MessageTypeEmail,
}
// Set From address from Robot's email (if configured)
if robotInstance != nil && robotInstance.RobotEmail != "" {
msg.From = robotInstance.RobotEmail
}
// Convert attachments
attachments := convertAttachments(ctx, content.Attachments)
if len(attachments) > 0 {
msg.Attachments = attachments
}
// Send email using global default channel
channel := robottypes.DefaultEmailChannel()
err := svc.Send(ctx, channel, msg)
if err != nil {
result.Error = err.Error()
return result
}
result.Success = true
result.Recipients = target.To
return result
}
// postWebhook posts delivery content to a single webhook target
func (dc *DeliveryCenter) postWebhook(
ctx context.Context,
content *robottypes.DeliveryContent,
target robottypes.WebhookTarget,
deliveryCtx *robottypes.DeliveryContext,
) robottypes.ChannelResult {
now := time.Now()
result := robottypes.ChannelResult{
Type: robottypes.DeliveryWebhook,
Target: target.URL,
SentAt: &now,
}
// Build webhook payload
payload := map[string]interface{}{
"event": "robot.delivery",
"timestamp": now.Format(time.RFC3339),
"execution_id": deliveryCtx.ExecutionID,
"member_id": deliveryCtx.MemberID,
"team_id": deliveryCtx.TeamID,
"trigger_type": deliveryCtx.TriggerType,
"content": map[string]interface{}{
"summary": content.Summary,
"body": content.Body,
},
}
// Add attachments info (not the actual files)
if len(content.Attachments) > 0 {
attachmentInfo := make([]map[string]interface{}, 0, len(content.Attachments))
for _, att := range content.Attachments {
attachmentInfo = append(attachmentInfo, map[string]interface{}{
"title": att.Title,
"description": att.Description,
"task_id": att.TaskID,
"file": att.File,
})
}
payload["attachments"] = attachmentInfo
}
// Marshal payload
payloadBytes, err := json.Marshal(payload)
if err != nil {
result.Error = fmt.Sprintf("failed to marshal payload: %v", err)
return result
}
// Build request
method := target.Method
if method == "" {
method = "POST"
}
req, err := http.NewRequestWithContext(ctx, method, target.URL, bytes.NewReader(payloadBytes))
if err != nil {
result.Error = fmt.Sprintf("failed to create request: %v", err)
return result
}
req.Header.Set("Content-Type", "application/json")
// Add custom headers
for key, value := range target.Headers {
req.Header.Set(key, value)
}
// Add secret if configured (for signature verification)
if target.Secret != "" {
// TODO: Implement HMAC signature
req.Header.Set("X-Webhook-Secret", target.Secret)
}
// Send request
resp, err := dc.httpClient.Do(req)
if err != nil {
result.Error = fmt.Sprintf("request failed: %v", err)
return result
}
defer resp.Body.Close()
// Read response body
body, _ := io.ReadAll(resp.Body)
// Check status code
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result.Error = fmt.Sprintf("webhook returned status %d: %s", resp.StatusCode, string(body))
return result
}
result.Success = true
result.Details = map[string]interface{}{
"status_code": resp.StatusCode,
"response": string(body),
}
return result
}
// callProcess calls a Yao Process with delivery content
func (dc *DeliveryCenter) callProcess(
ctx context.Context,
content *robottypes.DeliveryContent,
target robottypes.ProcessTarget,
deliveryCtx *robottypes.DeliveryContext,
) robottypes.ChannelResult {
now := time.Now()
result := robottypes.ChannelResult{
Type: robottypes.DeliveryProcess,
Target: target.Process,
SentAt: &now,
}
// Build args: DeliveryContent as first arg, then additional args
args := make([]interface{}, 0, 1+len(target.Args))
args = append(args, map[string]interface{}{
"content": map[string]interface{}{
"summary": content.Summary,
"body": content.Body,
"attachments": content.Attachments,
},
"context": map[string]interface{}{
"execution_id": deliveryCtx.ExecutionID,
"member_id": deliveryCtx.MemberID,
"team_id": deliveryCtx.TeamID,
"trigger_type": deliveryCtx.TriggerType,
},
})
args = append(args, target.Args...)
// Create and execute process
proc, err := process.Of(target.Process, args...)
if err != nil {
result.Error = fmt.Sprintf("failed to create process: %v", err)
return result
}
proc.Context = ctx
err = proc.Execute()
if err != nil {
result.Error = err.Error()
return result
}
result.Success = true
result.Details = proc.Value
return result
}
// buildEmailSubject builds the email subject line
func buildEmailSubject(subject, template string, content *robottypes.DeliveryContent, ctx *robottypes.DeliveryContext) string {
// Use explicit subject if provided
if subject != "" {
return subject
}
// Use template-based subject if template is specified
// TODO: Implement template rendering
if template != "" {
return fmt.Sprintf("[Robot] %s", content.Summary)
}
// Default: use summary
if content.Summary != "" {
return fmt.Sprintf("[Robot] %s", content.Summary)
}
return fmt.Sprintf("[Robot] Execution %s Complete", ctx.ExecutionID)
}
// buildEmailBody builds the email body content
func buildEmailBody(template string, content *robottypes.DeliveryContent) string {
// TODO: Implement template rendering
// For now, just use the body directly
if content.Body != "" {
return content.Body
}
return content.Summary
}
// convertAttachments converts DeliveryAttachment to messenger Attachment format
func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAttachment) []messengerTypes.Attachment {
if len(attachments) == 0 {
return nil
}
result := make([]messengerTypes.Attachment, 0, len(attachments))
for _, att := range attachments {
// Parse file wrapper: __<uploader>://<fileID>
uploader, fileID, isWrapper := attachment.Parse(att.File)
if !isWrapper {
// Skip non-wrapper attachments
continue
}
// Get file info from attachment manager
manager, ok := attachment.Managers[uploader]
if !ok {
continue
}
info, err := manager.Info(ctx, fileID)
if err != nil {
continue
}
// Read file content
content, err := manager.Read(ctx, fileID)
if err != nil {
continue
}
// Build messenger attachment
msgAtt := messengerTypes.Attachment{
Filename: info.Filename,
ContentType: info.ContentType,
Content: content,
}
result = append(result, msgAtt)
}
return result
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,35 @@
package types
import "sync"
// Global configuration for robot agent
// These values can be set during agent initialization
var (
// defaultEmailChannel - default messenger channel name for sending emails
// Can be configured via SetDefaultEmailChannel()
// Default: "email" (maps to messengers/channels.yao configuration)
defaultEmailChannel = "email"
// configMu protects global configuration
configMu sync.RWMutex
)
// DefaultEmailChannel returns the default email channel name
func DefaultEmailChannel() string {
configMu.RLock()
defer configMu.RUnlock()
return defaultEmailChannel
}
// SetDefaultEmailChannel sets the default messenger channel for email delivery
// This should be called during agent initialization
// The channel name must match a channel defined in messengers/channels.yao
func SetDefaultEmailChannel(channel string) {
if channel == "" {
return
}
configMu.Lock()
defer configMu.Unlock()
defaultEmailChannel = channel
}

View file

@ -20,6 +20,7 @@ type Robot struct {
SystemPrompt string `json:"system_prompt"`
Status RobotStatus `json:"robot_status"`
AutonomousMode bool `json:"autonomous_mode"`
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
// Parsed config (from robot_config JSON field)
Config *Config `json:"-"`
@ -317,7 +318,6 @@ type EmailPreference struct {
// EmailTarget - Single email target
type EmailTarget struct {
To []string `json:"to"` // Recipient addresses
CC []string `json:"cc,omitempty"` // CC addresses
Template string `json:"template,omitempty"` // Email template ID
Subject string `json:"subject,omitempty"` // Subject template
}
@ -383,6 +383,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
DisplayName: getString(m, "display_name"),
SystemPrompt: getString(m, "system_prompt"),
AutonomousMode: getBool(m, "autonomous_mode"),
RobotEmail: getString(m, "robot_email"),
}
// Parse robot_status

View file

@ -648,7 +648,6 @@ func TestDeliveryPreferencesStructure(t *testing.T) {
Targets: []types.EmailTarget{
{
To: []string{"team@example.com"},
CC: []string{"manager@example.com"},
Template: "weekly-report",
Subject: "Weekly Report - {{.Date}}",
},
@ -686,7 +685,6 @@ func TestDeliveryPreferencesStructure(t *testing.T) {
assert.Len(t, prefs.Email.Targets, 2)
assert.Equal(t, "weekly-report", prefs.Email.Targets[0].Template)
assert.Len(t, prefs.Email.Targets[0].To, 1)
assert.Len(t, prefs.Email.Targets[0].CC, 1)
// Webhook
assert.True(t, prefs.Webhook.Enabled)