mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
b3a39ad93d
* refactor: replace persistent subagents with ephemeral spawn tool (#subagent) - Drop subagents table, remove all persistent subagent infrastructure - Add 'subagent' session type with parent_session_id on bot_sessions - Rewrite subagent tool as single 'spawn' tool with parallel execution - Create system_subagent.md prompt, add _subagent.md include for chat - Limit subagent tools to file, exec, web_search, web_fetch only - Merge subagent token usage into parent chat session in reporting - Remove frontend subagent management page, update chat UI for spawn - Fix UTF-8 truncation in session title, fix query not passed to agent * refactor: remove history message page
277 lines
9.0 KiB
Go
277 lines
9.0 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
|
|
sdk "github.com/memohai/twilight-ai/sdk"
|
|
|
|
"github.com/memohai/memoh/internal/email"
|
|
)
|
|
|
|
type EmailProvider struct {
|
|
logger *slog.Logger
|
|
service *email.Service
|
|
manager *email.Manager
|
|
}
|
|
|
|
func NewEmailProvider(log *slog.Logger, service *email.Service, manager *email.Manager) *EmailProvider {
|
|
return &EmailProvider{
|
|
logger: log.With(slog.String("tool", "email")),
|
|
service: service,
|
|
manager: manager,
|
|
}
|
|
}
|
|
|
|
func (p *EmailProvider) Tools(_ context.Context, session SessionContext) ([]sdk.Tool, error) {
|
|
if session.IsSubagent {
|
|
return nil, nil
|
|
}
|
|
sess := session
|
|
return []sdk.Tool{
|
|
{
|
|
Name: "list_email_accounts", Description: "List the email accounts (provider bindings) configured for this bot, including provider IDs, email addresses, and permissions.",
|
|
Parameters: emptyObjectSchema(),
|
|
Execute: func(ctx *sdk.ToolExecContext, _ any) (any, error) {
|
|
return p.execListAccounts(ctx.Context, sess)
|
|
},
|
|
},
|
|
{
|
|
Name: "send_email", Description: "Send an email via the bot's configured email provider. Requires write permission.",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"to": map[string]any{"type": "string", "description": "Recipient email address(es), comma-separated"},
|
|
"subject": map[string]any{"type": "string", "description": "Email subject"},
|
|
"body": map[string]any{"type": "string", "description": "Email body content"},
|
|
"html": map[string]any{"type": "boolean", "description": "Whether body is HTML (default false)"},
|
|
"provider_id": map[string]any{"type": "string", "description": "Email provider ID to send from (optional, uses default if omitted)"},
|
|
},
|
|
"required": []string{"to", "subject", "body"},
|
|
},
|
|
Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) {
|
|
return p.execSendEmail(ctx.Context, sess, inputAsMap(input))
|
|
},
|
|
},
|
|
{
|
|
Name: "list_email", Description: "List emails from the mailbox (newest first). Supports pagination. Requires read permission.",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"page": map[string]any{"type": "integer", "description": "Page number, 0-based (default 0 = newest)"},
|
|
"page_size": map[string]any{"type": "integer", "description": "Emails per page (default 20)"},
|
|
"provider_id": map[string]any{"type": "string", "description": "Email provider ID (optional, uses first readable binding)"},
|
|
},
|
|
},
|
|
Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) {
|
|
return p.execListEmails(ctx.Context, sess, inputAsMap(input))
|
|
},
|
|
},
|
|
{
|
|
Name: "read_email", Description: "Read the full content of an email by its UID. Requires read permission.",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"uid": map[string]any{"type": "integer", "description": "The email UID from email_list results"},
|
|
"provider_id": map[string]any{"type": "string", "description": "Email provider ID (optional, uses first readable binding)"},
|
|
},
|
|
"required": []string{"uid"},
|
|
},
|
|
Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) {
|
|
return p.execReadEmail(ctx.Context, sess, inputAsMap(input))
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (p *EmailProvider) getBindings(ctx context.Context, botID string) ([]email.BindingResponse, error) {
|
|
bindings, err := p.service.ListBindings(ctx, botID)
|
|
if err != nil || len(bindings) == 0 {
|
|
return nil, errors.New("no email binding configured for this bot")
|
|
}
|
|
return bindings, nil
|
|
}
|
|
|
|
func (p *EmailProvider) execListAccounts(ctx context.Context, session SessionContext) (any, error) {
|
|
botID := strings.TrimSpace(session.BotID)
|
|
if botID == "" {
|
|
return nil, errors.New("bot_id is required")
|
|
}
|
|
bindings, err := p.getBindings(ctx, botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accounts := make([]map[string]any, 0, len(bindings))
|
|
for _, b := range bindings {
|
|
accounts = append(accounts, map[string]any{
|
|
"provider_id": b.EmailProviderID, "email_address": b.EmailAddress,
|
|
"can_read": b.CanRead, "can_write": b.CanWrite, "can_delete": b.CanDelete,
|
|
})
|
|
}
|
|
return map[string]any{"accounts": accounts}, nil
|
|
}
|
|
|
|
func (p *EmailProvider) execSendEmail(ctx context.Context, session SessionContext, args map[string]any) (any, error) {
|
|
botID := strings.TrimSpace(session.BotID)
|
|
if botID == "" {
|
|
return nil, errors.New("bot_id is required")
|
|
}
|
|
bindings, err := p.getBindings(ctx, botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
binding := resolveWriteBinding(bindings, StringArg(args, "provider_id"))
|
|
if binding == nil {
|
|
return nil, errors.New("email write permission denied or provider not found")
|
|
}
|
|
toRaw := StringArg(args, "to")
|
|
subject := StringArg(args, "subject")
|
|
body := StringArg(args, "body")
|
|
isHTML, _, _ := BoolArg(args, "html")
|
|
if toRaw == "" || subject == "" || body == "" {
|
|
return nil, errors.New("to, subject, and body are required")
|
|
}
|
|
var toList []string
|
|
for _, addr := range strings.Split(toRaw, ",") {
|
|
addr = strings.TrimSpace(addr)
|
|
if addr != "" {
|
|
toList = append(toList, addr)
|
|
}
|
|
}
|
|
messageID, err := p.manager.SendEmail(ctx, botID, binding.EmailProviderID, email.OutboundEmail{
|
|
To: toList, Subject: subject, Body: body, HTML: isHTML,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"message_id": messageID, "status": "sent"}, nil
|
|
}
|
|
|
|
func (p *EmailProvider) execListEmails(ctx context.Context, session SessionContext, args map[string]any) (any, error) {
|
|
botID := strings.TrimSpace(session.BotID)
|
|
if botID == "" {
|
|
return nil, errors.New("bot_id is required")
|
|
}
|
|
bindings, err := p.getBindings(ctx, botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
binding := resolveReadBinding(bindings, StringArg(args, "provider_id"))
|
|
if binding == nil {
|
|
return nil, errors.New("email read permission denied or provider not found")
|
|
}
|
|
providerName, config, err := p.service.ProviderConfig(ctx, binding.EmailProviderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
config = ensureProviderID(config, binding.EmailProviderID)
|
|
reader, err := p.service.Registry().GetMailboxReader(providerName)
|
|
if err != nil {
|
|
return nil, errors.New("mailbox listing not supported for this provider")
|
|
}
|
|
page, _, _ := IntArg(args, "page")
|
|
pageSize, _, _ := IntArg(args, "page_size")
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
emails, total, err := reader.ListMailbox(ctx, config, page, pageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summaries := make([]map[string]any, 0, len(emails))
|
|
for _, item := range emails {
|
|
summaries = append(summaries, map[string]any{
|
|
"uid": item.MessageID, "from": item.From, "subject": item.Subject, "received_at": item.ReceivedAt,
|
|
})
|
|
}
|
|
return map[string]any{"emails": summaries, "total": total, "page": page}, nil
|
|
}
|
|
|
|
func (p *EmailProvider) execReadEmail(ctx context.Context, session SessionContext, args map[string]any) (any, error) {
|
|
botID := strings.TrimSpace(session.BotID)
|
|
if botID == "" {
|
|
return nil, errors.New("bot_id is required")
|
|
}
|
|
bindings, err := p.getBindings(ctx, botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
binding := resolveReadBinding(bindings, StringArg(args, "provider_id"))
|
|
if binding == nil {
|
|
return nil, errors.New("email read permission denied or provider not found")
|
|
}
|
|
uidRaw, ok, _ := IntArg(args, "uid")
|
|
if !ok || uidRaw <= 0 {
|
|
uidStr := StringArg(args, "uid")
|
|
if uidStr != "" {
|
|
parsed, _ := strconv.Atoi(uidStr)
|
|
uidRaw = parsed
|
|
}
|
|
}
|
|
if uidRaw <= 0 {
|
|
return nil, errors.New("uid is required")
|
|
}
|
|
if uidRaw > math.MaxUint32 {
|
|
return nil, errors.New("uid out of range")
|
|
}
|
|
providerName, config, err := p.service.ProviderConfig(ctx, binding.EmailProviderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
config = ensureProviderID(config, binding.EmailProviderID)
|
|
reader, err := p.service.Registry().GetMailboxReader(providerName)
|
|
if err != nil {
|
|
return nil, errors.New("mailbox reading not supported for this provider")
|
|
}
|
|
item, err := reader.ReadMailbox(ctx, config, uint32(uidRaw)) //nolint:gosec // bounds checked above
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"uid": item.MessageID, "from": item.From, "to": item.To,
|
|
"subject": item.Subject, "body": item.BodyText, "received_at": item.ReceivedAt,
|
|
}, nil
|
|
}
|
|
|
|
func resolveReadBinding(bindings []email.BindingResponse, providerID string) *email.BindingResponse {
|
|
for i := range bindings {
|
|
if !bindings[i].CanRead {
|
|
continue
|
|
}
|
|
if providerID == "" || bindings[i].EmailProviderID == providerID {
|
|
return &bindings[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveWriteBinding(bindings []email.BindingResponse, providerID string) *email.BindingResponse {
|
|
for i := range bindings {
|
|
if !bindings[i].CanWrite {
|
|
continue
|
|
}
|
|
if providerID == "" || bindings[i].EmailProviderID == providerID {
|
|
return &bindings[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureProviderID(config map[string]any, providerID string) map[string]any {
|
|
if config == nil {
|
|
config = make(map[string]any)
|
|
} else {
|
|
copied := make(map[string]any, len(config)+1)
|
|
for k, v := range config {
|
|
copied[k] = v
|
|
}
|
|
config = copied
|
|
}
|
|
config["_provider_id"] = providerID
|
|
return config
|
|
}
|