feat(pancake): auto-react allow/deny scope filter + UI toggle

Extends #919 with per-channel scope control for Facebook auto-react.

- AutoReactOptions pointer struct (allow/deny post & user IDs)
- filterAutoReact + containsString helpers; deny overrides allow
- UI surfaces features.auto_react toggle + 4 tags fields
- Nil options = react-all (backward compatible)
This commit is contained in:
viettranx committed 2026-04-18 17:55:11 +07:00
1 parent 2abb317e02
commit 9f68f3ee0d
7 files changed
+214 -10

No files matched your search

+18
View File
@@ -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.
@@ -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}}`
+54 -6
View File
@@ -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 {
@@ -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)
}
})
}
}
+14 -4
View File
@@ -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 ---
@@ -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" });
});
});
@@ -201,6 +201,22 @@ 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.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" },
],