mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 20:15:48 +00:00
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:
1 parent
2abb317e02
commit
9f68f3ee0d
7 files changed
+214
-10
No files matched your search
@@ -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}}`
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user