diff --git a/CHANGELOG.md b/CHANGELOG.md
index f640b6a7..7065f20d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,21 @@ All notable changes to GoClaw are documented here. For full documentation, see [
}
```
+### New Features
+
+- **Pancake private-reply (comment → DM).** Enables a one-time DM to commenters
+ after the public reply. Stateless on GoClaw side — no DB dedup table, no
+ in-memory state:
+ - Config: `features.private_reply` (bool) + `private_reply_message` (text).
+ - **Template variables** `{{commenter_name}}` and `{{post_title}}` with
+ literal-replace semantics (pre-sanitizes `{{`/`}}` from var values to
+ prevent var-in-var substitution).
+ - Empty `private_reply_message` → English fallback constant.
+ - **Dedup strategy**: webhook-level comment_id dedup (already in
+ `comment_handler.go`) + Facebook's per-comment idempotent `private_replies`
+ endpoint handle duplicates platform-side. No GoClaw state required.
+ - No DB migration.
+
### Improvements
- **Context pruning cleanup.** Removed redundant Pass 0 (per-result 30% guard),
@@ -30,6 +45,10 @@ All notable changes to GoClaw are documented here. For full documentation, see [
missing a `mode` field get auto-backfilled with `mode: "cache-ttl"` to
preserve their intent after the opt-in flip. Rows with NULL config stay
NULL (new opt-in default applies). PG migration 51; SQLite schema v19.
+- **Pancake channel metadata routing.** Whitelist in
+ `internal/channels/routing_metadata.go` now preserves `post_id` and
+ `display_name` across the inbound → outbound hop so the private-reply
+ template variables survive the agent pipeline round-trip.
## Project Status
diff --git a/go.mod b/go.mod
index 2779b944..254e0415 100644
--- a/go.mod
+++ b/go.mod
@@ -128,6 +128,7 @@ require (
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
diff --git a/go.sum b/go.sum
index c6790de7..faea3b88 100644
--- a/go.sum
+++ b/go.sum
@@ -407,8 +407,8 @@ github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyf
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
-github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -565,6 +565,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
diff --git a/internal/channels/pancake/api_client_test.go b/internal/channels/pancake/api_client_test.go
index bd35eaea..4e357503 100644
--- a/internal/channels/pancake/api_client_test.go
+++ b/internal/channels/pancake/api_client_test.go
@@ -180,9 +180,9 @@ func TestGetPosts_ErrorResponse(t *testing.T) {
func TestConfigParsing_CommentReplyOptions(t *testing.T) {
raw := `{
"page_id": "123",
- "features": {"comment_reply": true, "first_inbox": true},
+ "features": {"comment_reply": true, "private_reply": true},
"comment_reply_options": {"filter": "keyword", "keywords": ["price", "buy"]},
- "first_inbox_message": "Thanks!",
+ "private_reply_message": "Thanks!",
"post_context_cache_ttl": "30m"
}`
@@ -191,8 +191,8 @@ func TestConfigParsing_CommentReplyOptions(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
- if !cfg.Features.FirstInbox {
- t.Error("Features.FirstInbox should be true")
+ if !cfg.Features.PrivateReply {
+ t.Error("Features.PrivateReply should be true")
}
if cfg.CommentReplyOptions.Filter != "keyword" {
t.Errorf("Filter = %q, want %q", cfg.CommentReplyOptions.Filter, "keyword")
@@ -202,8 +202,8 @@ func TestConfigParsing_CommentReplyOptions(t *testing.T) {
cfg.CommentReplyOptions.Keywords[1] != "buy" {
t.Errorf("Keywords = %v, want [price buy]", cfg.CommentReplyOptions.Keywords)
}
- if cfg.FirstInboxMessage != "Thanks!" {
- t.Errorf("FirstInboxMessage = %q, want %q", cfg.FirstInboxMessage, "Thanks!")
+ if cfg.PrivateReplyMessage != "Thanks!" {
+ t.Errorf("PrivateReplyMessage = %q, want %q", cfg.PrivateReplyMessage, "Thanks!")
}
if cfg.PostContextCacheTTL != "30m" {
t.Errorf("PostContextCacheTTL = %q, want %q", cfg.PostContextCacheTTL, "30m")
@@ -274,14 +274,14 @@ func TestConfigParsing_Defaults(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
- if cfg.Features.FirstInbox {
- t.Error("Features.FirstInbox should default to false")
+ if cfg.Features.PrivateReply {
+ t.Error("Features.PrivateReply should default to false")
}
if cfg.CommentReplyOptions.Filter != "" {
t.Errorf("CommentReplyOptions.Filter should default to empty, got %q", cfg.CommentReplyOptions.Filter)
}
- if cfg.FirstInboxMessage != "" {
- t.Errorf("FirstInboxMessage should default to empty, got %q", cfg.FirstInboxMessage)
+ if cfg.PrivateReplyMessage != "" {
+ t.Errorf("PrivateReplyMessage should default to empty, got %q", cfg.PrivateReplyMessage)
}
}
diff --git a/internal/channels/pancake/comment_handler.go b/internal/channels/pancake/comment_handler.go
index 4364f0f4..c74a22be 100644
--- a/internal/channels/pancake/comment_handler.go
+++ b/internal/channels/pancake/comment_handler.go
@@ -13,13 +13,13 @@ import (
// handleCommentEvent processes a Pancake COMMENT webhook event.
// Mirrors the inbox handler pattern with additional comment-specific guards.
func (ch *Channel) handleCommentEvent(data MessagingData) {
- // Feature gate — exit only if BOTH reply and auto-react are disabled.
+ // Feature gate — exit if nothing to do.
if !ch.config.Features.CommentReply && !ch.config.Features.AutoReact {
ch.commentReplyDisabledOnce.Do(func() {
- slog.Info("pancake: comment ignored because comment_reply and auto_react are both disabled",
+ slog.Info("pancake: comment ignored because comment_reply and auto_react are disabled",
"page_id", ch.pageID,
"channel_name", ch.Name(),
- "hint", "enable config.features.comment_reply or config.features.auto_react")
+ "hint", "enable config.features.comment_reply or auto_react")
})
return
}
@@ -85,7 +85,6 @@ func (ch *Channel) handleCommentEvent(data MessagingData) {
return
}
- // Comment filter.
if !ch.filterComment(data.Message.Content) {
slog.Debug("pancake: comment filtered out",
"page_id", ch.pageID, "msg_id", data.Message.ID)
diff --git a/internal/channels/pancake/comment_handler_test.go b/internal/channels/pancake/comment_handler_test.go
index 1588fb85..45ff7d40 100644
--- a/internal/channels/pancake/comment_handler_test.go
+++ b/internal/channels/pancake/comment_handler_test.go
@@ -103,7 +103,7 @@ func TestHandleCommentEvent_FeatureDisabledLogsDiagnostic(t *testing.T) {
ch.handleCommentEvent(commentEvent("page-1", "conv-2", "user-2", "msg-2", "hello again"))
out := buf.String()
- if count := strings.Count(out, "comment_reply and auto_react are both disabled"); count != 1 {
+ if count := strings.Count(out, "comment_reply and auto_react are disabled"); count != 1 {
t.Fatalf("expected exactly one diagnostic log for disabled features, got %d logs:\n%s", count, out)
}
if !strings.Contains(out, "page-1") {
diff --git a/internal/channels/pancake/echo_dedup.go b/internal/channels/pancake/echo_dedup.go
index fde97a67..2695cddd 100644
--- a/internal/channels/pancake/echo_dedup.go
+++ b/internal/channels/pancake/echo_dedup.go
@@ -119,12 +119,7 @@ func normalizeEchoContent(content string) string {
return strings.TrimSpace(strings.Join(normalized, "\n"))
}
-// firstInboxSentTTL controls how long a senderID is retained in firstInboxSent.
-// After this period, the sender can receive the first-inbox DM again (e.g. new session after a long gap).
-const firstInboxSentTTL = 72 * time.Hour
-
// runDedupCleaner evicts dedup entries older than dedupTTL every dedupCleanEvery.
-// Also evicts firstInboxSent entries to bound memory growth on high-traffic pages.
func (ch *Channel) runDedupCleaner() {
ticker := time.NewTicker(dedupCleanEvery)
defer ticker.Stop()
@@ -146,12 +141,6 @@ func (ch *Channel) runDedupCleaner() {
}
return true
})
- ch.firstInboxSent.Range(func(k, v any) bool {
- if t, ok := v.(time.Time); ok && now.Sub(t) > firstInboxSentTTL {
- ch.firstInboxSent.Delete(k)
- }
- return true
- })
}
}
}
diff --git a/internal/channels/pancake/pancake.go b/internal/channels/pancake/pancake.go
index 9711936f..8a2a785d 100644
--- a/internal/channels/pancake/pancake.go
+++ b/internal/channels/pancake/pancake.go
@@ -45,10 +45,6 @@ type Channel struct {
// recentOutbound suppresses short-lived webhook echoes of our own text replies.
recentOutbound sync.Map // conversationID + "\x00" + normalized content → time.Time
- // firstInboxSent tracks which senders have already received the one-time first-inbox DM.
- // In-memory only: resets on restart (acceptable — re-sending once is benign).
- firstInboxSent sync.Map // senderID(string) → time.Time
-
// postFetcher fetches and caches page post content for comment context enrichment.
postFetcher *PostFetcher
@@ -259,16 +255,16 @@ func (ch *Channel) sendInboxReply(ctx context.Context, msg bus.OutboundMessage)
return nil
}
-// sendCommentReply replies to a comment and optionally sends a one-time first-inbox DM.
+// sendCommentReply posts a public reply to a comment and optionally sends a
+// one-time private DM to the commenter (best-effort). Stateless — no GoClaw
+// dedup state; webhook-level comment_id dedup + FB platform per-comment
+// idempotency prevent duplicates.
func (ch *Channel) sendCommentReply(ctx context.Context, msg bus.OutboundMessage) error {
- // Bound API calls: ReplyComment + PrivateReply can hang if Pancake is slow.
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
conversationID := msg.ChatID
- // Guard first — otherwise rememberOutboundEcho would stamp phantom echoes
- // for a send that never happens, polluting future inbound echo dedup.
commentID := msg.Metadata["reply_to_comment_id"]
if commentID == "" {
return fmt.Errorf("pancake: reply_to_comment_id missing in outbound metadata for comment reply")
@@ -279,7 +275,6 @@ func (ch *Channel) sendCommentReply(ctx context.Context, msg bus.OutboundMessage
for _, part := range parts {
ch.rememberOutboundEcho(conversationID, part)
}
-
for _, part := range parts {
if err := ch.apiClient.ReplyComment(ctx, conversationID, commentID, part); err != nil {
ch.handleAPIError(err)
@@ -288,32 +283,50 @@ func (ch *Channel) sendCommentReply(ctx context.Context, msg bus.OutboundMessage
}
}
- // First inbox: one-time DM after comment reply (best-effort).
- if ch.config.Features.FirstInbox {
+ if ch.config.Features.PrivateReply {
senderID := msg.Metadata["sender_id"]
if senderID != "" {
- ch.sendFirstInbox(ctx, senderID, conversationID)
+ ch.sendPrivateReply(
+ ctx,
+ senderID,
+ conversationID,
+ msg.Metadata["post_id"],
+ msg.Metadata["display_name"],
+ )
}
}
return nil
}
-// sendFirstInbox sends a one-time DM to a commenter (best-effort, fire-and-forget).
-// If the send fails, the firstInboxSent entry is deleted to allow retry on the next comment.
-func (ch *Channel) sendFirstInbox(ctx context.Context, senderID, conversationID string) {
- if _, loaded := ch.firstInboxSent.LoadOrStore(senderID, time.Now()); loaded {
- return // already sent to this sender
+// sendPrivateReply sends a one-time DM to a commenter (best-effort,
+// fire-and-forget). Idempotency relies on the caller-side webhook dedup +
+// Facebook's per-comment private_replies endpoint returning an error when a
+// DM was already sent — we log the warn and move on.
+func (ch *Channel) sendPrivateReply(ctx context.Context, senderID, conversationID, postID, commenterName string) {
+ if !ch.config.Features.PrivateReply || senderID == "" {
+ return
}
- message := ch.config.FirstInboxMessage
- if message == "" {
- message = "Thanks for your comment! We can assist you further via private message."
+
+ postTitle := ""
+ if postID != "" && ch.postFetcher != nil {
+ if post, perr := ch.postFetcher.GetPost(ctx, postID); perr == nil && post != nil {
+ postTitle = post.Message
+ }
}
+
+ message := renderPrivateReplyMessage(ch.config.PrivateReplyMessage, map[string]string{
+ "commenter_name": commenterName,
+ "post_title": postTitle,
+ })
+
if err := ch.apiClient.PrivateReply(ctx, conversationID, message); err != nil {
- slog.Warn("pancake: first inbox send failed",
- "sender_id", senderID, "err", err)
- ch.firstInboxSent.Delete(senderID) // allow retry on next comment
+ slog.Warn("pancake: private_reply send failed",
+ "page_id", ch.pageID, "sender_id", senderID, "conv_id", conversationID, "err", err)
+ return
}
+ slog.Debug("pancake: private_reply sent",
+ "page_id", ch.pageID, "sender_id", senderID, "conv_id", conversationID)
}
// BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default).
diff --git a/internal/channels/pancake/pancake_test.go b/internal/channels/pancake/pancake_test.go
index 7975267b..3752cc4b 100644
--- a/internal/channels/pancake/pancake_test.go
+++ b/internal/channels/pancake/pancake_test.go
@@ -745,10 +745,10 @@ func TestSend_CommentMode_MissingCommentID_ReturnsError(t *testing.T) {
}
}
-func TestSend_CommentMode_WithFirstInbox(t *testing.T) {
+func TestSend_CommentMode_WithPrivateReply(t *testing.T) {
cfg := pancakeInstanceConfig{}
- cfg.Features.FirstInbox = true
- cfg.FirstInboxMessage = "Thanks!"
+ cfg.Features.PrivateReply = true
+ cfg.PrivateReplyMessage = "Thanks!"
ch, transport := newChannelWithMultiCapture(t, cfg)
err := ch.Send(context.Background(), bus.OutboundMessage{
@@ -783,10 +783,13 @@ func TestSend_CommentMode_WithFirstInbox(t *testing.T) {
}
}
-func TestSend_CommentMode_FirstInboxDedup(t *testing.T) {
+func TestSend_CommentMode_PrivateReplyStateless(t *testing.T) {
+ // Stateless: each Send() with PrivateReply enabled fires a DM.
+ // Dedup responsibility lives at the webhook layer (comment_id) and
+ // at Facebook's platform (per-comment private_replies idempotency).
cfg := pancakeInstanceConfig{}
- cfg.Features.FirstInbox = true
- cfg.FirstInboxMessage = "DM!"
+ cfg.Features.PrivateReply = true
+ cfg.PrivateReplyMessage = "DM!"
ch, transport := newChannelWithMultiCapture(t, cfg)
outMsg := bus.OutboundMessage{
@@ -798,39 +801,33 @@ func TestSend_CommentMode_FirstInboxDedup(t *testing.T) {
"reply_to_comment_id": "msg-1",
},
}
- ch.Send(context.Background(), outMsg) //nolint:errcheck
- outMsg.ChatID = "conv-456" // second comment, different conv, same sender
+ ch.Send(context.Background(), outMsg) //nolint:errcheck
+ outMsg.ChatID = "conv-456"
outMsg.Metadata["reply_to_comment_id"] = "msg-2"
- ch.Send(context.Background(), outMsg) //nolint:errcheck
+ ch.Send(context.Background(), outMsg) //nolint:errcheck
transport.mu.Lock()
defer transport.mu.Unlock()
- // Expected: reply_comment x2, private_reply x1 (deduped on sender)
- if len(transport.reqs) != 3 {
- t.Fatalf("expected 3 requests (2x reply_comment + 1x private_reply), got %d", len(transport.reqs))
+ // 2x reply_comment + 2x private_reply = 4 requests (stateless)
+ if len(transport.reqs) != 4 {
+ t.Fatalf("expected 4 requests (2x reply_comment + 2x private_reply, stateless), got %d", len(transport.reqs))
}
- var actions []string
+ var privateCount int
for _, body := range transport.bodies {
var p map[string]any
json.Unmarshal(body, &p)
- if a, ok := p["action"].(string); ok {
- actions = append(actions, a)
- }
- }
- privateCount := 0
- for _, a := range actions {
- if a == "private_reply" {
+ if p["action"] == "private_reply" {
privateCount++
}
}
- if privateCount != 1 {
- t.Errorf("expected exactly 1 private_reply, got %d (actions: %v)", privateCount, actions)
+ if privateCount != 2 {
+ t.Errorf("expected 2 private_reply calls (stateless), got %d", privateCount)
}
}
-func TestSend_CommentMode_FirstInboxDisabled(t *testing.T) {
+func TestSend_CommentMode_PrivateReplyDisabled(t *testing.T) {
cfg := pancakeInstanceConfig{}
- cfg.Features.FirstInbox = false
+ cfg.Features.PrivateReply = false
ch, transport := newChannelWithMultiCapture(t, cfg)
ch.Send(context.Background(), bus.OutboundMessage{ //nolint:errcheck
@@ -851,7 +848,7 @@ func TestSend_CommentMode_FirstInboxDisabled(t *testing.T) {
var p map[string]any
json.Unmarshal(transport.bodies[0], &p)
if p["action"] == "private_reply" {
- t.Error("should not send private_reply when FirstInbox is disabled")
+ t.Error("should not send private_reply when PrivateReply is disabled")
}
}
@@ -898,14 +895,15 @@ func TestSend_CommentMode_EchoRemembered(t *testing.T) {
}
}
-// --- First Inbox ---
+// --- Private Reply ---
-func TestSendFirstInbox_DefaultMessage(t *testing.T) {
+func TestSendPrivateReply_DefaultMessage(t *testing.T) {
cfg := pancakeInstanceConfig{}
- cfg.FirstInboxMessage = "" // empty = use default
+ cfg.Features.PrivateReply = true
+ cfg.PrivateReplyMessage = "" // empty = use default
ch, transport := newChannelWithMultiCapture(t, cfg)
- ch.sendFirstInbox(context.Background(), "user-1", "conv-123")
+ ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
transport.mu.Lock()
defer transport.mu.Unlock()
@@ -919,16 +917,17 @@ func TestSendFirstInbox_DefaultMessage(t *testing.T) {
}
msg, _ := p["message"].(string)
if msg == "" {
- t.Error("expected non-empty default first inbox message")
+ t.Error("expected non-empty default private reply message")
}
}
-func TestSendFirstInbox_CustomMessage(t *testing.T) {
+func TestSendPrivateReply_CustomMessage(t *testing.T) {
cfg := pancakeInstanceConfig{}
- cfg.FirstInboxMessage = "Thanks for your comment!"
+ cfg.Features.PrivateReply = true
+ cfg.PrivateReplyMessage = "Thanks for your comment!"
ch, transport := newChannelWithMultiCapture(t, cfg)
- ch.sendFirstInbox(context.Background(), "user-1", "conv-123")
+ ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
transport.mu.Lock()
defer transport.mu.Unlock()
@@ -942,7 +941,9 @@ func TestSendFirstInbox_CustomMessage(t *testing.T) {
}
}
-func TestSendFirstInbox_ErrorRetryAllowed(t *testing.T) {
+func TestSendPrivateReply_APIErrorLoggedAndNonBlocking(t *testing.T) {
+ // Stateless: API errors are logged (warn) but do not prevent subsequent
+ // sends. No state to release. Second call still attempts the API.
errorTransport := &captureTransport{
resp: &http.Response{
StatusCode: http.StatusInternalServerError,
@@ -951,26 +952,25 @@ func TestSendFirstInbox_ErrorRetryAllowed(t *testing.T) {
},
}
cfg := pancakeInstanceConfig{}
- cfg.FirstInboxMessage = "DM"
+ cfg.Features.PrivateReply = true
+ cfg.PrivateReplyMessage = "DM"
msgBus := bus.New()
cfg.PageID = "page-123"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t"}
ch, _ := New(cfg, creds, msgBus, nil)
ch.apiClient.httpClient = &http.Client{Transport: errorTransport}
- // First call: API error → firstInboxSent entry should be deleted (allows retry).
- ch.sendFirstInbox(context.Background(), "user-1", "conv-123")
- _, alreadyStored := ch.firstInboxSent.Load("user-1")
- if alreadyStored {
- t.Error("firstInboxSent should be deleted on error (allow retry)")
+ ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
+ if errorTransport.req == nil {
+ t.Fatal("expected first API call to be attempted even when it errors")
}
- // Second call: should attempt again (retry allowed).
+ // Second call: still attempts the API — stateless behaviour.
secondTransport := &captureTransport{}
ch.apiClient.httpClient = &http.Client{Transport: secondTransport}
- ch.sendFirstInbox(context.Background(), "user-1", "conv-123")
+ ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
if secondTransport.req == nil {
- t.Error("expected retry request after error-deletion")
+ t.Error("expected retry request after previous failure (stateless, no per-sender dedup)")
}
}
@@ -1006,8 +1006,8 @@ func TestFactoryExplicitPlatformPreserved(t *testing.T) {
func TestCommentFlowEndToEnd(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
- cfg.Features.FirstInbox = true
- cfg.FirstInboxMessage = "Welcome!"
+ cfg.Features.PrivateReply = true
+ cfg.PrivateReplyMessage = "Welcome!"
transport := &multiCaptureTransport{}
msgBus := bus.New()
cfg.PageID = "page-e2e"
@@ -1076,7 +1076,7 @@ func TestCommentFlowEndToEnd(t *testing.T) {
t.Errorf("second action = %q, want private_reply", actions[1])
}
- // Step 6: Second comment from same sender — no second DM.
+ // Step 6: Second comment from same sender — stateless: another DM fires.
body2 := buildWebhookBody("page-e2e", "conv-e2e", "COMMENT", "user-e2e", "msg-e2e-2", "another comment", "")
req2 := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body2))
w2 := httptest.NewRecorder()
@@ -1089,8 +1089,8 @@ func TestCommentFlowEndToEnd(t *testing.T) {
t.Fatal("expected second inbound message")
}
outMsg2 := bus.OutboundMessage{
- ChatID: inMsg2.ChatID,
- Content: "thanks again",
+ ChatID: inMsg2.ChatID,
+ Content: "thanks again",
Metadata: inMsg2.Metadata,
}
ch.Send(context.Background(), outMsg2) //nolint:errcheck
@@ -1099,8 +1099,9 @@ func TestCommentFlowEndToEnd(t *testing.T) {
finalCount := len(transport.reqs)
transport.mu.Unlock()
- // 2 (first round) + 1 (second reply_comment only, no second private_reply)
- if finalCount != 3 {
- t.Errorf("expected 3 total requests after dedup, got %d", finalCount)
+ // 2 (first round: reply_comment + private_reply) + 2 (second: reply_comment + private_reply)
+ // Stateless — no per-sender dedup. FB's per-comment idempotency handles duplicates platform-side.
+ if finalCount != 4 {
+ t.Errorf("expected 4 total requests (stateless: 2 rounds × (reply + DM)), got %d", finalCount)
}
}
diff --git a/internal/channels/pancake/private_reply.go b/internal/channels/pancake/private_reply.go
new file mode 100644
index 00000000..9dca7732
--- /dev/null
+++ b/internal/channels/pancake/private_reply.go
@@ -0,0 +1,24 @@
+package pancake
+
+import "strings"
+
+// defaultPrivateReplyMsg is the English fallback when PrivateReplyMessage is
+// empty. Not localized by design — sellers set their own wording in config.
+const defaultPrivateReplyMsg = "Thanks for your comment! We'll DM you shortly."
+
+// renderPrivateReplyMessage substitutes {{key}} placeholders in tmpl with vars
+// values. Pre-sanitizes values (strips "{{" and "}}") so a value cannot inject
+// another placeholder. Empty tmpl falls back to defaultPrivateReplyMsg.
+// Unknown placeholders are left as-is.
+func renderPrivateReplyMessage(tmpl string, vars map[string]string) string {
+ if tmpl == "" {
+ tmpl = defaultPrivateReplyMsg
+ }
+ out := tmpl
+ for k, v := range vars {
+ safe := strings.ReplaceAll(v, "{{", "")
+ safe = strings.ReplaceAll(safe, "}}", "")
+ out = strings.ReplaceAll(out, "{{"+k+"}}", safe)
+ }
+ return out
+}
diff --git a/internal/channels/pancake/private_reply_rename_test.go b/internal/channels/pancake/private_reply_rename_test.go
new file mode 100644
index 00000000..60226726
--- /dev/null
+++ b/internal/channels/pancake/private_reply_rename_test.go
@@ -0,0 +1,66 @@
+package pancake
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+)
+
+// TestPrivateReply_StatelessFiresEveryCall verifies private_reply fires on
+// every Send() when Features.PrivateReply is enabled. Stateless design: no
+// GoClaw-side dedup. Webhook-level comment_id dedup + FB per-comment
+// idempotency handle duplicates; sender-level dedup intentionally removed.
+func TestPrivateReply_StatelessFiresEveryCall(t *testing.T) {
+ cfg := pancakeInstanceConfig{}
+ cfg.Features.PrivateReply = true
+ cfg.PrivateReplyMessage = "Hi {{commenter_name}}"
+ ch, transport := newChannelWithMultiCapture(t, cfg)
+
+ outMsg := bus.OutboundMessage{
+ ChatID: "conv-1",
+ Content: "public reply",
+ Metadata: map[string]string{
+ "pancake_mode": "comment",
+ "sender_id": "user-1",
+ "reply_to_comment_id": "comment-1",
+ "display_name": "Tuan",
+ },
+ }
+
+ if err := ch.Send(context.Background(), outMsg); err != nil {
+ t.Fatalf("first Send: %v", err)
+ }
+
+ outMsg.ChatID = "conv-2"
+ outMsg.Metadata["reply_to_comment_id"] = "comment-2"
+ if err := ch.Send(context.Background(), outMsg); err != nil {
+ t.Fatalf("second Send: %v", err)
+ }
+
+ transport.mu.Lock()
+ defer transport.mu.Unlock()
+
+ var privateReplyCount int
+ var lastBody string
+ for _, body := range transport.bodies {
+ var p map[string]any
+ if err := json.Unmarshal(body, &p); err != nil {
+ continue
+ }
+ if p["action"] == "private_reply" {
+ privateReplyCount++
+ if msg, _ := p["message"].(string); msg != "" {
+ lastBody = msg
+ }
+ }
+ }
+
+ if privateReplyCount != 2 {
+ t.Errorf("expected 2 private_reply calls (stateless, one per comment), got %d", privateReplyCount)
+ }
+ if lastBody != "Hi Tuan" {
+ t.Errorf("private_reply body = %q, want %q (template should render)", lastBody, "Hi Tuan")
+ }
+}
diff --git a/internal/channels/pancake/private_reply_test.go b/internal/channels/pancake/private_reply_test.go
new file mode 100644
index 00000000..b0acbd9b
--- /dev/null
+++ b/internal/channels/pancake/private_reply_test.go
@@ -0,0 +1,109 @@
+package pancake
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestRenderPrivateReplyMessage(t *testing.T) {
+ t.Run("empty template falls back to built-in English", func(t *testing.T) {
+ got := renderPrivateReplyMessage("", nil)
+ if got != defaultPrivateReplyMsg {
+ t.Errorf("empty tmpl = %q; want defaultPrivateReplyMsg", got)
+ }
+ if !strings.Contains(got, "Thanks") {
+ t.Errorf("default should mention thanks: %q", got)
+ }
+ })
+
+ t.Run("single var", func(t *testing.T) {
+ got := renderPrivateReplyMessage("Hi {{commenter_name}}", map[string]string{
+ "commenter_name": "Tuan",
+ })
+ if got != "Hi Tuan" {
+ t.Errorf("got %q", got)
+ }
+ })
+
+ t.Run("multiple vars", func(t *testing.T) {
+ got := renderPrivateReplyMessage("Hi {{commenter_name}} from {{post_title}}", map[string]string{
+ "commenter_name": "Tuan",
+ "post_title": "Xmas sale",
+ })
+ if got != "Hi Tuan from Xmas sale" {
+ t.Errorf("got %q", got)
+ }
+ })
+
+ t.Run("unknown placeholder left as-is", func(t *testing.T) {
+ got := renderPrivateReplyMessage("Hi {{unknown}}", map[string]string{
+ "commenter_name": "Tuan",
+ })
+ if got != "Hi {{unknown}}" {
+ t.Errorf("got %q; want placeholder preserved", got)
+ }
+ })
+
+ t.Run("var value with braces cannot inject new placeholder", func(t *testing.T) {
+ got := renderPrivateReplyMessage("Hi {{commenter_name}} from {{post_title}}", map[string]string{
+ "commenter_name": "{{post_title}}",
+ "post_title": "Xmas",
+ })
+ if strings.Contains(got, "{{") || strings.Contains(got, "}}") {
+ t.Errorf("render leaked braces: %q", got)
+ }
+ })
+
+ t.Run("html-like content passes through", func(t *testing.T) {
+ got := renderPrivateReplyMessage("Hi {{commenter_name}}", map[string]string{
+ "commenter_name": "",
+ })
+ if got != "Hi " {
+ t.Errorf("got %q", got)
+ }
+ })
+
+ t.Run("missing vars render placeholder verbatim", func(t *testing.T) {
+ got := renderPrivateReplyMessage("Hi {{commenter_name}} from {{post_title}}", map[string]string{
+ "commenter_name": "Tuan",
+ })
+ if got != "Hi Tuan from {{post_title}}" {
+ t.Errorf("got %q", got)
+ }
+ })
+}
+
+func TestPancakeConfig_PrivateReplyMessageRoundtrip(t *testing.T) {
+ cfg := pancakeInstanceConfig{
+ PrivateReplyMessage: "Hi {{commenter_name}}",
+ }
+ cfg.Features.PrivateReply = true
+
+ buf, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var round pancakeInstanceConfig
+ if err := json.Unmarshal(buf, &round); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if round.PrivateReplyMessage != "Hi {{commenter_name}}" {
+ t.Errorf("message = %q", round.PrivateReplyMessage)
+ }
+ if !round.Features.PrivateReply {
+ t.Errorf("feature flag lost")
+ }
+}
+
+func TestPancakeConfig_PrivateReplyMessageOmitempty(t *testing.T) {
+ cfg := pancakeInstanceConfig{PageID: "p1"}
+ buf, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if strings.Contains(string(buf), "private_reply_message") {
+ t.Errorf("expected private_reply_message omitted from empty config: %s", buf)
+ }
+}
diff --git a/internal/channels/pancake/types.go b/internal/channels/pancake/types.go
index f4b25a5c..610d43d4 100644
--- a/internal/channels/pancake/types.go
+++ b/internal/channels/pancake/types.go
@@ -22,16 +22,16 @@ type pancakeInstanceConfig struct {
Features struct {
InboxReply bool `json:"inbox_reply"`
CommentReply bool `json:"comment_reply"`
- FirstInbox bool `json:"first_inbox"` // send one-time DM to commenter after comment reply
- AutoReact bool `json:"auto_react"` // auto-like user comments on Facebook (platform=facebook only)
+ PrivateReply bool `json:"private_reply"` // send one-time DM to commenter (after comment reply or standalone)
+ AutoReact bool `json:"auto_react"` // auto-like user comments on Facebook (platform=facebook only)
} `json:"features"`
CommentReplyOptions struct {
IncludePostContext bool `json:"include_post_context"` // prepend post text to comment content
Filter string `json:"filter"` // "all" | "keyword" (default: all)
Keywords []string `json:"keywords"` // required when filter = "keyword"
} `json:"comment_reply_options"`
+ PrivateReplyMessage string `json:"private_reply_message,omitempty"` // custom DM text; defaults to built-in message. Supports {{commenter_name}} / {{post_title}} vars.
AutoReactOptions *AutoReactOptions `json:"auto_react_options,omitempty"`
- FirstInboxMessage string `json:"first_inbox_message,omitempty"` // custom DM text; defaults to built-in message
PostContextCacheTTL string `json:"post_context_cache_ttl,omitempty"` // e.g. "30m"; defaults to 15m
AllowFrom []string `json:"allow_from,omitempty"`
BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit)
diff --git a/internal/channels/routing_metadata.go b/internal/channels/routing_metadata.go
index 7de8b97f..606f368b 100644
--- a/internal/channels/routing_metadata.go
+++ b/internal/channels/routing_metadata.go
@@ -10,10 +10,12 @@ var routingMetaKeys = []string{
"group_id", // legacy group identifier
"feishu_reply_target_id", // feishu/lark thread reply routing
"fb_mode", // facebook messenger vs comment routing
- "sender_id", // facebook sender for first-inbox / pancake sender for first-inbox
+ "sender_id", // facebook sender for first-inbox / pancake sender for private-reply
"page_id", // facebook page routing
"reply_to_comment_id", // facebook/pancake comment reply target
"pancake_mode", // pancake inbox vs comment routing
+ "post_id", // pancake: post id for template vars
+ "display_name", // pancake: commenter display name for template vars
}
var finalReplyMetaKeys = append([]string{
diff --git a/internal/channels/routing_metadata_test.go b/internal/channels/routing_metadata_test.go
index 62d52654..1a668fa3 100644
--- a/internal/channels/routing_metadata_test.go
+++ b/internal/channels/routing_metadata_test.go
@@ -37,3 +37,28 @@ func TestCopyFinalRoutingMeta_PreservesPlaceholderAndPancakeMode(t *testing.T) {
t.Fatalf("CopyFinalRoutingMeta()[%q] = %q, want %q", "pancake_mode", got["pancake_mode"], "comment")
}
}
+
+// TestCopyRoutingMeta_PreservesPancakePrivateReplyKeys verifies the metadata
+// keys used by the private_reply DM (post_id, display_name, sender_id)
+// survive inbound→outbound copy.
+func TestCopyRoutingMeta_PreservesPancakePrivateReplyKeys(t *testing.T) {
+ src := map[string]string{
+ "post_id": "post-42",
+ "display_name": "Tuấn",
+ "sender_id": "user-1",
+ }
+
+ got := copyRoutingMeta(src)
+ for k, want := range src {
+ if got[k] != want {
+ t.Fatalf("copyRoutingMeta()[%q] = %q, want %q", k, got[k], want)
+ }
+ }
+
+ final := CopyFinalRoutingMeta(src)
+ for k, want := range src {
+ if final[k] != want {
+ t.Fatalf("CopyFinalRoutingMeta()[%q] = %q, want %q", k, final[k], want)
+ }
+ }
+}
diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json
index 19502329..f470c290 100644
--- a/ui/web/src/i18n/locales/en/channels.json
+++ b/ui/web/src/i18n/locales/en/channels.json
@@ -317,7 +317,15 @@
"help": "Restrict which tools the agent can use in this group"
},
"system_prompt": { "label": "System Prompt" },
- "platform": { "label": "Platform", "help": "Select the platform this Pancake page serves." }
+ "platform": { "label": "Platform", "help": "Select the platform this Pancake page serves." },
+ "features.private_reply": {
+ "label": "Private Reply (Comment → DM)",
+ "help": "Send a one-time DM to commenters after the public reply. Facebook/Instagram only. Meta allows DM within 7 days of the comment."
+ },
+ "private_reply_message": {
+ "label": "DM Message",
+ "help": "Supports the placeholders commenter_name and post_title. Empty = default English text."
+ }
},
"fieldOptions": {
"block_reply": {
diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json
index 710b5b2b..4895e5da 100644
--- a/ui/web/src/i18n/locales/vi/channels.json
+++ b/ui/web/src/i18n/locales/vi/channels.json
@@ -244,7 +244,15 @@
"skills": { "label": "Bộ lọc skill", "help": "Giới hạn skill khả dụng cho nhóm này" },
"tools": { "label": "Danh sách công cụ được phép", "help": "Giới hạn công cụ agent có thể dùng trong nhóm này" },
"system_prompt": { "label": "Prompt hệ thống" },
- "platform": { "label": "Nền tảng", "help": "Chọn nền tảng mà trang Pancake này phục vụ." }
+ "platform": { "label": "Nền tảng", "help": "Chọn nền tảng mà trang Pancake này phục vụ." },
+ "features.private_reply": {
+ "label": "Private Reply (Comment → DM)",
+ "help": "Gửi tin nhắn riêng cho người bình luận sau khi đã trả lời công khai. Chỉ Facebook/Instagram. Meta cho phép DM trong vòng 7 ngày kể từ khi có bình luận."
+ },
+ "private_reply_message": {
+ "label": "Nội dung DM",
+ "help": "Hỗ trợ biến commenter_name và post_title. Để trống dùng mặc định tiếng Anh."
+ }
},
"fieldOptions": {
"block_reply": {
diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json
index 3315484c..abab58a4 100644
--- a/ui/web/src/i18n/locales/zh/channels.json
+++ b/ui/web/src/i18n/locales/zh/channels.json
@@ -244,7 +244,15 @@
"skills": { "label": "Skill过滤", "help": "限制此群组可用的Skill" },
"tools": { "label": "工具白名单", "help": "限制Agent在此群组中可使用的工具" },
"system_prompt": { "label": "系统提示词" },
- "platform": { "label": "平台", "help": "选择此 Pancake 页面所服务的平台。" }
+ "platform": { "label": "平台", "help": "选择此 Pancake 页面所服务的平台。" },
+ "features.private_reply": {
+ "label": "私信回复(评论 → 私信)",
+ "help": "在公开回复后向评论者发送一次性私信。仅支持 Facebook/Instagram,Meta 允许在评论后 7 天内发送。"
+ },
+ "private_reply_message": {
+ "label": "私信内容",
+ "help": "支持 commenter_name 和 post_title 占位符。留空使用英文默认文本。"
+ }
},
"fieldOptions": {
"block_reply": {
diff --git a/ui/web/src/pages/channels/channel-schemas.test.ts b/ui/web/src/pages/channels/channel-schemas.test.ts
index bf1b90c6..6ef16a5a 100644
--- a/ui/web/src/pages/channels/channel-schemas.test.ts
+++ b/ui/web/src/pages/channels/channel-schemas.test.ts
@@ -41,6 +41,37 @@ describe("pancake configSchema", () => {
expect(values).not.toContain("zalo_oa");
});
+ it("exposes private_reply feature toggle gated on fb/ig only", () => {
+ const feat = pancakeConfig.find((f) => f.key === "features.private_reply");
+ expect(feat).toBeDefined();
+ expect(feat!.type).toBe("boolean");
+ expect(feat!.defaultValue).toBe(false);
+ expect(feat!.showWhen).toMatchObject({
+ key: "platform",
+ value: ["facebook", "instagram"],
+ });
+ });
+
+ it("exposes private_reply_message gated by the feature toggle", () => {
+ const msg = pancakeConfig.find((f) => f.key === "private_reply_message");
+ expect(msg).toBeDefined();
+ expect(msg!.type).toBe("textarea");
+ expect(msg!.showWhen).toEqual({ key: "features.private_reply", value: "true" });
+ });
+
+ it("does NOT expose removed private_reply config fields", () => {
+ const removed = [
+ "private_reply_mode",
+ "private_reply_only",
+ "private_reply_ttl_days",
+ "private_reply_options.allow_post_ids",
+ "private_reply_options.deny_post_ids",
+ ];
+ for (const key of removed) {
+ expect(pancakeConfig.find((f) => f.key === key), `field ${key} should be removed`).toBeUndefined();
+ }
+ });
+
it("has features.auto_react boolean toggle gated on platform=facebook", () => {
const f = pancakeConfig.find((x) => x.key === "features.auto_react");
expect(f).toBeDefined();
diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts
index fc3cd00d..3a70eb34 100644
--- a/ui/web/src/pages/channels/channel-schemas.ts
+++ b/ui/web/src/pages/channels/channel-schemas.ts
@@ -201,6 +201,13 @@ export const configSchema: Record = {
{ key: "features.inbox_reply", label: "Inbox Auto-Reply", type: "boolean", defaultValue: true },
{ key: "features.comment_reply", label: "Comment Reply", type: "boolean", defaultValue: false,
showWhen: { key: "platform", value: ["facebook", "instagram", "threads", "tiktok", "youtube"] } },
+ { key: "features.private_reply", label: "Private Reply (Comment → DM)", type: "boolean", defaultValue: false,
+ help: "Send a one-time DM to commenters after the public reply. Facebook/Instagram only. Meta allows DM within 7 days of the comment.",
+ showWhen: { key: "platform", value: ["facebook", "instagram"] } },
+ { key: "private_reply_message", label: "DM Message", type: "textarea",
+ help: "Supports {{commenter_name}} and {{post_title}}. Empty = default English text.",
+ placeholder: "Hi {{commenter_name}}! Thanks for commenting on \"{{post_title}}\". How can I help?",
+ showWhen: { key: "features.private_reply", value: "true" } },
{ key: "features.auto_react", label: "Auto-React (Like) Comments", type: "boolean",
defaultValue: false,
showWhen: { key: "platform", value: "facebook" },