From 5cca9d7ac6d9e7d118facfc589fd9963ff2ff0bd Mon Sep 17 00:00:00 2001 From: Plateau Nguyen Date: Fri, 24 Apr 2026 07:41:38 +0700 Subject: [PATCH] refactor(pancake): simplify private-reply to stateless DM (#951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CHANGELOG.md | 19 +++ go.mod | 1 + go.sum | 6 +- internal/channels/pancake/api_client_test.go | 20 ++-- internal/channels/pancake/comment_handler.go | 7 +- .../channels/pancake/comment_handler_test.go | 2 +- internal/channels/pancake/echo_dedup.go | 11 -- internal/channels/pancake/pancake.go | 59 ++++++---- internal/channels/pancake/pancake_test.go | 103 +++++++++-------- internal/channels/pancake/private_reply.go | 24 ++++ .../pancake/private_reply_rename_test.go | 66 +++++++++++ .../channels/pancake/private_reply_test.go | 109 ++++++++++++++++++ internal/channels/pancake/types.go | 6 +- internal/channels/routing_metadata.go | 4 +- internal/channels/routing_metadata_test.go | 25 ++++ ui/web/src/i18n/locales/en/channels.json | 10 +- ui/web/src/i18n/locales/vi/channels.json | 10 +- ui/web/src/i18n/locales/zh/channels.json | 10 +- .../pages/channels/channel-schemas.test.ts | 31 +++++ ui/web/src/pages/channels/channel-schemas.ts | 7 ++ 20 files changed, 421 insertions(+), 109 deletions(-) create mode 100644 internal/channels/pancake/private_reply.go create mode 100644 internal/channels/pancake/private_reply_rename_test.go create mode 100644 internal/channels/pancake/private_reply_test.go 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" },