- Introduced Weixin integration support with new configuration options for WeChat iLink Bot. - Updated existing adapters (DingTalk, Discord, Feishu, Telegram) to include sender_id and app_id in message metadata for improved context handling. - Enhanced dispatcher logic to accommodate the new Weixin adapter and ensure proper initialization and shutdown processes. - Improved message handling across integrations to support typing indicators, providing a more interactive user experience.
508 lines
13 KiB
Go
508 lines
13 KiB
Go
package events
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yaoapp/gou/process"
|
|
"github.com/yaoapp/gou/text"
|
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
|
"github.com/yaoapp/yao/attachment"
|
|
eventtypes "github.com/yaoapp/yao/event/types"
|
|
"github.com/yaoapp/yao/messenger"
|
|
messengerTypes "github.com/yaoapp/yao/messenger/types"
|
|
)
|
|
|
|
// handleDelivery routes delivery content to configured channels (email, webhook, process).
|
|
func (h *robotHandler) handleDelivery(ctx context.Context, ev *eventtypes.Event, resp chan<- eventtypes.Result) {
|
|
var payload DeliveryPayload
|
|
if err := ev.Should(&payload); err != nil {
|
|
log.Error("delivery handler: invalid payload: %v", err)
|
|
if ev.IsCall {
|
|
resp <- eventtypes.Result{Err: err}
|
|
}
|
|
return
|
|
}
|
|
|
|
log.Info("delivery handler: execution=%s member=%s", payload.ExecutionID, payload.MemberID)
|
|
|
|
content := payload.Content
|
|
prefs := payload.Preferences
|
|
if content == nil {
|
|
log.Warn("delivery handler: nil content for execution=%s", payload.ExecutionID)
|
|
if ev.IsCall {
|
|
resp <- eventtypes.Result{Data: "no content"}
|
|
}
|
|
return
|
|
}
|
|
if prefs == nil {
|
|
if ev.IsCall {
|
|
resp <- eventtypes.Result{Data: "no preferences, skipped"}
|
|
}
|
|
return
|
|
}
|
|
|
|
deliveryCtx := &robottypes.DeliveryContext{
|
|
MemberID: payload.MemberID,
|
|
ExecutionID: payload.ExecutionID,
|
|
TeamID: payload.TeamID,
|
|
}
|
|
|
|
var results []robottypes.ChannelResult
|
|
var lastErr error
|
|
|
|
if prefs.Email != nil && prefs.Email.Enabled {
|
|
for _, target := range prefs.Email.Targets {
|
|
r := h.sendEmail(ctx, content, target, deliveryCtx)
|
|
results = append(results, r)
|
|
if !r.Success && lastErr == nil {
|
|
lastErr = fmt.Errorf("email delivery failed: %s", r.Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
if prefs.Webhook != nil && prefs.Webhook.Enabled {
|
|
for _, target := range prefs.Webhook.Targets {
|
|
r := h.postWebhook(ctx, content, target, deliveryCtx)
|
|
results = append(results, r)
|
|
if !r.Success && lastErr == nil {
|
|
lastErr = fmt.Errorf("webhook delivery failed: %s", r.Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
if prefs.Process != nil && prefs.Process.Enabled {
|
|
for _, target := range prefs.Process.Targets {
|
|
r := h.callProcess(ctx, content, target, deliveryCtx)
|
|
results = append(results, r)
|
|
if !r.Success && lastErr == nil {
|
|
lastErr = fmt.Errorf("process delivery failed: %s", r.Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Push delivery to integration channels only when the task originated from one
|
|
if reply := getReplyFunc(); reply != nil && payload.ChatID != "" {
|
|
channel, chatID := splitChannelChatID(payload.ChatID)
|
|
if channel != "" && chatID != "" {
|
|
msg := buildDeliveryMessage(content)
|
|
if msg != nil {
|
|
extra := map[string]any{
|
|
"member_id": payload.MemberID,
|
|
"execution_id": payload.ExecutionID,
|
|
}
|
|
for k, v := range payload.Extra {
|
|
extra[k] = v
|
|
}
|
|
senderID, _ := payload.Extra["sender_id"].(string)
|
|
appID, _ := payload.Extra["app_id"].(string)
|
|
metadata := &MessageMetadata{
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
SenderID: senderID,
|
|
AppID: appID,
|
|
Extra: extra,
|
|
}
|
|
if err := reply(ctx, msg, metadata); err != nil {
|
|
log.Error("delivery handler: integration reply failed channel=%s execution=%s: %v", channel, payload.ExecutionID, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if lastErr != nil {
|
|
log.Error("delivery handler: partial failure execution=%s: %v", payload.ExecutionID, lastErr)
|
|
}
|
|
|
|
if ev.IsCall {
|
|
resp <- eventtypes.Result{
|
|
Data: map[string]interface{}{
|
|
"execution_id": payload.ExecutionID,
|
|
"results": results,
|
|
},
|
|
Err: lastErr,
|
|
}
|
|
}
|
|
}
|
|
|
|
// buildDeliveryMessage converts DeliveryContent into a standard assistant Message.
|
|
func buildDeliveryMessage(content *robottypes.DeliveryContent) *agentcontext.Message {
|
|
if content == nil {
|
|
return nil
|
|
}
|
|
|
|
var parts []interface{}
|
|
|
|
text := content.Body
|
|
if text == "" {
|
|
text = content.Summary
|
|
}
|
|
if text != "" {
|
|
parts = append(parts, map[string]interface{}{
|
|
"type": "text",
|
|
"text": text,
|
|
})
|
|
}
|
|
|
|
for _, att := range content.Attachments {
|
|
if att.File == "" {
|
|
continue
|
|
}
|
|
part := map[string]interface{}{
|
|
"type": "file",
|
|
"file": map[string]interface{}{
|
|
"url": att.File,
|
|
"filename": att.Title,
|
|
},
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var msgContent interface{}
|
|
if len(parts) == 1 {
|
|
if tp, ok := parts[0].(map[string]interface{}); ok && tp["type"] == "text" {
|
|
msgContent = tp["text"]
|
|
} else {
|
|
msgContent = parts
|
|
}
|
|
} else {
|
|
msgContent = parts
|
|
}
|
|
|
|
return &agentcontext.Message{
|
|
Role: agentcontext.RoleAssistant,
|
|
Content: msgContent,
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Email
|
|
// ============================================================================
|
|
|
|
func (h *robotHandler) sendEmail(
|
|
ctx context.Context,
|
|
content *robottypes.DeliveryContent,
|
|
target robottypes.EmailTarget,
|
|
deliveryCtx *robottypes.DeliveryContext,
|
|
) robottypes.ChannelResult {
|
|
now := time.Now()
|
|
targetID := strings.Join(target.To, ",")
|
|
if targetID == "" {
|
|
targetID = "no-recipients"
|
|
}
|
|
|
|
result := robottypes.ChannelResult{
|
|
Type: robottypes.DeliveryEmail,
|
|
Target: targetID,
|
|
SentAt: &now,
|
|
}
|
|
|
|
svc := messenger.Instance
|
|
if svc == nil {
|
|
result.Error = "messenger service not available"
|
|
return result
|
|
}
|
|
|
|
htmlBody, plainBody := buildEmailBody(target.Template, content)
|
|
msg := &messengerTypes.Message{
|
|
To: target.To,
|
|
Subject: buildEmailSubject(target.Subject, target.Template, content, deliveryCtx),
|
|
Body: plainBody,
|
|
HTML: htmlBody,
|
|
Type: messengerTypes.MessageTypeEmail,
|
|
}
|
|
|
|
attachments := convertAttachments(ctx, content.Attachments)
|
|
if len(attachments) > 0 {
|
|
msg.Attachments = attachments
|
|
}
|
|
|
|
channel := robottypes.DefaultEmailChannel()
|
|
if err := svc.Send(ctx, channel, msg); err != nil {
|
|
result.Error = err.Error()
|
|
return result
|
|
}
|
|
|
|
result.Success = true
|
|
result.Recipients = target.To
|
|
return result
|
|
}
|
|
|
|
// ============================================================================
|
|
// Webhook
|
|
// ============================================================================
|
|
|
|
func (h *robotHandler) 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,
|
|
}
|
|
|
|
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,
|
|
},
|
|
}
|
|
|
|
if len(content.Attachments) > 0 {
|
|
info := make([]map[string]interface{}, 0, len(content.Attachments))
|
|
for _, att := range content.Attachments {
|
|
info = append(info, map[string]interface{}{
|
|
"title": att.Title,
|
|
"description": att.Description,
|
|
"task_id": att.TaskID,
|
|
"file": att.File,
|
|
})
|
|
}
|
|
payload["attachments"] = info
|
|
}
|
|
|
|
payloadBytes, err := json.Marshal(payload)
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("failed to marshal payload: %v", err)
|
|
return result
|
|
}
|
|
|
|
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")
|
|
for key, value := range target.Headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
if target.Secret != "" {
|
|
signature := ComputeHMACSignature(payloadBytes, target.Secret)
|
|
req.Header.Set("X-Yao-Signature", signature)
|
|
req.Header.Set("X-Yao-Signature-Algorithm", "HMAC-SHA256")
|
|
}
|
|
|
|
httpResp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("request failed: %v", err)
|
|
return result
|
|
}
|
|
defer httpResp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(httpResp.Body)
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
result.Error = fmt.Sprintf("webhook returned status %d: %s", httpResp.StatusCode, string(body))
|
|
return result
|
|
}
|
|
|
|
result.Success = true
|
|
result.Details = map[string]interface{}{
|
|
"status_code": httpResp.StatusCode,
|
|
"response": string(body),
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ============================================================================
|
|
// Process
|
|
// ============================================================================
|
|
|
|
func (h *robotHandler) 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,
|
|
}
|
|
|
|
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...)
|
|
|
|
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
|
|
|
|
if err = proc.Execute(); err != nil {
|
|
result.Error = err.Error()
|
|
return result
|
|
}
|
|
|
|
result.Success = true
|
|
result.Details = toJSONSerializable(proc.Value)
|
|
return result
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
func toJSONSerializable(v interface{}) interface{} {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if _, err := json.Marshal(v); err != nil {
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func buildEmailSubject(subject, template string, content *robottypes.DeliveryContent, ctx *robottypes.DeliveryContext) string {
|
|
if subject != "" {
|
|
return subject
|
|
}
|
|
if content.Summary != "" {
|
|
return content.Summary
|
|
}
|
|
return fmt.Sprintf("Execution %s Complete", ctx.ExecutionID)
|
|
}
|
|
|
|
func buildEmailBody(template string, content *robottypes.DeliveryContent) (string, string) {
|
|
markdown := content.Body
|
|
if markdown == "" {
|
|
markdown = content.Summary
|
|
}
|
|
html, err := text.MarkdownToHTML(markdown)
|
|
if err != nil {
|
|
return markdown, markdown
|
|
}
|
|
return html, markdown
|
|
}
|
|
|
|
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 {
|
|
uploader, fileID, isWrapper := attachment.Parse(att.File)
|
|
if !isWrapper {
|
|
log.Warn("convertAttachments: skipping non-wrapper file value=%q title=%q", att.File, att.Title)
|
|
continue
|
|
}
|
|
manager, ok := attachment.Managers[uploader]
|
|
if !ok {
|
|
log.Warn("convertAttachments: manager not found uploader=%q file=%q title=%q (available: %v)",
|
|
uploader, att.File, att.Title, attachmentManagerKeys())
|
|
continue
|
|
}
|
|
info, err := manager.Info(ctx, fileID)
|
|
if err != nil {
|
|
log.Warn("convertAttachments: failed to get file info fileID=%q uploader=%q: %v", fileID, uploader, err)
|
|
continue
|
|
}
|
|
content, err := manager.Read(ctx, fileID)
|
|
if err != nil {
|
|
log.Warn("convertAttachments: failed to read file fileID=%q uploader=%q: %v", fileID, uploader, err)
|
|
continue
|
|
}
|
|
|
|
filename := info.Filename
|
|
if att.Title != "" {
|
|
ext := ""
|
|
if idx := strings.LastIndex(info.Filename, "."); idx >= 0 {
|
|
ext = info.Filename[idx:]
|
|
}
|
|
titleExt := ""
|
|
if idx := strings.LastIndex(att.Title, "."); idx >= 0 {
|
|
titleExt = att.Title[idx:]
|
|
}
|
|
if titleExt != "" {
|
|
filename = att.Title
|
|
} else {
|
|
filename = att.Title + ext
|
|
}
|
|
}
|
|
|
|
log.Info("convertAttachments: added attachment filename=%q contentType=%q size=%d", filename, info.ContentType, len(content))
|
|
result = append(result, messengerTypes.Attachment{
|
|
Filename: filename,
|
|
ContentType: info.ContentType,
|
|
Content: content,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func attachmentManagerKeys() []string {
|
|
keys := make([]string, 0, len(attachment.Managers))
|
|
for k := range attachment.Managers {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// ComputeHMACSignature computes HMAC-SHA256 signature for webhook payload.
|
|
func ComputeHMACSignature(payload []byte, secret string) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(payload)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// VerifyHMACSignature verifies the HMAC-SHA256 signature of a webhook payload.
|
|
func VerifyHMACSignature(payload []byte, secret, signature string) bool {
|
|
expected := ComputeHMACSignature(payload, secret)
|
|
return hmac.Equal([]byte(expected), []byte(signature))
|
|
}
|
|
|
|
// splitChannelChatID splits a composite "channel:chatID" string (e.g. "telegram:8134167376")
|
|
// into its channel and chatID parts. If no colon is present, channel is empty.
|
|
func splitChannelChatID(composite string) (channel, chatID string) {
|
|
if idx := strings.Index(composite, ":"); idx >= 0 {
|
|
return composite[:idx], composite[idx+1:]
|
|
}
|
|
return "", composite
|
|
}
|