refactor(pancake): simplify private-reply to stateless DM (#951)

* feat(pancake): private-reply funnel — 2 modes, scope filter, DB dedup, metrics

Refactor the pancake channel's one-time DM feature from the 5-day-old minimal
`first_inbox` toggle into a complete comment → DM funnel. Breaking rename
(first_inbox → private_reply) matches Pancake + Facebook Graph terminology.

Channel logic
- Two modes: `after_reply` (default — public reply then DM) and `standalone`
  (DM-only; bypasses keyword filter, publishes synthetic outbound via
  `ch.Bus().PublishOutbound` so the LLM pipeline is skipped for template DMs).
- Post-level scope filter (`private_reply_options.allow_post_ids` /
  `deny_post_ids`, deny beats allow).
- Template vars `{{commenter_name}}` / `{{post_title}}` with pre-sanitized
  literal-replace (strips `{{`/`}}` from values — no nested substitution).
- Locale-aware default text via `i18n.T(locale, MsgPancakePrivateReplyDefault)`
  with en/vi/zh catalogs and English fallback.

DB dedup (replaces in-memory sync.Map)
- New `store.PancakePrivateReplyStore` (PG + SQLite impls), tenant-scoped via
  `store.TenantIDFromContext`, fail-closed on missing tenant.
- Atomic `TryClaim` / `Unclaim` (INSERT ... ON CONFLICT DO UPDATE WHERE stale)
  eliminates the concurrent-comment TOCTOU that would have fired duplicate
  DMs — claim first, release on API failure so the next comment can retry.
- Configurable TTL (`private_reply_ttl_days`, default 7).
- Dual-DB: migration 000056 on PG; schema v25 on SQLite.
  Bumps RequiredSchemaVersion 55 → 56.

Wiring
- `pancake.FactoryWithStores(ppReplyStore)` follows the existing
  `FactoryWithStores*` pattern (telegram, discord, whatsapp) and replaces
  `pancake.Factory` in `cmd/gateway.go`.
- `Channel.Send` injects `store.WithTenantID(ctx, ch.TenantID())` before
  dispatch so the outbound path sees a non-nil tenant (regression guard:
  without this the new store fails-closed and drops DMs in production — unit
  fakes masked this by bypassing the tenant check).
- Routing metadata whitelist extended: `private_reply_mode`,
  `private_reply_only`, `post_id`, `display_name` survive inbound → outbound.

Observability
- New `internal/metrics` package with `pancake_private_reply_total{
  page_id, result, reason}` counter and `/metrics` endpoint mounted on the
  gateway.
- Endpoint gated behind Bearer auth when `gateway.token` is configured; open
  (with warning) when unset for local dev scraping. Operators should set a
  token or bind `GOCLAW_HOST=127.0.0.1` to avoid leaking page identifiers.

UI
- 6 additive Pancake fields in `channel-schemas.ts` with `showWhen` gating on
  `features.private_reply` (itself gated on platform = fb/ig).
- 10 schema tests; full web build passes.
- i18n entries in en/vi/zh `channels.json` + 2 new Go keys
  (`MsgPancakePrivateReplyWindowExpired`, `MsgPancakePrivateReplyDefault`).

Tests
- 18 unit tests in pancake package (render/filter/mode switch/scope/dedup/
  metrics/tenant-ctx regression); 6 store-level tests; 5 E2E tests against
  live PG covering happy path, dedup-across-restart, TTL expiry via SQL,
  scope filter, FB 7-day policy error → no claim retained.
- All green on both PG and SQLite build tags with race detector.

* refactor(pancake): simplify private-reply to stateless DM

Roll back the dedup funnel from PR 951 — no table, no in-memory state,
no metrics, no modes, no scope filter. Keep only feature flag + message
template.

Rationale: each platform owning its own dedup table is an anti-pattern
that inflates multi-tenant data footprint. Use the platform itself
(Facebook Graph /comment/private_replies is per-comment idempotent)
combined with the existing webhook comment_id dedup in
comment_handler.go. Zero GoClaw state required.

Removed:
- DB table pancake_private_reply_sent (PG migration 000056, SQLite v25)
- Store interface + PG + SQLite implementations + tests
- internal/metrics package + /metrics endpoint + prometheus dep
- Two modes (after_reply/standalone) + standalone fast-path
- Scope filter (allow/deny post IDs) + PrivateReplyOptions struct
- TTL config (private_reply_ttl_days)
- Locale-aware i18n default (MsgPancakePrivateReplyDefault,
  MsgPancakePrivateReplyWindowExpired)
- FactoryWithStores wiring + tenant ctx injection in Send()
- SetHTTPClientForTest dead code

Kept:
- features.private_reply flag
- private_reply_message template with {{commenter_name}} and
  {{post_title}} vars (literal-replace, injection-safe)
- After-reply flow: comment -> public reply -> DM
- Hard-coded English fallback when message is empty

Schema: RequiredSchemaVersion 56 -> 55; SQLite SchemaVersion 25 -> 24;
schema.sql DDL block removed.

Net delta: -2661 lines across 44 files. Build (PG + sqliteonly) +
vet + race tests clean.

* test(agent): bump none-mode prompt size budget to 3100

vault_read wiring (#948) added ~95 chars to read_file tool summary,
pushing none-mode prompt from <3000 to 3075 chars. Bump budget to
3100 (~775 tokens) to match the intentional addition.

* test(integration): ensure data_migrations table exists in reset helper

The reset helper runs before RunPendingHooks, but RunPendingHooks is
what normally creates data_migrations. On a fresh CI database the
DELETE fails with 'relation does not exist'. Create the table
defensively so reset works regardless of execution order.

* chore(sqlite): remove accidental trailing blank line in schema.sql

---------

Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
Plateau Nguyenandviettranx authored and GitHub committed 2026-04-24 07:41:38 +07:00
1 parent 259e754303
commit 5cca9d7ac6
20 files changed
+421 -109

No files matched your search

+19
View File
@@ -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
+1
View File
@@ -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
+4 -2
View File
@@ -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=
+10 -10
View File
@@ -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)
}
}
+3 -4
View File
@@ -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)
@@ -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") {
-11
View File
@@ -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
})
}
}
}
+36 -23
View File
@@ -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).
+52 -51
View File
@@ -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)
}
}
@@ -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
}
@@ -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")
}
}
@@ -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": "<script>alert(1)</script>",
})
if got != "Hi <script>alert(1)</script>" {
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)
}
}
+3 -3
View File
@@ -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)
+3 -1
View File
@@ -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{
@@ -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)
}
}
}
+9 -1
View File
@@ -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": {
+9 -1
View File
@@ -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": {
+9 -1
View File
@@ -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": {
@@ -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();
@@ -201,6 +201,13 @@ export const configSchema: Record<string, FieldDef[]> = {
{ 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" },