Files
goclaw/internal/channels/dispatch.go
T
Plateau NguyenandGitHub fb8afd41bf feat(channels): add Facebook Messenger and Pancake channel integrations (#731)
Add two new channel implementations for Facebook Fanpage (comment + Messenger
auto-reply, first inbox DM) and Pancake/pages.fm (multi-platform inbox via
Facebook, Zalo, Instagram, TikTok, WhatsApp, LINE).

Key features:
- Facebook: comment auto-reply, Messenger auto-reply, first inbox DM,
  HMAC-SHA256 webhook verification, multi-page webhook routing
- Pancake: multi-platform inbox, outbound echo dedup with HTML normalization,
  race-condition-safe echo fingerprinting, platform-aware formatting
- Bootstrap skip: pre-fill USER.md from channel metadata (Pancake)
- SanitizeDisplayName across all channels (defense in depth)

Code audit fixes:
- Fix truncateForTikTok byte→rune slicing (UTF-8 corruption)
- Fix empty message.ID shared dedup slot (silent message loss)
- Fix DisplayName markdown injection in buildPrefilledUser
- Consolidate duplicate ChannelMeta type (agent→bootstrap)
- Compile-time interface assertions, alphabetical type constants
- Per-message logs demoted to slog.Debug, errors.As for wrapped errors
- UI: alphabetical channel ordering, complete config schemas
- Remove deprecated WhatsApp bridge_url, fix nested error parsing
2026-04-09 23:15:08 +07:00

203 lines
6.1 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,
"chat_id", msg.ChatID,
"content_len", len(msg.Content),
"content_preview", Truncate(msg.Content, 160),
"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
}