mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 08:11:23 +00:00
2cc9d68cdc
* fix(tts): config save + Edge provider registration + dark mode chat bubbles - Wrap TTS config payload in `raw` field for config.patch RPC (#229) - Always register Edge TTS provider (free, no API key) instead of gating on `enabled` flag - Fix low-contrast user message bubbles in dark mode chat * fix(tts): skip duplicate media dispatch when temp file already delivered When both the agent loop and the message tool dispatch the same TTS temp file, the first dispatch succeeds and cleanup deletes it. Filter out missing temp media files before sending to prevent "file not found" errors and spurious error notifications on Telegram/Slack/Discord. * feat(tts): include edge-tts in Docker image when Python enabled Edge TTS is free (no API key) and serves as a universal TTS fallback. Install it alongside Python in both ENABLE_PYTHON and ENABLE_FULL_SKILLS builds. * chore(docker): expose build args from .env for compose builds Pass ENABLE_OTEL, ENABLE_PYTHON, ENABLE_FULL_SKILLS as env-driven build args so .env can control Docker build features without editing docker-compose.yml directly. * fix(tts): hot-reload TTS config on settings change via pub/sub TTS providers were only registered at startup, so changing provider/API key via the Web UI had no effect until container restart. Add a tts-config-reload bus subscriber that rebuilds the TTS manager on config changes, matching the pattern used by quota, cron, and web_fetch. Always create a TtsTool at startup (even without providers) so the reload subscriber can populate it when settings are first configured. * fix(tts): protect TtsTool.UpdateManager with RWMutex to prevent data race UpdateManager() can be called from the config reload goroutine while Execute() reads t.manager concurrently from agent goroutines. Add sync.RWMutex following the same pattern as WebFetchTool.UpdatePolicy(). Also update setupTTS doc comment which incorrectly stated it could return nil — Edge TTS is now always registered. --------- Co-authored-by: viettranx <viettranx@gmail.com>
200 lines
6.0 KiB
Go
200 lines
6.0 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
)
|
|
|
|
// WebhookRoute holds a path and handler pair for mounting on the main gateway mux.
|
|
type WebhookRoute struct {
|
|
Path string
|
|
Handler http.Handler
|
|
}
|
|
|
|
// dispatchOutbound consumes outbound messages from the bus and routes them
|
|
// to the appropriate channel. Internal channels are silently skipped.
|
|
func (m *Manager) dispatchOutbound(ctx context.Context) {
|
|
slog.Info("outbound dispatcher started")
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Info("outbound dispatcher stopped")
|
|
return
|
|
default:
|
|
msg, ok := m.bus.SubscribeOutbound(ctx)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Skip internal channels
|
|
if IsInternalChannel(msg.Channel) {
|
|
continue
|
|
}
|
|
|
|
m.mu.RLock()
|
|
channel, exists := m.channels[msg.Channel]
|
|
m.mu.RUnlock()
|
|
|
|
if !exists {
|
|
slog.Warn("unknown channel for outbound message", "channel", msg.Channel)
|
|
continue
|
|
}
|
|
|
|
// Filter out temp media files that no longer exist (already sent by another dispatch).
|
|
if len(msg.Media) > 0 {
|
|
tmpDir := os.TempDir()
|
|
filtered := msg.Media[:0]
|
|
for _, media := range msg.Media {
|
|
if media.URL != "" && strings.HasPrefix(media.URL, tmpDir) {
|
|
if _, err := os.Stat(media.URL); err != nil {
|
|
slog.Debug("skipping already-delivered temp media", "path", media.URL)
|
|
continue
|
|
}
|
|
}
|
|
filtered = append(filtered, media)
|
|
}
|
|
msg.Media = filtered
|
|
// If only media was in this message and all files are gone, skip entirely.
|
|
if len(msg.Media) == 0 && msg.Content == "" {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if err := channel.Send(ctx, msg); err != nil {
|
|
slog.Error("error sending message to channel",
|
|
"channel", msg.Channel,
|
|
"error", err,
|
|
)
|
|
// Try to send a text-only error notification back to the chat.
|
|
// Only for media failures — text-only failures likely mean the chat
|
|
// is inaccessible (kicked, blocked, etc.) so retrying won't help.
|
|
if len(msg.Media) > 0 {
|
|
notifyMsg := bus.OutboundMessage{
|
|
Channel: msg.Channel,
|
|
ChatID: msg.ChatID,
|
|
Content: formatChannelSendError(err),
|
|
Metadata: sendErrorMeta(msg.Metadata),
|
|
}
|
|
if err2 := channel.Send(ctx, notifyMsg); err2 != nil {
|
|
slog.Warn("failed to send error notification",
|
|
"channel", msg.Channel, "error", err2)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up temp media files only. Workspace-generated files are preserved
|
|
// so they remain accessible via workspace/web UI after delivery.
|
|
tmpDir := os.TempDir()
|
|
for _, media := range msg.Media {
|
|
if media.URL != "" && strings.HasPrefix(media.URL, tmpDir) {
|
|
if err := os.Remove(media.URL); err != nil {
|
|
slog.Debug("failed to clean up media file", "path", media.URL, "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// WebhookHandlers returns all webhook handlers from channels that implement WebhookChannel.
|
|
// Used to mount webhook routes on the main gateway mux.
|
|
func (m *Manager) WebhookHandlers() []WebhookRoute {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
var routes []WebhookRoute
|
|
for _, ch := range m.channels {
|
|
if wh, ok := ch.(WebhookChannel); ok {
|
|
if path, handler := wh.WebhookHandler(); path != "" && handler != nil {
|
|
routes = append(routes, WebhookRoute{Path: path, Handler: handler})
|
|
}
|
|
}
|
|
}
|
|
return routes
|
|
}
|
|
|
|
// SendToChannel delivers a message to a specific channel by name.
|
|
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
|
|
m.mu.RLock()
|
|
channel, exists := m.channels[channelName]
|
|
m.mu.RUnlock()
|
|
|
|
if !exists {
|
|
return fmt.Errorf("channel %s not found", channelName)
|
|
}
|
|
|
|
msg := bus.OutboundMessage{
|
|
Channel: channelName,
|
|
ChatID: chatID,
|
|
Content: content,
|
|
}
|
|
|
|
return channel.Send(ctx, msg)
|
|
}
|
|
|
|
// --- Send error notification helpers ---
|
|
|
|
// telegramAPIDescRe extracts the human-readable description from Telegram Bot API errors.
|
|
// Example: `telego: sendPhoto: api: 400 "Bad Request: not enough rights to send photos to the chat"`
|
|
//
|
|
// → "not enough rights to send photos to the chat"
|
|
var telegramAPIDescRe = regexp.MustCompile(`"Bad Request:\s*(.+?)"`)
|
|
|
|
// formatChannelSendError converts a channel.Send error into a user-friendly message.
|
|
// Never exposes raw library/HTTP details.
|
|
func formatChannelSendError(err error) string {
|
|
raw := err.Error()
|
|
lower := strings.ToLower(raw)
|
|
|
|
// Telegram "Bad Request: <description>" — extract description
|
|
if m := telegramAPIDescRe.FindStringSubmatch(raw); len(m) == 2 {
|
|
return fmt.Sprintf("⚠️ Send failed: %s", m[1])
|
|
}
|
|
|
|
// Common Telegram API errors (non-Bad Request)
|
|
switch {
|
|
case strings.Contains(lower, "not enough rights"):
|
|
return "⚠️ Send failed: bot doesn't have permission to send this type of message."
|
|
case strings.Contains(lower, "chat not found"):
|
|
return "⚠️ Send failed: chat not found."
|
|
case strings.Contains(lower, "bot was blocked"):
|
|
return "⚠️ Send failed: bot was blocked by the user."
|
|
case strings.Contains(lower, "user is deactivated"):
|
|
return "⚠️ Send failed: user account is deactivated."
|
|
case strings.Contains(lower, "too many requests") || strings.Contains(lower, "flood"):
|
|
return "⚠️ Send failed: rate limited by Telegram. Please try again later."
|
|
case strings.Contains(lower, "file is too big") || strings.Contains(lower, "wrong file"):
|
|
return "⚠️ Send failed: file is too large or invalid for Telegram."
|
|
}
|
|
|
|
// Generic fallback — don't expose internals
|
|
return "⚠️ Failed to deliver message. Check bot logs for details."
|
|
}
|
|
|
|
// sendErrorMeta copies only the routing fields from outbound metadata.
|
|
// Strips reply_to_message_id, placeholder_key, audio_as_voice, etc.
|
|
// that could cause unintended side effects on the error notification.
|
|
func sendErrorMeta(orig map[string]string) map[string]string {
|
|
if orig == nil {
|
|
return nil
|
|
}
|
|
meta := make(map[string]string)
|
|
for _, k := range []string{"local_key", "message_thread_id"} {
|
|
if v := orig[k]; v != "" {
|
|
meta[k] = v
|
|
}
|
|
}
|
|
if len(meta) == 0 {
|
|
return nil
|
|
}
|
|
return meta
|
|
}
|