mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-31 02:24:41 +00:00
Split 11 Go files exceeding 500 lines into smaller, focused files: - channels/manager.go → manager.go + dispatch.go + runs.go + events.go - channels/slack/channel.go → channel.go + send.go + utils.go - channels/slack/handlers.go → handlers.go + handlers_mention.go + handlers_files.go - channels/telegram/handlers.go → handlers.go + handlers_utils.go - channels/zalo/personal/channel.go → channel.go + send.go + listen.go + handlers.go - gateway/methods/agents.go → agents.go + agents_create.go + agents_update.go + agents_delete.go - http/summoner.go → summoner.go + summoner_regenerate.go + summoner_prompts.go + summoner_utils.go - http/skills.go → skills.go + skills_upload.go + skills_versions.go + skills_grants.go - http/mcp.go → mcp.go + mcp_tools.go + mcp_grants.go + mcp_requests.go - store/pg/cron.go → cron.go + cron_crud.go + cron_update.go + cron_exec.go No logic changes — pure file reorganization to keep files under 200 lines.
179 lines
5.3 KiB
Go
179 lines
5.3 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
|
|
}
|
|
|
|
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 temporary media files after successful (or failed) send.
|
|
// Files are created by tools (create_image, tts) and only needed for the send.
|
|
for _, media := range msg.Media {
|
|
if media.URL != "" {
|
|
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
|
|
}
|