diff --git a/docs/17-changelog.md b/docs/17-changelog.md index 351b7354..780a75ef 100644 --- a/docs/17-changelog.md +++ b/docs/17-changelog.md @@ -21,6 +21,24 @@ Completes issue #952: moved `web_search` configuration to tenant-exclusive setti --- +### Pancake auto-react allow/deny scope filter (2026-04-17) + +Extends the existing Facebook comment auto-react (like) feature with per-channel scope control. + +#### Added + +- `pancakeInstanceConfig.AutoReactOptions` (pointer type) in `internal/channels/pancake/types.go` — `allow_post_ids`, `deny_post_ids`, `allow_user_ids`, `deny_user_ids` string slices. Nil = no filter (react all). +- `filterAutoReact()` + `containsString()` helpers in `internal/channels/pancake/comment_handler.go`. Pure function; deny lists override allow lists; whitespace-trimmed string equality. +- Rollout-phase `slog.Info("pancake: auto-react filtered by allow/deny list")` fires when a gate-passing comment gets filtered (downgrade to Debug after ~2 weeks). +- UI: `features.auto_react` toggle surface fix (previously required raw JSON edit) + 4 tags fields in `ui/web/src/pages/channels/channel-schemas.ts`, gated on `platform=facebook` + `features.auto_react=true`. + +#### Behaviour + +- Zero-config (existing channels) = no scope filter = existing react-all behaviour. Backward compatible. +- Deny list entry with matching post/user ID → skip react. Empty allow list = no allow filter. + +--- + ### Secure CLI grant enforcement — registered binaries now hard-deny ungranted exec (2026-04-17) Closes a credential-scope bypass: an agent with no `secure_cli_agent_grants` row for a registered binary could still invoke it via shell fallthrough (`gh ...`, `sh -c 'gh ...'`) and pick up inherited env (`$GH_TOKEN`) or on-disk OAuth state (`~/.config/gh`). Registration is now an authorization boundary, not only a credential-injection hint. diff --git a/internal/channels/pancake/api_client_test.go b/internal/channels/pancake/api_client_test.go index 2eadf28d..bd35eaea 100644 --- a/internal/channels/pancake/api_client_test.go +++ b/internal/channels/pancake/api_client_test.go @@ -210,6 +210,62 @@ func TestConfigParsing_CommentReplyOptions(t *testing.T) { } } +func TestPancakeConfig_AutoReactOptionsRoundtrip(t *testing.T) { + src := `{ + "page_id": "123", + "platform": "facebook", + "features": {"auto_react": true}, + "auto_react_options": { + "allow_post_ids": ["post-1", "post-2"], + "deny_post_ids": ["post-3"], + "allow_user_ids": ["user-a"], + "deny_user_ids": ["user-b", "user-c"] + } + }` + var cfg pancakeInstanceConfig + if err := json.Unmarshal([]byte(src), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.AutoReactOptions == nil { + t.Fatal("AutoReactOptions should be non-nil when JSON has auto_react_options") + } + if len(cfg.AutoReactOptions.AllowPostIDs) != 2 { + t.Errorf("AllowPostIDs len = %d, want 2", len(cfg.AutoReactOptions.AllowPostIDs)) + } + if cfg.AutoReactOptions.DenyPostIDs[0] != "post-3" { + t.Errorf("DenyPostIDs[0] = %q, want post-3", cfg.AutoReactOptions.DenyPostIDs[0]) + } + if len(cfg.AutoReactOptions.DenyUserIDs) != 2 { + t.Errorf("DenyUserIDs len = %d, want 2", len(cfg.AutoReactOptions.DenyUserIDs)) + } + if cfg.AutoReactOptions.AllowUserIDs[0] != "user-a" { + t.Errorf("AllowUserIDs[0] = %q, want user-a", cfg.AutoReactOptions.AllowUserIDs[0]) + } +} + +func TestPancakeConfig_AutoReactOptionsOmitempty(t *testing.T) { + var cfg pancakeInstanceConfig + cfg.PageID = "123" + b, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(b), "auto_react_options") { + t.Errorf("empty AutoReactOptions should be omitted, got: %s", b) + } +} + +func TestPancakeConfig_AutoReactOptionsDefaultsEmpty(t *testing.T) { + src := `{"page_id": "123"}` + var cfg pancakeInstanceConfig + if err := json.Unmarshal([]byte(src), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.AutoReactOptions != nil { + t.Errorf("AutoReactOptions should be nil when absent, got %+v", cfg.AutoReactOptions) + } +} + func TestConfigParsing_Defaults(t *testing.T) { raw := `{"page_id":"123","features":{"inbox_reply":true}}` diff --git a/internal/channels/pancake/comment_handler.go b/internal/channels/pancake/comment_handler.go index a6c3d6f3..4364f0f4 100644 --- a/internal/channels/pancake/comment_handler.go +++ b/internal/channels/pancake/comment_handler.go @@ -61,12 +61,22 @@ func (ch *Channel) handleCommentEvent(data MessagingData) { // Auto-react BEFORE keyword filter — fires on all valid non-duplicate comments. // Independent of comment_reply: reacts even if reply is disabled. if ch.config.Features.AutoReact && ch.platform == "facebook" && data.Message.ID != "" { - select { - case ch.reactSem <- struct{}{}: - go ch.reactCommentAsync(data.ConversationID, data.Message.ID) - default: - slog.Debug("pancake: react semaphore full, dropping reaction", - "page_id", ch.pageID, "comment_id", data.Message.ID) + if filterAutoReact(&ch.config, data.PostID, data.Message.SenderID) { + select { + case ch.reactSem <- struct{}{}: + go ch.reactCommentAsync(data.ConversationID, data.Message.ID) + default: + slog.Debug("pancake: react semaphore full, dropping reaction", + "page_id", ch.pageID, "comment_id", data.Message.ID) + } + } else { + // Rollout-phase Info log for misconfiguration diagnosis. + // TODO: downgrade to slog.Debug after ~2 weeks post-release. + slog.Info("pancake: auto-react filtered by allow/deny list", + "page_id", ch.pageID, + "comment_id", data.Message.ID, + "post_id", data.PostID, + "sender_id", data.Message.SenderID) } } @@ -182,6 +192,44 @@ func (ch *Channel) reactCommentAsync(conversationID, messageID string) { "comment_id", messageID, "page_id", ch.pageID) } +// filterAutoReact returns true when allow/deny lists permit reacting. +// Only called after the fast-path gate (Features.AutoReact, platform, msgID) passes. +// Nil AutoReactOptions = no scope filter = allow. +// Deny lists evaluated before allow lists. +func filterAutoReact(cfg *pancakeInstanceConfig, postID, senderID string) bool { + opts := cfg.AutoReactOptions + if opts == nil { + return true + } + if containsString(opts.DenyUserIDs, senderID) { + return false + } + if containsString(opts.DenyPostIDs, postID) { + return false + } + if len(opts.AllowUserIDs) > 0 && !containsString(opts.AllowUserIDs, senderID) { + return false + } + if len(opts.AllowPostIDs) > 0 && !containsString(opts.AllowPostIDs, postID) { + return false + } + return true +} + +// containsString checks if target is present in ss after whitespace trim. +// Returns false on empty target to prevent false-positive match on blank sender. +func containsString(ss []string, target string) bool { + if target == "" { + return false + } + for _, s := range ss { + if strings.TrimSpace(s) == target { + return true + } + } + return false +} + // filterComment checks if the comment matches the configured filter. // Returns true if the comment should be processed. func (ch *Channel) filterComment(content string) bool { diff --git a/internal/channels/pancake/comment_handler_test.go b/internal/channels/pancake/comment_handler_test.go index 2b4c4be9..1588fb85 100644 --- a/internal/channels/pancake/comment_handler_test.go +++ b/internal/channels/pancake/comment_handler_test.go @@ -569,3 +569,39 @@ func TestHandleCommentEvent_AutoReact_EmptyMessageID(t *testing.T) { default: } } + +func TestFilterAutoReact_Matrix(t *testing.T) { + tests := []struct { + name string + opts *AutoReactOptions + postID, senderID string + want bool + }{ + {"nil opts → allow", nil, "p1", "u1", true}, + {"empty opts → allow", &AutoReactOptions{}, "p1", "u1", true}, + {"deny user match", &AutoReactOptions{DenyUserIDs: []string{"u1"}}, "p1", "u1", false}, + {"deny post match", &AutoReactOptions{DenyPostIDs: []string{"p1"}}, "p1", "u1", false}, + {"allow user miss (list non-empty)", &AutoReactOptions{AllowUserIDs: []string{"u2"}}, "p1", "u1", false}, + {"allow user hit", &AutoReactOptions{AllowUserIDs: []string{"u1"}}, "p1", "u1", true}, + {"allow post miss", &AutoReactOptions{AllowPostIDs: []string{"p2"}}, "p1", "u1", false}, + {"allow post hit", &AutoReactOptions{AllowPostIDs: []string{"p1"}}, "p1", "u1", true}, + {"deny beats allow (overlap)", &AutoReactOptions{ + AllowUserIDs: []string{"u1"}, + DenyUserIDs: []string{"u1"}, + }, "p1", "u1", false}, + {"deny user with whitespace trims", &AutoReactOptions{DenyUserIDs: []string{" u1 "}}, "p1", "u1", false}, + {"allow post with whitespace trims to match", &AutoReactOptions{AllowPostIDs: []string{" p1 "}}, "p1", "u1", true}, + {"empty senderID with deny empty-string → no match", &AutoReactOptions{DenyUserIDs: []string{""}}, "p1", "", true}, + {"empty sender + allow list → blocked", &AutoReactOptions{AllowUserIDs: []string{"u1"}}, "p1", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &pancakeInstanceConfig{AutoReactOptions: tt.opts} + got := filterAutoReact(cfg, tt.postID, tt.senderID) + if got != tt.want { + t.Errorf("filterAutoReact() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/channels/pancake/types.go b/internal/channels/pancake/types.go index dcb76b79..f4b25a5c 100644 --- a/internal/channels/pancake/types.go +++ b/internal/channels/pancake/types.go @@ -30,10 +30,20 @@ type pancakeInstanceConfig struct { Filter string `json:"filter"` // "all" | "keyword" (default: all) Keywords []string `json:"keywords"` // required when filter = "keyword" } `json:"comment_reply_options"` - 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) + 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) +} + +// AutoReactOptions holds per-page scope filters for Facebook auto-react. +// Nil = no scope filter (react all). Deny lists override allow lists. +type AutoReactOptions struct { + AllowPostIDs []string `json:"allow_post_ids,omitempty"` + DenyPostIDs []string `json:"deny_post_ids,omitempty"` + AllowUserIDs []string `json:"allow_user_ids,omitempty"` + DenyUserIDs []string `json:"deny_user_ids,omitempty"` } // --- Webhook payload types --- diff --git a/ui/web/src/pages/channels/channel-schemas.test.ts b/ui/web/src/pages/channels/channel-schemas.test.ts index 6bf564e7..bf1b90c6 100644 --- a/ui/web/src/pages/channels/channel-schemas.test.ts +++ b/ui/web/src/pages/channels/channel-schemas.test.ts @@ -40,4 +40,24 @@ describe("pancake configSchema", () => { expect(values).not.toContain("whatsapp"); expect(values).not.toContain("zalo_oa"); }); + + it("has features.auto_react boolean toggle gated on platform=facebook", () => { + const f = pancakeConfig.find((x) => x.key === "features.auto_react"); + expect(f).toBeDefined(); + expect(f!.type).toBe("boolean"); + expect(f!.defaultValue).toBe(false); + expect(f!.showWhen).toEqual({ key: "platform", value: "facebook" }); + }); + + it.each([ + "auto_react_options.allow_post_ids", + "auto_react_options.deny_post_ids", + "auto_react_options.allow_user_ids", + "auto_react_options.deny_user_ids", + ])("has %s as tags field gated by features.auto_react", (key) => { + const f = pancakeConfig.find((x) => x.key === key); + expect(f).toBeDefined(); + expect(f!.type).toBe("tags"); + expect(f!.showWhen).toEqual({ key: "features.auto_react", value: "true" }); + }); }); diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index a24654fb..fc3cd00d 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -201,6 +201,22 @@ 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.auto_react", label: "Auto-React (Like) Comments", type: "boolean", + defaultValue: false, + showWhen: { key: "platform", value: "facebook" }, + help: "Automatically like Facebook comments. Set webhook_secret for security." }, + { key: "auto_react_options.allow_post_ids", label: "Auto-React: Allow Post IDs", type: "tags", + showWhen: { key: "features.auto_react", value: "true" }, + help: "Only react on these post IDs. Empty = all posts. Deny list overrides." }, + { key: "auto_react_options.deny_post_ids", label: "Auto-React: Deny Post IDs", type: "tags", + showWhen: { key: "features.auto_react", value: "true" }, + help: "Never react on these post IDs." }, + { key: "auto_react_options.allow_user_ids", label: "Auto-React: Allow User IDs", type: "tags", + showWhen: { key: "features.auto_react", value: "true" }, + help: "Only react to comments from these user IDs. Empty = all users. Deny list overrides." }, + { key: "auto_react_options.deny_user_ids", label: "Auto-React: Deny User IDs", type: "tags", + showWhen: { key: "features.auto_react", value: "true" }, + help: "Never react to comments from these user IDs." }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "Sender IDs to whitelist. Empty = accept all." }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit" }, ],