Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/discord"
"github.com/nextlevelbuilder/goclaw/internal/channels/facebook"
"github.com/nextlevelbuilder/goclaw/internal/channels/pancake"
"github.com/nextlevelbuilder/goclaw/internal/channels/feishu"
slackchannel "github.com/nextlevelbuilder/goclaw/internal/channels/slack"
"github.com/nextlevelbuilder/goclaw/internal/channels/telegram"
Expand Down Expand Up @@ -565,6 +567,8 @@ func runGateway() {
instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB, pgStores.PendingMessages, "pgx"))
instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeFacebook, facebook.Factory)
instanceLoader.RegisterFactory(channels.TypePancake, pancake.Factory)
if err := instanceLoader.LoadAll(context.Background()); err != nil {
slog.Error("failed to load channel instances from DB", "error", err)
}
Expand Down
12 changes: 12 additions & 0 deletions cmd/gateway_consumer_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ func extractSessionMetadata(msg bus.InboundMessage, peerKind string) map[string]
return meta
}

// buildPancakeSessionLabel returns "Pancake:{senderName}:{pageName}" with non-empty parts only.
func buildPancakeSessionLabel(senderName, pageName string) string {
label := "Pancake"
if senderName != "" {
label += ":" + senderName
}
if pageName != "" {
label += ":" + pageName
}
return label
}

// buildAnnounceOutMeta builds outbound metadata for announce messages so that
// Send() can route replies to the correct forum topic or DM thread.
func buildAnnounceOutMeta(localKey string) map[string]string {
Expand Down
6 changes: 6 additions & 0 deletions cmd/gateway_consumer_normal.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ func processNormalMessage(
}
}

// Set session label for Pancake channels: "Pancake:{SenderName}:{PageName}"
if msg.Metadata["pancake_mode"] != "" {
label := buildPancakeSessionLabel(msg.Metadata["display_name"], msg.Metadata["page_name"])
deps.SessStore.SetLabel(ctx, sessionKey, label)
}

// Auto-collect channel contacts for the contact selector.
// Skip internal senders (system:*, notification:*, teammate:*, ticker:*, session_send_tool).
if deps.ContactCollector != nil && msg.SenderID != "" && !bus.IsInternalSender(msg.SenderID) {
Expand Down
4 changes: 2 additions & 2 deletions docs/05-channels-messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ Every channel must implement the base interface:
| Interface | Purpose | Implemented By |
|-----------|---------|----------------|
| `StreamingChannel` | Real-time streaming updates | Telegram, Slack |
| `WebhookChannel` | Webhook HTTP handler mounting | Feishu |
| `WebhookChannel` | Webhook HTTP handler mounting | Facebook, Feishu/Lark, Pancake |
| `ReactionChannel` | Status reactions on messages | Telegram, Slack, Feishu |
| `BlockReplyChannel` | Override gateway block_reply setting | Slack |
| `BlockReplyChannel` | Override gateway block_reply setting | Discord, Feishu/Lark, Pancake, Slack, Zalo OA, Zalo Personal |

`BaseChannel` provides a shared implementation that all channels embed: allowlist matching, `HandleMessage()`, `CheckPolicy()`, and user ID extraction.

Expand Down
2 changes: 2 additions & 0 deletions docs/17-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,11 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep
- **File viewer**: Improved workspace file view/download and storage depth control
- **Pairing DB errors**: Handle transient errors gracefully
- **Provider thinking**: Corrected DashScope per-model thinking logic
- **Pancake Page loop guard**: Narrowed webhook ingress to `messaging` + `INBOX` events and normalized HTML-formatted echoes before short-TTL outbound echo suppression, reducing Facebook Page self-reply loops in Pancake inbox conversations

### Documentation

- Updated `05-channels-messaging.md` — Refreshed `WebhookChannel` / `BlockReplyChannel` implementation tables for Facebook, Pancake, Discord, and Zalo-family channels
- Updated `18-http-api.md` — Added section 17 for Runtime & Packages Management endpoints
- Updated `09-security.md` — Added Docker entrypoint documentation, pkg-helper architecture, privilege separation
- Updated `17-changelog.md` — New entries for packages management, Docker security, and auth fix
Expand Down
2 changes: 2 additions & 0 deletions internal/channels/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ const (
TypeWhatsApp = "whatsapp"
TypeZaloOA = "zalo_oa"
TypeZaloPersonal = "zalo_personal"
TypeFacebook = "facebook"
TypePancake = "pancake"
)

// Channel defines the interface that all channel implementations must satisfy.
Expand Down
3 changes: 3 additions & 0 deletions internal/channels/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
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.
Expand Down
99 changes: 99 additions & 0 deletions internal/channels/facebook/comment_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package facebook

import (
"fmt"
"log/slog"
"strings"
)

// handleCommentEvent processes a feed webhook change where item == "comment".
func (ch *Channel) handleCommentEvent(entry WebhookEntry, change ChangeValue) {
// Feature gate.
if !ch.config.Features.CommentReply {
return
}

// Only process new comments (not edits or deletions).
if change.Verb != "add" {
return
}

// Page routing: ensure this event belongs to our page instance (before dedup write).
if entry.ID != ch.pageID {
return
}

// Self-reply prevention: skip comments posted by the page itself.
if change.From.ID == ch.pageID {
return
}

// Dedup: Facebook may deliver the same event more than once.
if ch.isDup("comment:" + change.CommentID) {
slog.Debug("facebook: duplicate comment event skipped", "comment_id", change.CommentID)
return
}

// Build message content — optionally enriched with post + thread context.
// Use stopCtx so inflight Graph API calls are cancelled when the channel stops.
content := change.Message
if ch.config.CommentReplyOptions.IncludePostContext && change.PostID != "" {
content = ch.buildEnrichedContent(change)
}

senderID := change.From.ID
// Session key groups all comments by the same user on the same post.
chatID := fmt.Sprintf("%s:%s", change.PostID, senderID)

metadata := map[string]string{
"comment_id": change.CommentID,
"post_id": change.PostID,
"parent_id": change.ParentID,
"sender_name": change.From.Name,
"sender_id": senderID,
"fb_mode": "comment",
"reply_to_comment_id": change.CommentID,
}

ch.HandleMessage(senderID, chatID, content, nil, metadata, "direct")
}

// buildEnrichedContent fetches post content and comment thread, assembles a rich
// context string for the agent. Uses stopCtx so it is cancelled on channel Stop().
func (ch *Channel) buildEnrichedContent(change ChangeValue) string {
ctx := ch.stopCtx

var sb strings.Builder

// Post content.
post, err := ch.postFetcher.GetPost(ctx, change.PostID)
if err == nil && post != nil && post.Message != "" {
sb.WriteString("[Bài đăng] ")
sb.WriteString(post.Message)
sb.WriteString("\n\n")
}

// Comment thread (when this is a nested reply).
if change.ParentID != "" {
depth := ch.config.CommentReplyOptions.MaxThreadDepth
if depth <= 0 {
depth = 10
}
thread, err := ch.postFetcher.GetCommentThread(ctx, change.ParentID, depth)
if err == nil && len(thread) > 0 {
sb.WriteString("[Thread]\n")
for _, c := range thread {
sb.WriteString(fmt.Sprintf("- %s: %s\n", c.From.Name, c.Message))
}
sb.WriteString("\n")
}
}

// Current comment.
sb.WriteString("[Comment mới] ")
sb.WriteString(change.From.Name)
sb.WriteString(": ")
sb.WriteString(change.Message)

return sb.String()
}
Loading
Loading